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

This commit is contained in:
Jean Chevronnet 2026-07-21 13:36:14 +00:00
parent acbf557104
commit 3b76454586
No known key found for this signature in database
6 changed files with 61 additions and 10 deletions

View file

@ -686,6 +686,15 @@ impl ChannelInfo {
.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.
pub fn akick_match(&self, target: &echo_api::BanTarget) -> Option<&ChanAkick> {
self.akick.iter().find(|k| echo_api::akick_matches(&k.mask, target))

View file

@ -1489,9 +1489,11 @@ impl Engine {
// A services bot assigned here is opped on join and is never subject
// to secureops/restricted — it's staff, not a member.
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
// 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}") });
}
// 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).
let is_bot = self.bot_uids.values().any(|b| b == &uid);
if op && !is_bot {
let account = self.network.account_of(&uid).map(str::to_string);
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();
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.
let is_bot = self.bot_uids.values().any(|b| b == &uid);
if voice && !is_bot {
let account = self.network.account_of(&uid).map(str::to_string);
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();
return self.finish(out, vec![NetAction::ChannelMode { from, channel, modes: format!("-v {uid}") }]);
}

View file

@ -5245,6 +5245,31 @@
}
// 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.
#[test]
fn access_change_notifies_the_affected_user() {