Add a deny-status (d) channel access flag that bars a user from op/voice
All checks were successful
CI / check (push) Successful in 5m21s
All checks were successful
CI / check (push) Successful in 5m21s
This commit is contained in:
parent
acbf557104
commit
3b76454586
6 changed files with 61 additions and 10 deletions
|
|
@ -1023,8 +1023,8 @@ pub struct ChanAccessView {
|
||||||
// via the same primitive, by GroupServ. Each letter grants a capability:
|
// via the same primitive, by GroupServ. Each letter grants a capability:
|
||||||
// f full/co-founder o auto-op O op commands h auto-halfop
|
// f full/co-founder o auto-op O op commands h auto-halfop
|
||||||
// v auto-voice t topic i invite a modify access list
|
// v auto-voice t topic i invite a modify access list
|
||||||
// s channel settings g greet shown
|
// s channel settings g greet shown d deny all status (barred from op/voice)
|
||||||
pub const ACCESS_FLAGS: &str = "foOhvtiasg";
|
pub const ACCESS_FLAGS: &str = "foOhvtiasgd";
|
||||||
|
|
||||||
/// A channel access rank, ordered lowest to highest. The derived `Ord` mirrors
|
/// A channel access rank, ordered lowest to highest. The derived `Ord` mirrors
|
||||||
/// the ircd's prefix ranks (voice < halfop < op < admin < founder), so services
|
/// the ircd's prefix ranks (voice < halfop < op < admin < founder), so services
|
||||||
|
|
@ -1122,12 +1122,13 @@ pub enum Flag {
|
||||||
Access, // a — manage the access / flags / akick lists
|
Access, // a — manage the access / flags / akick lists
|
||||||
Set, // s — change channel settings
|
Set, // s — change channel settings
|
||||||
Greet, // g — greeting shown on join
|
Greet, // g — greeting shown on join
|
||||||
|
NoStatus, // d — barred from ANY status: no auto op/voice, and services strip it
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Flag {
|
impl Flag {
|
||||||
/// Every flag, in canonical (display / ACCESS_FLAGS) order.
|
/// Every flag, in canonical (display / ACCESS_FLAGS) order.
|
||||||
pub const ALL: [Flag; 10] = [
|
pub const ALL: [Flag; 11] = [
|
||||||
Flag::Founder, Flag::AutoOp, Flag::Op, Flag::Halfop, Flag::Voice, Flag::Topic, Flag::Invite, Flag::Access, Flag::Set, Flag::Greet,
|
Flag::Founder, Flag::AutoOp, Flag::Op, Flag::Halfop, Flag::Voice, Flag::Topic, Flag::Invite, Flag::Access, Flag::Set, Flag::Greet, Flag::NoStatus,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The flag's letter. Exhaustive, so a new variant forces choosing its letter.
|
/// The flag's letter. Exhaustive, so a new variant forces choosing its letter.
|
||||||
|
|
@ -1143,6 +1144,7 @@ impl Flag {
|
||||||
Flag::Access => 'a',
|
Flag::Access => 'a',
|
||||||
Flag::Set => 's',
|
Flag::Set => 's',
|
||||||
Flag::Greet => 'g',
|
Flag::Greet => 'g',
|
||||||
|
Flag::NoStatus => 'd',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1181,7 +1183,10 @@ impl Flags {
|
||||||
let op = autoop || self.has(Flag::Op);
|
let op = autoop || self.has(Flag::Op);
|
||||||
let access = self.has(Flag::Access);
|
let access = self.has(Flag::Access);
|
||||||
// Auto-op plus access-list management is the SOP tier: auto protect + op.
|
// Auto-op plus access-list management is the SOP tier: auto protect + op.
|
||||||
let auto = if autoop {
|
// NoStatus (deny) overrides every status flag: the user is granted nothing.
|
||||||
|
let auto = if self.has(Flag::NoStatus) {
|
||||||
|
None
|
||||||
|
} else if autoop {
|
||||||
if access { Some("+ao") } else { Some("+o") }
|
if access { Some("+ao") } else { Some("+o") }
|
||||||
} else if self.has(Flag::Halfop) {
|
} else if self.has(Flag::Halfop) {
|
||||||
Some("+h")
|
Some("+h")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use echo_api::t;
|
||||||
// FLAGS <#channel> [account [+/-flags]]: the granular access model. With no
|
// FLAGS <#channel> [account [+/-flags]]: the granular access model. With no
|
||||||
// account, list the access entries and their flags; with an account, show or
|
// account, list the access entries and their flags; with an account, show or
|
||||||
// change its flags. Letters: f full o auto-op O op h auto-halfop v auto-voice
|
// change its flags. Letters: f full o auto-op O op h auto-halfop v auto-voice
|
||||||
// t topic i invite a access-list s settings g greet. Viewing needs op access;
|
// t topic i invite a access-list s settings g greet d deny-status. Viewing needs op access;
|
||||||
// changing needs the founder or the \x02a\x02 flag. Every stored level — a tier
|
// changing needs the founder or the \x02a\x02 flag. Every stored level — a tier
|
||||||
// preset ("op"/"sop"/…) or a raw flag string — resolves through `Flags`.
|
// preset ("op"/"sop"/…) or a raw flag string — resolves through `Flags`.
|
||||||
pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ const TOPICS: &[HelpEntry] = &[
|
||||||
HelpEntry { cmd: "LIST", summary: "list registered channels", detail: "Syntax: \x02LIST\x02\nLists registered channels." },
|
HelpEntry { cmd: "LIST", summary: "list registered channels", detail: "Syntax: \x02LIST\x02\nLists registered channels." },
|
||||||
HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER <account> | DESC <text> | URL [address] | EMAIL [address] | SUCCESSOR <account>|OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." },
|
HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER <account> | DESC <text> | URL [address] | EMAIL [address] | SUCCESSOR <account>|OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." },
|
||||||
HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>\x02\nManages the channel access list. The target may be a GroupServ \x02!group\x02; each of its members holding the group's \x02c\x02 flag then inherits the access." },
|
HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>\x02\nManages the channel access list. The target may be a GroupServ \x02!group\x02; each of its members holding the group's \x02c\x02 flag then inherits the access." },
|
||||||
HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags." },
|
HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags. Letters: \x02f\x02 full, \x02o\x02 auto-op, \x02O\x02 op, \x02h\x02 auto-halfop, \x02v\x02 auto-voice, \x02t\x02 topic, \x02i\x02 invite, \x02a\x02 access, \x02s\x02 settings, \x02g\x02 greet, \x02d\x02 deny status (bar the user from any op/voice)." },
|
||||||
HelpEntry { cmd: "LEVELS", summary: "tune which tier holds each capability", detail: "Syntax: \x02LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]\x02\nGrants a channel capability (\x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02) to an access tier (\x02VOP\x02/\x02HOP\x02/\x02AOP\x02/\x02SOP\x02) and above, e.g. let halfops manage AKICK. Additive — it never removes access. Founder only." },
|
HelpEntry { cmd: "LEVELS", summary: "tune which tier holds each capability", detail: "Syntax: \x02LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]\x02\nGrants a channel capability (\x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02) to an access tier (\x02VOP\x02/\x02HOP\x02/\x02AOP\x02/\x02SOP\x02) and above, e.g. let halfops manage AKICK. Additive — it never removes access. Founder only." },
|
||||||
HelpEntry { cmd: "SOP/AOP/HOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02SOP|AOP|HOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: SOP grants op plus access-list management, AOP grants op, HOP grants halfop, VOP grants voice." },
|
HelpEntry { cmd: "SOP/AOP/HOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02SOP|AOP|HOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: SOP grants op plus access-list management, AOP grants op, HOP grants halfop, VOP grants voice." },
|
||||||
HelpEntry { cmd: "STATUS", summary: "show a user's access", detail: "Syntax: \x02STATUS <#channel> [nick]\x02\nShows a user's access level on a channel." },
|
HelpEntry { cmd: "STATUS", summary: "show a user's access", detail: "Syntax: \x02STATUS <#channel> [nick]\x02\nShows a user's access level on a channel." },
|
||||||
|
|
|
||||||
|
|
@ -686,6 +686,15 @@ impl ChannelInfo {
|
||||||
.is_some_and(|a| echo_api::level_caps(&a.level).op)
|
.is_some_and(|a| echo_api::level_caps(&a.level).op)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `account` is barred from any channel status by the NoStatus (deny)
|
||||||
|
/// access flag — services grant them nothing and strip op/voice they're given.
|
||||||
|
pub fn denied(&self, account: &str) -> bool {
|
||||||
|
self.access
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.account.eq_ignore_ascii_case(account))
|
||||||
|
.is_some_and(|a| echo_api::Flags::from_level(&a.level).has(echo_api::Flag::NoStatus))
|
||||||
|
}
|
||||||
|
|
||||||
/// The auto-kick entry matching a live user (plain host mask or extban), if any.
|
/// The auto-kick entry matching a live user (plain host mask or extban), if any.
|
||||||
pub fn akick_match(&self, target: &echo_api::BanTarget) -> Option<&ChanAkick> {
|
pub fn akick_match(&self, target: &echo_api::BanTarget) -> Option<&ChanAkick> {
|
||||||
self.akick.iter().find(|k| echo_api::akick_matches(&k.mask, target))
|
self.akick.iter().find(|k| echo_api::akick_matches(&k.mask, target))
|
||||||
|
|
|
||||||
|
|
@ -1489,9 +1489,11 @@ impl Engine {
|
||||||
// A services bot assigned here is opped on join and is never subject
|
// A services bot assigned here is opped on join and is never subject
|
||||||
// to secureops/restricted — it's staff, not a member.
|
// to secureops/restricted — it's staff, not a member.
|
||||||
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
||||||
|
// A user barred by the deny flag never keeps status (#549).
|
||||||
|
let denied = account.as_deref().is_some_and(|a| self.db.channel(&channel).is_some_and(|c| c.denied(a)));
|
||||||
// SECUREOPS: a user who arrives opped (FJOIN prefix) but lacks op-level
|
// SECUREOPS: a user who arrives opped (FJOIN prefix) but lacks op-level
|
||||||
// access loses it, unless we're about to grant it to them anyway.
|
// access loses it, unless we're about to grant it to them anyway.
|
||||||
if op && !has_op_access && !is_bot && self.db.channel(&channel).is_some_and(|c| c.settings.secureops) {
|
if op && !is_bot && (denied || (!has_op_access && self.db.channel(&channel).is_some_and(|c| c.settings.secureops))) {
|
||||||
acts.push(NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("-o {uid}") });
|
acts.push(NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("-o {uid}") });
|
||||||
}
|
}
|
||||||
// RESTRICTED: only users with access (or opers) may be in the channel.
|
// RESTRICTED: only users with access (or opers) may be in the channel.
|
||||||
|
|
@ -1564,8 +1566,13 @@ impl Engine {
|
||||||
// to secureops (the Join path exempts it the same way).
|
// to secureops (the Join path exempts it the same way).
|
||||||
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
||||||
if op && !is_bot {
|
if op && !is_bot {
|
||||||
|
let account = self.network.account_of(&uid).map(str::to_string);
|
||||||
if let Some(c) = self.db.channel(&channel) {
|
if let Some(c) = self.db.channel(&channel) {
|
||||||
if c.settings.secureops && !self.network.account_of(&uid).is_some_and(|a| c.is_op(a)) {
|
// Strip +o for a denied user (always), or under secureops from a
|
||||||
|
// user without op-level access.
|
||||||
|
let denied = account.as_deref().is_some_and(|a| c.denied(a));
|
||||||
|
let secure = c.settings.secureops && !account.as_deref().is_some_and(|a| c.is_op(a));
|
||||||
|
if denied || secure {
|
||||||
let from = self.chan_service.clone().unwrap_or_default();
|
let from = self.chan_service.clone().unwrap_or_default();
|
||||||
return self.finish(out, vec![NetAction::ChannelMode { from, channel, modes: format!("-o {uid}") }]);
|
return self.finish(out, vec![NetAction::ChannelMode { from, channel, modes: format!("-o {uid}") }]);
|
||||||
}
|
}
|
||||||
|
|
@ -1579,8 +1586,13 @@ impl Engine {
|
||||||
// access loses it. A services bot is staff, never a member.
|
// access loses it. A services bot is staff, never a member.
|
||||||
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
||||||
if voice && !is_bot {
|
if voice && !is_bot {
|
||||||
|
let account = self.network.account_of(&uid).map(str::to_string);
|
||||||
if let Some(c) = self.db.channel(&channel) {
|
if let Some(c) = self.db.channel(&channel) {
|
||||||
if c.settings.securevoices && !self.network.account_of(&uid).is_some_and(|a| c.join_mode(a).is_some()) {
|
// Strip +v for a denied user (always), or under securevoices from
|
||||||
|
// a user without voice-level (or higher) access.
|
||||||
|
let denied = account.as_deref().is_some_and(|a| c.denied(a));
|
||||||
|
let secure = c.settings.securevoices && !account.as_deref().is_some_and(|a| c.join_mode(a).is_some());
|
||||||
|
if denied || secure {
|
||||||
let from = self.chan_service.clone().unwrap_or_default();
|
let from = self.chan_service.clone().unwrap_or_default();
|
||||||
return self.finish(out, vec![NetAction::ChannelMode { from, channel, modes: format!("-v {uid}") }]);
|
return self.finish(out, vec![NetAction::ChannelMode { from, channel, modes: format!("-v {uid}") }]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5245,6 +5245,31 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Being added to / removed from a channel's access list notifies the affected
|
// Being added to / removed from a channel's access list notifies the affected
|
||||||
|
// The NoStatus (deny) flag bars a user from any channel status: op/voice they
|
||||||
|
// are given is stripped straight back, regardless of secureops/securevoices.
|
||||||
|
#[test]
|
||||||
|
fn deny_flag_strips_op_and_voice() {
|
||||||
|
use echo_chanserv::ChanServ;
|
||||||
|
let path = std::env::temp_dir().join("echo-cs-deny.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
db.register("alice", "sesame", None).unwrap();
|
||||||
|
db.register("bob", "hunter2", None).unwrap();
|
||||||
|
db.register_channel("#c", "alice").unwrap();
|
||||||
|
db.access_add("#c", "bob", "d").unwrap();
|
||||||
|
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||||||
|
let cs = ChanServ { uid: "42SAAAAAB".into() };
|
||||||
|
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { msgid: None, from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY hunter2".into() });
|
||||||
|
e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#c".into(), op: false });
|
||||||
|
let op = e.handle(NetEvent::ChannelOp { channel: "#c".into(), uid: "000AAAAAC".into(), op: true });
|
||||||
|
assert!(op.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "-o 000AAAAAC")), "deny strips op: {op:?}");
|
||||||
|
let voice = e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAC".into(), voice: true });
|
||||||
|
assert!(voice.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "-v 000AAAAAC")), "deny strips voice: {voice:?}");
|
||||||
|
}
|
||||||
|
|
||||||
// user: a memo while they're offline, a direct notice while they're online.
|
// user: a memo while they're offline, a direct notice while they're online.
|
||||||
#[test]
|
#[test]
|
||||||
fn access_change_notifies_the_affected_user() {
|
fn access_change_notifies_the_affected_user() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue