1use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct Permissions(pub u64);
25
26impl Permissions {
27 pub const MANAGE_ROLES: u64 = 1 << 0;
28 pub const MANAGE_CHANNELS: u64 = 1 << 1;
29 pub const MANAGE_METADATA: u64 = 1 << 2;
30 pub const KICK: u64 = 1 << 3;
31 pub const BAN: u64 = 1 << 4;
32 pub const MANAGE_MESSAGES: u64 = 1 << 5;
33 pub const CREATE_INVITE: u64 = 1 << 6;
34 pub const VIEW_AUDIT_LOG: u64 = 1 << 8;
35 pub const MENTION_EVERYONE: u64 = 1 << 9;
36 pub const PIN_MESSAGES: u64 = 1 << 11;
39 pub const ADMIN_FOUNDING_MASK: u64 = Self::MANAGE_ROLES
49 | Self::MANAGE_CHANNELS
50 | Self::MANAGE_METADATA
51 | Self::KICK
52 | Self::BAN
53 | Self::MANAGE_MESSAGES
54 | Self::CREATE_INVITE
55 | Self::VIEW_AUDIT_LOG
56 | Self::MENTION_EVERYONE;
57
58 pub const ADMIN_ALL: u64 = Self::ADMIN_FOUNDING_MASK | Self::PIN_MESSAGES;
62
63 pub const MANAGEMENT_MASK: u64 = Self::MANAGE_ROLES
68 | Self::MANAGE_CHANNELS
69 | Self::MANAGE_METADATA
70 | Self::KICK
71 | Self::BAN
72 | Self::MANAGE_MESSAGES
73 | Self::CREATE_INVITE
74 | Self::VIEW_AUDIT_LOG
75 | Self::PIN_MESSAGES;
76
77 pub const STAFF_MASK: u64 = Self::MANAGE_ROLES
85 | Self::MANAGE_CHANNELS
86 | Self::MANAGE_METADATA
87 | Self::BAN
88 | Self::CREATE_INVITE
89 | Self::PIN_MESSAGES;
90
91 pub fn empty() -> Self {
92 Permissions(0)
93 }
94 pub fn admin() -> Self {
95 Permissions(Self::ADMIN_ALL)
96 }
97 pub fn is_management(self) -> bool {
100 self.0 & Self::MANAGEMENT_MASK != 0
101 }
102 pub fn contains(self, bits: u64) -> bool {
104 self.0 & bits == bits
105 }
106 pub fn union(self, other: Permissions) -> Permissions {
107 Permissions(self.0 | other.0)
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case", tag = "kind", content = "channel_id")]
115pub enum RoleScope {
116 Server,
117 Channel(String),
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct Role {
125 pub role_id: String,
127 pub name: String,
128 pub position: u32,
133 pub permissions: Permissions,
134 pub scope: RoleScope,
135 #[serde(default)]
137 pub color: u32,
138}
139
140impl Role {
141 pub fn admin(role_id: String) -> Self {
144 Role {
145 role_id,
146 name: "Admin".to_string(),
147 position: 1,
148 permissions: Permissions::admin(),
149 scope: RoleScope::Server,
150 color: 0,
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct MemberGrant {
160 pub member: String,
161 #[serde(default)]
162 pub role_ids: Vec<String>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
168pub struct CommunityRoles {
169 #[serde(default)]
170 pub roles: Vec<Role>,
171 #[serde(default)]
172 pub grants: Vec<MemberGrant>,
173}
174
175impl CommunityRoles {
176 pub fn role(&self, role_id: &str) -> Option<&Role> {
178 self.roles.iter().find(|r| r.role_id == role_id)
179 }
180
181 pub fn roles_of<'a>(&'a self, member_hex: &'a str) -> impl Iterator<Item = &'a Role> + 'a {
183 self.grants
184 .iter()
185 .filter(move |g| g.member == member_hex)
186 .flat_map(move |g| g.role_ids.iter())
187 .filter_map(move |rid| self.role(rid))
188 }
189
190 pub fn effective_permissions(&self, member_hex: &str) -> Permissions {
192 self.roles_of(member_hex)
193 .fold(Permissions::empty(), |acc, r| acc.union(r.permissions))
194 }
195
196 pub fn has_permission(&self, member_hex: &str, bits: u64) -> bool {
198 self.effective_permissions(member_hex).contains(bits)
199 }
200
201 pub fn highest_position(&self, member_hex: &str) -> Option<u32> {
204 self.roles_of(member_hex).map(|r| r.position).min()
205 }
206
207 pub fn is_privileged(&self, member_hex: &str) -> bool {
209 self.roles_of(member_hex).next().is_some()
210 }
211
212 pub fn is_admin(&self, member_hex: &str) -> bool {
215 self.roles_of(member_hex).any(|r| r.permissions.is_management())
216 }
217
218 pub fn is_staff(&self, member_hex: &str, owner_hex: Option<&str>) -> bool {
222 if owner_hex == Some(member_hex) {
223 return true;
224 }
225 self.effective_permissions(member_hex).0 & Permissions::STAFF_MASK != 0
226 }
227
228 pub fn roles_make_staff(&self, role_ids: &[String]) -> bool {
233 role_ids
234 .iter()
235 .filter_map(|rid| self.role(rid))
236 .any(|r| r.permissions.0 & Permissions::STAFF_MASK != 0)
237 }
238
239 pub fn is_authorized(&self, actor_hex: &str, owner_hex: Option<&str>, permission: u64) -> bool {
244 if owner_hex == Some(actor_hex) {
245 return true;
246 }
247 self.has_permission(actor_hex, permission)
248 }
249
250 pub fn can_manage_position(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32) -> bool {
258 self.can_act_on_position(actor_hex, owner_hex, target_position, Permissions::MANAGE_ROLES)
259 }
260
261 pub fn can_manage_member(&self, actor_hex: &str, owner_hex: Option<&str>, target_hex: &str) -> bool {
265 self.can_act_on_member(actor_hex, owner_hex, target_hex, Permissions::MANAGE_ROLES)
266 }
267
268 pub fn outranks(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32) -> bool {
273 if owner_hex == Some(actor_hex) {
274 return true;
275 }
276 match self.highest_position(actor_hex) {
277 Some(p) => p < target_position,
278 None => false,
279 }
280 }
281
282 pub fn can_act_on_position(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32, permission: u64) -> bool {
287 if owner_hex == Some(actor_hex) {
288 return true;
289 }
290 self.has_permission(actor_hex, permission) && self.outranks(actor_hex, owner_hex, target_position)
291 }
292
293 pub fn can_act_on_member(&self, actor_hex: &str, owner_hex: Option<&str>, target_hex: &str, permission: u64) -> bool {
297 if owner_hex == Some(target_hex) {
298 return false;
299 }
300 let target_position = self.highest_position(target_hex).unwrap_or(u32::MAX);
301 self.can_act_on_position(actor_hex, owner_hex, target_position, permission)
302 }
303
304 pub fn channel_roles(&self, channel_hex: &str) -> Vec<&Role> {
324 let wanted = channel_hex.to_ascii_lowercase();
325 let mut out: Vec<&Role> = self
326 .roles
327 .iter()
328 .filter(|r| matches!(&r.scope, RoleScope::Channel(c) if c.eq_ignore_ascii_case(&wanted)))
329 .collect();
330 out.sort_by_key(|r| r.position);
331 out
332 }
333
334 pub fn channel_role_ids(&self, channel_hex: &str) -> Vec<String> {
336 self.channel_roles(channel_hex).into_iter().map(|r| r.role_id.clone()).collect()
337 }
338
339 pub fn is_entitled(
346 &self,
347 owner_hex: Option<&str>,
348 member_hex: &str,
349 channel_hex: &str,
350 with: &[String],
351 without: &[String],
352 ) -> bool {
353 if owner_hex == Some(member_hex) {
354 return true;
355 }
356 let mut held: std::collections::HashSet<&str> =
357 self.roles_of(member_hex).map(|r| r.role_id.as_str()).collect();
358 for id in with {
359 held.insert(id.as_str());
360 }
361 for id in without {
362 held.remove(id.as_str());
363 }
364 self.channel_role_ids(channel_hex).iter().any(|id| held.contains(id.as_str()))
365 }
366
367 pub fn effective_permissions_in(&self, member_hex: &str, channel_hex: &str) -> Permissions {
376 let wanted = channel_hex.to_ascii_lowercase();
377 self.roles_of(member_hex)
378 .filter(|r| match &r.scope {
379 RoleScope::Server => true,
380 RoleScope::Channel(c) => c.eq_ignore_ascii_case(&wanted),
381 })
382 .fold(Permissions::empty(), |acc, r| acc.union(r.permissions))
383 }
384
385 pub fn is_authorized_in(&self, actor_hex: &str, owner_hex: Option<&str>, channel_hex: &str, permission: u64) -> bool {
388 if owner_hex == Some(actor_hex) {
389 return true;
390 }
391 self.effective_permissions_in(actor_hex, channel_hex).contains(permission)
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn admin_roster(member: &str) -> CommunityRoles {
400 let role = Role::admin("a".repeat(64));
401 CommunityRoles {
402 grants: vec![MemberGrant {
403 member: member.to_string(),
404 role_ids: vec![role.role_id.clone()],
405 }],
406 roles: vec![role],
407 }
408 }
409
410 #[test]
411 fn admin_role_holds_every_management_bit() {
412 let p = Permissions::admin();
413 for bit in [
414 Permissions::MANAGE_ROLES,
415 Permissions::MANAGE_CHANNELS,
416 Permissions::MANAGE_METADATA,
417 Permissions::KICK,
418 Permissions::BAN,
419 Permissions::MANAGE_MESSAGES,
420 Permissions::CREATE_INVITE,
421 Permissions::VIEW_AUDIT_LOG,
422 Permissions::MENTION_EVERYONE,
423 ] {
424 assert!(p.contains(bit), "admin must hold bit {bit}");
425 }
426 }
427
428 #[test]
429 fn social_only_role_is_not_management_nor_admin() {
430 let social = Permissions(Permissions::MENTION_EVERYONE);
434 assert!(!social.is_management(), "a purely-social permission is not management");
435 let role = Role {
436 role_id: "c".repeat(64),
437 name: "Hype".into(),
438 position: 5,
439 permissions: social,
440 scope: RoleScope::Server,
441 color: 0,
442 };
443 let alice = "aa".repeat(32);
444 let r = CommunityRoles {
445 grants: vec![MemberGrant { member: alice.clone(), role_ids: vec![role.role_id.clone()] }],
446 roles: vec![role],
447 };
448 assert!(!r.is_admin(&alice), "a social-only role holder is not an admin");
449 assert!(r.is_privileged(&alice), "though they do hold a role");
450 }
451
452 #[test]
453 fn hierarchy_only_strictly_higher_can_manage() {
454 let owner = "00".repeat(32);
456 let admin = "aa".repeat(32);
457 let moderator = "bb".repeat(32);
458 let member = "cc".repeat(32);
459 let admin_role = Role::admin("a".repeat(64));
460 let mod_role = Role {
461 role_id: "b".repeat(64),
462 name: "Mod".into(),
463 position: 2,
464 permissions: Permissions(Permissions::MANAGE_ROLES | Permissions::KICK),
465 scope: RoleScope::Server,
466 color: 0,
467 };
468 let admin_pos = admin_role.position; let mod_pos = mod_role.position; let r = CommunityRoles {
471 grants: vec![
472 MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] },
473 MemberGrant { member: moderator.clone(), role_ids: vec![mod_role.role_id.clone()] },
474 ],
475 roles: vec![admin_role, mod_role],
476 };
477 let owner_ref = Some(owner.as_str());
478
479 assert!(r.can_manage_position(&owner, owner_ref, admin_pos));
481 assert!(r.can_manage_member(&owner, owner_ref, &admin));
482 assert!(!r.can_manage_member(&admin, owner_ref, &owner), "owner is never a valid target");
483
484 assert!(!r.can_manage_position(&admin, owner_ref, admin_pos), "admin can't grant a peer-rank role");
487 assert!(r.can_manage_position(&admin, owner_ref, mod_pos), "admin outranks Mod");
488 assert!(r.can_manage_member(&admin, owner_ref, &moderator), "admin outranks the mod member");
489 assert!(r.can_manage_member(&admin, owner_ref, &member), "admin outranks a roleless member");
490 assert!(!r.can_manage_member(&admin, owner_ref, &admin), "admin can't manage a peer admin");
491
492 assert!(!r.can_manage_position(&moderator, owner_ref, admin_pos));
494 assert!(!r.can_manage_position(&member, owner_ref, mod_pos), "no role, no MANAGE_ROLES → nothing");
495 }
496
497 #[test]
498 fn can_act_on_member_gates_on_permission_and_rank() {
499 let owner = "00".repeat(32);
503 let admin = "aa".repeat(32);
504 let kicker = "bb".repeat(32);
505 let member = "cc".repeat(32);
506 let admin_role = Role::admin("a".repeat(64)); let kick_role = Role {
508 role_id: "b".repeat(64),
509 name: "Mod".into(),
510 position: 2,
511 permissions: Permissions(Permissions::KICK), scope: RoleScope::Server,
513 color: 0,
514 };
515 let r = CommunityRoles {
516 grants: vec![
517 MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] },
518 MemberGrant { member: kicker.clone(), role_ids: vec![kick_role.role_id.clone()] },
519 ],
520 roles: vec![admin_role, kick_role],
521 };
522 let o = Some(owner.as_str());
523 use Permissions as P;
524
525 assert!(r.can_act_on_member(&admin, o, &kicker, P::BAN));
527 assert!(r.can_act_on_member(&admin, o, &member, P::BAN));
528 assert!(!r.can_act_on_member(&admin, o, &admin, P::BAN), "no banning a peer admin (equal rank)");
529 assert!(!r.can_act_on_member(&admin, o, &owner, P::BAN), "the owner is never a valid target");
530 assert!(r.can_act_on_member(&admin, o, &member, P::MANAGE_MESSAGES), "admin can hide a member's msg");
531
532 assert!(!r.can_act_on_member(&kicker, o, &member, P::BAN), "no BAN permission → can't ban");
534 assert!(!r.can_act_on_member(&kicker, o, &member, P::MANAGE_MESSAGES), "no MANAGE_MESSAGES → can't hide");
535 assert!(r.can_act_on_member(&kicker, o, &member, P::KICK), "but it CAN kick a plain member");
536
537 assert!(r.can_act_on_member(&owner, o, &admin, P::BAN));
539 assert!(!r.can_act_on_member(&member, o, &kicker, P::BAN));
541 }
542
543 #[test]
544 fn effective_permissions_union_and_position() {
545 let alice = "aa".repeat(32);
546 let r = admin_roster(&alice);
547 assert!(r.has_permission(&alice, Permissions::BAN));
548 assert!(r.has_permission(&alice, Permissions::MANAGE_ROLES));
549 assert_eq!(r.highest_position(&alice), Some(1));
550 assert!(r.is_privileged(&alice));
551
552 let bob = "bb".repeat(32);
554 assert!(!r.has_permission(&bob, Permissions::BAN));
555 assert_eq!(r.highest_position(&bob), None);
556 assert!(!r.is_privileged(&bob));
557 }
558
559 #[test]
560 fn multiple_roles_union_perms_and_take_highest_position() {
561 let alice = "aa".repeat(32);
562 let mod_role = Role {
563 role_id: "b".repeat(64),
564 name: "Mod".into(),
565 position: 2,
566 permissions: Permissions(Permissions::MANAGE_MESSAGES | Permissions::KICK),
567 scope: RoleScope::Server,
568 color: 0,
569 };
570 let admin = Role::admin("a".repeat(64));
571 let r = CommunityRoles {
572 grants: vec![MemberGrant {
573 member: alice.clone(),
574 role_ids: vec![admin.role_id.clone(), mod_role.role_id.clone()],
575 }],
576 roles: vec![admin, mod_role],
577 };
578 assert!(r.has_permission(&alice, Permissions::BAN)); assert!(r.has_permission(&alice, Permissions::MANAGE_MESSAGES)); assert_eq!(r.highest_position(&alice), Some(1));
582 }
583
584 #[test]
585 fn round_trips_json() {
586 let alice = "aa".repeat(32);
587 let r = admin_roster(&alice);
588 let json = serde_json::to_string(&r).unwrap();
589 let back: CommunityRoles = serde_json::from_str(&json).unwrap();
590 assert_eq!(r, back);
591 assert!(json.contains("\"kind\":\"server\""));
592 }
593
594 #[test]
595 fn is_authorized_owner_supreme_admin_by_permission_demoted_rejected() {
596 let owner = "00".repeat(32);
597 let alice = "aa".repeat(32);
598 let r = admin_roster(&alice); assert!(r.is_authorized(&owner, Some(&owner), Permissions::BAN));
601 assert!(r.is_authorized(&alice, Some(&owner), Permissions::BAN));
603 let bob = "bb".repeat(32);
605 assert!(!r.is_authorized(&bob, Some(&owner), Permissions::BAN));
606 let demoted = CommunityRoles { roles: r.roles.clone(), grants: vec![] };
607 assert!(!demoted.is_authorized(&alice, Some(&owner), Permissions::BAN));
608 assert!(demoted.is_authorized(&owner, Some(&owner), Permissions::BAN));
610 }
611
612 #[test]
613 fn channel_scope_round_trips() {
614 let scope = RoleScope::Channel("cc".repeat(32));
615 let json = serde_json::to_string(&scope).unwrap();
616 let back: RoleScope = serde_json::from_str(&json).unwrap();
617 assert_eq!(scope, back);
618 }
619
620 fn scoped_roster(chan: &str, member: &str, admin: &str) -> CommunityRoles {
625 let scoped = Role {
626 role_id: "11".repeat(32),
627 name: "Testers".into(),
628 position: 5,
629 permissions: Permissions(Permissions::MANAGE_MESSAGES),
630 scope: RoleScope::Channel(chan.to_string()),
631 color: 0,
632 };
633 let server = Role::admin("22".repeat(32));
634 CommunityRoles {
635 grants: vec![
636 MemberGrant { member: member.to_string(), role_ids: vec![scoped.role_id.clone()] },
637 MemberGrant { member: admin.to_string(), role_ids: vec![server.role_id.clone()] },
638 ],
639 roles: vec![scoped, server],
640 }
641 }
642
643 #[test]
644 fn a_channels_scoped_roles_are_its_access_list() {
645 let chan = "cc".repeat(32);
646 let other = "dd".repeat(32);
647 let owner = "00".repeat(32);
648 let member = "aa".repeat(32);
649 let admin = "bb".repeat(32);
650 let r = scoped_roster(&chan, &member, &admin);
651
652 assert_eq!(r.channel_roles(&chan).len(), 1, "the scoped role is the access list");
653 assert!(r.channel_roles(&other).is_empty(), "another channel gets none of it");
654
655 assert!(r.is_entitled(Some(&owner), &member, &chan, &[], &[]), "the granted holder is entitled");
656 assert!(r.is_entitled(Some(&owner), &owner, &chan, &[], &[]), "the owner is always entitled");
657 assert!(!r.is_entitled(Some(&owner), &admin, &chan, &[], &[]), "rank alone is not entitlement");
660 assert!(!r.is_entitled(Some(&owner), &member, &other, &[], &[]), "entitlement does not spill to another channel");
661 }
662
663 #[test]
664 fn the_grant_overlay_settles_against_a_publish_the_fold_has_not_caught() {
665 let chan = "cc".repeat(32);
666 let owner = "00".repeat(32);
667 let member = "aa".repeat(32);
668 let stranger = "ee".repeat(32);
669 let r = scoped_roster(&chan, &member, &"bb".repeat(32));
670 let scoped_id = r.channel_role_ids(&chan)[0].clone();
671
672 assert!(
673 r.is_entitled(Some(&owner), &stranger, &chan, std::slice::from_ref(&scoped_id), &[]),
674 "a just-published grant settles as entitled before the fold catches up"
675 );
676 assert!(
677 !r.is_entitled(Some(&owner), &member, &chan, &[], std::slice::from_ref(&scoped_id)),
678 "a just-published revoke settles as unentitled before the fold catches up"
679 );
680 }
681
682 #[test]
683 fn channel_narrowing_is_offer_side_only() {
684 let chan = "cc".repeat(32);
685 let other = "dd".repeat(32);
686 let owner = "00".repeat(32);
687 let member = "aa".repeat(32);
688 let r = scoped_roster(&chan, &member, &"bb".repeat(32));
689
690 assert!(r.is_authorized_in(&member, Some(&owner), &chan, Permissions::MANAGE_MESSAGES));
692 assert!(!r.is_authorized_in(&member, Some(&owner), &other, Permissions::MANAGE_MESSAGES));
693
694 assert!(
698 r.is_authorized(&member, Some(&owner), Permissions::MANAGE_MESSAGES),
699 "the scope-blind honor path is unchanged"
700 );
701 }
702
703 #[test]
707 fn founding_mask_is_frozen_at_the_original_nine_bits() {
708 const ORIGINAL_NINE: u64 = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4)
709 | (1 << 5) | (1 << 6) | (1 << 8) | (1 << 9);
710 assert_eq!(Permissions::ADMIN_FOUNDING_MASK, ORIGINAL_NINE);
711 assert!(Permissions(ORIGINAL_NINE).contains(Permissions::ADMIN_FOUNDING_MASK));
713 assert!(Permissions::admin().contains(Permissions::ADMIN_FOUNDING_MASK));
715 assert!(Permissions::admin().contains(Permissions::PIN_MESSAGES));
716 assert!(!Permissions(ORIGINAL_NINE).contains(Permissions::ADMIN_ALL));
719 }
720}