Skip to main content

vector_core/community/
roles.rs

1//! Concord role graph (GROUP_PROTOCOL.md).
2//!
3//! The MVP exposes a single auto-generated "Admin" role, but this is the FULL graph:
4//! roles are data (not a hardcoded `is_admin` flag), enforcement is capability-based
5//! (effective-permission bits + position), and the engine reads an arbitrary set of
6//! roles + grants. Mod roles, per-channel mods, and custom roles later are just more
7//! `Role` records flowing through the same code — additive, no enforcement changes.
8//!
9//! WIRE MODEL (per-entity): roles and grants do NOT live in the GroupRoot, and they
10//! are NOT one consolidated blob. Each role is its own addressable RoleMetadata event
11//! (vsk=1, d-tag = role_id) and each member's grants are their own Grant event
12//! (vsk=3, d-tag = an opaque per-member locator). So two managers editing *different*
13//! roles or *different* members never clobber each other — only same-coordinate edits
14//! converge (authority-first). `CommunityRoles` here is the in-memory AGGREGATION a
15//! client builds from those fetched per-entity events, not an on-wire document.
16
17use serde::{Deserialize, Serialize};
18
19/// Management/moderation permission bits. Access (read/post a channel) is NOT
20/// here — that is key possession (the two-mechanism split). Bit positions are part of
21/// the wire format and are FROZEN: append a reserved bit, never renumber or reuse one.
22#[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    // Reserved / retired (claim the bit so it's never reassigned):
37    // `1 << 7` was MANAGE_INVITES — RETIRED (per-creator ownership: no one can manage another's
38    // links, so there is nothing to grant; `CREATE_INVITE` mints your own, `BAN` owns the revoking rekey).
39    // MANAGE_EMOJI = 1 << 10, PIN_MESSAGES = 1 << 11, MANAGE_EVENTS = 1 << 12.
40
41    /// Every management bit currently defined — what the MVP "Admin" role holds.
42    pub const ADMIN_ALL: u64 = Self::MANAGE_ROLES
43        | Self::MANAGE_CHANNELS
44        | Self::MANAGE_METADATA
45        | Self::KICK
46        | Self::BAN
47        | Self::MANAGE_MESSAGES
48        | Self::CREATE_INVITE
49        | Self::VIEW_AUDIT_LOG
50        | Self::MENTION_EVERYONE;
51
52    /// Control-plane bits: exercising any of these signs a control/metadata edition (keyless model —
53    /// the actor's own npub signature IS the authority, re-verified against the roster). Every
54    /// management bit EXCEPT purely-social ones (`MENTION_EVERYONE` acts at the message layer, not the
55    /// control plane). A role with any of these is a "management role" — its holder shows the admin crown.
56    pub const MANAGEMENT_MASK: u64 = Self::MANAGE_ROLES
57        | Self::MANAGE_CHANNELS
58        | Self::MANAGE_METADATA
59        | Self::KICK
60        | Self::BAN
61        | Self::MANAGE_MESSAGES
62        | Self::CREATE_INVITE
63        | Self::VIEW_AUDIT_LOG;
64
65    pub fn empty() -> Self {
66        Permissions(0)
67    }
68    pub fn admin() -> Self {
69        Permissions(Self::ADMIN_ALL)
70    }
71    /// True iff this role carries any management permission (vs. a purely-social role) — i.e. its
72    /// holder counts as an admin.
73    pub fn is_management(self) -> bool {
74        self.0 & Self::MANAGEMENT_MASK != 0
75    }
76    /// True iff every bit in `bits` is set.
77    pub fn contains(self, bits: u64) -> bool {
78        self.0 & bits == bits
79    }
80    pub fn union(self, other: Permissions) -> Permissions {
81        Permissions(self.0 | other.0)
82    }
83}
84
85/// Discord's "any channel" vs "this channel". Server-scope acts everywhere;
86/// channel-scope is rejected against any other channel.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case", tag = "kind", content = "channel_id")]
89pub enum RoleScope {
90    Server,
91    /// Channel id, lowercase hex.
92    Channel(String),
93}
94
95/// A role: its own addressable RoleMetadata event (vsk=1). Fully general; the MVP
96/// auto-creates exactly one (`Role::admin`).
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Role {
99    /// Random opaque 32-byte hex id, stable across renames; also the event's d-tag.
100    pub role_id: String,
101    pub name: String,
102    /// Lower = higher authority. Owner is the implicit top (position 0, never a Role).
103    /// Authoritative ordering will move to a single RoleOrder entity when
104    /// drag-reorder ships; until then this declared value is the position (no reordering
105    /// to race in the owner-only MVP).
106    pub position: u32,
107    pub permissions: Permissions,
108    pub scope: RoleScope,
109    /// UI badge color (e.g. the Admin crown); 0 = theme default. Cosmetic.
110    #[serde(default)]
111    pub color: u32,
112}
113
114impl Role {
115    /// The MVP's auto-created server-scope Admin role: all management bits, position 1
116    /// (just below the owner). `role_id` is a fresh random opaque id minted at creation.
117    pub fn admin(role_id: String) -> Self {
118        Role {
119            role_id,
120            name: "Admin".to_string(),
121            position: 1,
122            permissions: Permissions::admin(),
123            scope: RoleScope::Server,
124            color: 0,
125        }
126    }
127}
128
129/// One member's role grants (vsk=3) — its own addressable event so granting Alice
130/// never clobbers Bob's grants. `member` is the grantee's pubkey, lowercase hex (same form
131/// as the banlist), and `role_ids` are the roles they hold (a member can have several).
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct MemberGrant {
134    pub member: String,
135    #[serde(default)]
136    pub role_ids: Vec<String>,
137}
138
139/// The role graph a client AGGREGATES from the fetched per-entity events (RoleMetadata +
140/// per-member Grant). Not an on-wire document — the enforcement engine queries this.
141#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
142pub struct CommunityRoles {
143    #[serde(default)]
144    pub roles: Vec<Role>,
145    #[serde(default)]
146    pub grants: Vec<MemberGrant>,
147}
148
149impl CommunityRoles {
150    /// Look up a role definition by id.
151    pub fn role(&self, role_id: &str) -> Option<&Role> {
152        self.roles.iter().find(|r| r.role_id == role_id)
153    }
154
155    /// The roles granted to `member_hex` (resolved through the grant list).
156    pub fn roles_of<'a>(&'a self, member_hex: &'a str) -> impl Iterator<Item = &'a Role> + 'a {
157        self.grants
158            .iter()
159            .filter(move |g| g.member == member_hex)
160            .flat_map(move |g| g.role_ids.iter())
161            .filter_map(move |rid| self.role(rid))
162    }
163
164    /// Effective permissions for a member = union of their granted roles' bits.
165    pub fn effective_permissions(&self, member_hex: &str) -> Permissions {
166        self.roles_of(member_hex)
167            .fold(Permissions::empty(), |acc, r| acc.union(r.permissions))
168    }
169
170    /// True iff the member's effective permissions include every bit in `bits`.
171    pub fn has_permission(&self, member_hex: &str, bits: u64) -> bool {
172        self.effective_permissions(member_hex).contains(bits)
173    }
174
175    /// Highest authority (lowest position index) among the member's roles; `None` if
176    /// they hold no role. The owner (implicit position 0) is handled by the caller.
177    pub fn highest_position(&self, member_hex: &str) -> Option<u32> {
178        self.roles_of(member_hex).map(|r| r.position).min()
179    }
180
181    /// True iff the member holds at least one role (i.e. is privileged below the owner).
182    pub fn is_privileged(&self, member_hex: &str) -> bool {
183        self.roles_of(member_hex).next().is_some()
184    }
185
186    /// True iff the member holds a role with management permissions — an "admin" (vs. a member who
187    /// holds only a non-management/social role). Drives the member-list crown.
188    pub fn is_admin(&self, member_hex: &str) -> bool {
189        self.roles_of(member_hex).any(|r| r.permissions.is_management())
190    }
191
192    /// Is `actor_hex` authorized for an action requiring `permission`? The **owner** (the
193    /// proven owner npub, if known) is supreme and always authorized; otherwise the actor must hold
194    /// a role granting `permission`. This is the grant-set check the inner-author-proof gates on: a
195    /// demoted member is no longer in the grant set, so their actions stop being honored.
196    pub fn is_authorized(&self, actor_hex: &str, owner_hex: Option<&str>, permission: u64) -> bool {
197        if owner_hex == Some(actor_hex) {
198            return true;
199        }
200        self.has_permission(actor_hex, permission)
201    }
202
203    /// escalation defense — may `actor_hex` manage something sitting at `target_position`
204    /// (grant/revoke/edit/reorder a role)? The actor must **strictly outrank** it (their highest
205    /// authority is a *lower* position number) AND hold `MANAGE_ROLES`. The owner is supreme
206    /// (implicit position 0, above every role) and always may. Equal cannot act on equal: an admin
207    /// can never grant/revoke a peer admin at the same position — only someone strictly above can.
208    /// This is what stops an admin granting the Admin role (`pos == pos`, refused) while still
209    /// letting them manage any role beneath them.
210    pub fn can_manage_position(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32) -> bool {
211        self.can_act_on_position(actor_hex, owner_hex, target_position, Permissions::MANAGE_ROLES)
212    }
213
214    /// May `actor_hex` act on MEMBER `target_hex` for a role change (add/remove a role)? Resolves the
215    /// target's highest authority and applies the `MANAGE_ROLES` position rule. The **owner is never a
216    /// valid target** (supreme, unremovable — the sole hardcoded exception).
217    pub fn can_manage_member(&self, actor_hex: &str, owner_hex: Option<&str>, target_hex: &str) -> bool {
218        self.can_act_on_member(actor_hex, owner_hex, target_hex, Permissions::MANAGE_ROLES)
219    }
220
221    /// The pure position test (no permission bit): does `actor_hex` **strictly outrank**
222    /// `target_position`? The owner (implicit position 0) outranks everything; a roleless actor
223    /// outranks nothing. This is the position half of every authority check — callers AND it with the
224    /// specific permission the action needs (`BAN`, `MANAGE_MESSAGES`, `MANAGE_ROLES`, ...).
225    pub fn outranks(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32) -> bool {
226        if owner_hex == Some(actor_hex) {
227            return true;
228        }
229        match self.highest_position(actor_hex) {
230            Some(p) => p < target_position,
231            None => false,
232        }
233    }
234
235    /// Generalized authority test: may `actor_hex` perform an action requiring `permission` against a
236    /// target at `target_position`? Owner is supreme; otherwise the actor must hold `permission` AND
237    /// strictly outrank the target. (`can_manage_position` is this with `MANAGE_ROLES`; bans pass
238    /// `BAN`, moderation-hides pass `MANAGE_MESSAGES`.)
239    pub fn can_act_on_position(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32, permission: u64) -> bool {
240        if owner_hex == Some(actor_hex) {
241            return true;
242        }
243        self.has_permission(actor_hex, permission) && self.outranks(actor_hex, owner_hex, target_position)
244    }
245
246    /// Generalized member-targeting authority test (ban / kick / hide / role-change). The **owner is
247    /// never a valid target**; a roleless member sits below everyone. The actor needs `permission`
248    /// plus a strict outrank of the target's highest role.
249    pub fn can_act_on_member(&self, actor_hex: &str, owner_hex: Option<&str>, target_hex: &str, permission: u64) -> bool {
250        if owner_hex == Some(target_hex) {
251            return false;
252        }
253        let target_position = self.highest_position(target_hex).unwrap_or(u32::MAX);
254        self.can_act_on_position(actor_hex, owner_hex, target_position, permission)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn admin_roster(member: &str) -> CommunityRoles {
263        let role = Role::admin("a".repeat(64));
264        CommunityRoles {
265            grants: vec![MemberGrant {
266                member: member.to_string(),
267                role_ids: vec![role.role_id.clone()],
268            }],
269            roles: vec![role],
270        }
271    }
272
273    #[test]
274    fn admin_role_holds_every_management_bit() {
275        let p = Permissions::admin();
276        for bit in [
277            Permissions::MANAGE_ROLES,
278            Permissions::MANAGE_CHANNELS,
279            Permissions::MANAGE_METADATA,
280            Permissions::KICK,
281            Permissions::BAN,
282            Permissions::MANAGE_MESSAGES,
283            Permissions::CREATE_INVITE,
284            Permissions::VIEW_AUDIT_LOG,
285            Permissions::MENTION_EVERYONE,
286        ] {
287            assert!(p.contains(bit), "admin must hold bit {bit}");
288        }
289    }
290
291    #[test]
292    fn social_only_role_is_not_management_nor_admin() {
293        // A role holding ONLY a social bit (MENTION_EVERYONE) is not "management" and its holder is
294        // NOT an admin — so it gets no crown and no management-secret delivery. Guards the
295        // MANAGEMENT_MASK invariant against a future fat-finger that folds a social bit in.
296        let social = Permissions(Permissions::MENTION_EVERYONE);
297        assert!(!social.is_management(), "a purely-social permission is not management");
298        let role = Role {
299            role_id: "c".repeat(64),
300            name: "Hype".into(),
301            position: 5,
302            permissions: social,
303            scope: RoleScope::Server,
304            color: 0,
305        };
306        let alice = "aa".repeat(32);
307        let r = CommunityRoles {
308            grants: vec![MemberGrant { member: alice.clone(), role_ids: vec![role.role_id.clone()] }],
309            roles: vec![role],
310        };
311        assert!(!r.is_admin(&alice), "a social-only role holder is not an admin");
312        assert!(r.is_privileged(&alice), "though they do hold a role");
313    }
314
315    #[test]
316    fn hierarchy_only_strictly_higher_can_manage() {
317        // owner (implicit pos 0) > admin (pos 1) > mod (pos 2); a plain member holds no role.
318        let owner = "00".repeat(32);
319        let admin = "aa".repeat(32);
320        let moderator = "bb".repeat(32);
321        let member = "cc".repeat(32);
322        let admin_role = Role::admin("a".repeat(64));
323        let mod_role = Role {
324            role_id: "b".repeat(64),
325            name: "Mod".into(),
326            position: 2,
327            permissions: Permissions(Permissions::MANAGE_ROLES | Permissions::KICK),
328            scope: RoleScope::Server,
329            color: 0,
330        };
331        let admin_pos = admin_role.position; // 1
332        let mod_pos = mod_role.position; // 2
333        let r = CommunityRoles {
334            grants: vec![
335                MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] },
336                MemberGrant { member: moderator.clone(), role_ids: vec![mod_role.role_id.clone()] },
337            ],
338            roles: vec![admin_role, mod_role],
339        };
340        let owner_ref = Some(owner.as_str());
341
342        // Owner is supreme — outranks every position and every member, can't be targeted.
343        assert!(r.can_manage_position(&owner, owner_ref, admin_pos));
344        assert!(r.can_manage_member(&owner, owner_ref, &admin));
345        assert!(!r.can_manage_member(&admin, owner_ref, &owner), "owner is never a valid target");
346
347        // Equal cannot act on equal: an admin can NOT manage the Admin position (closes self/peer
348        // escalation — granting the Admin role), but CAN manage everything strictly below.
349        assert!(!r.can_manage_position(&admin, owner_ref, admin_pos), "admin can't grant a peer-rank role");
350        assert!(r.can_manage_position(&admin, owner_ref, mod_pos), "admin outranks Mod");
351        assert!(r.can_manage_member(&admin, owner_ref, &moderator), "admin outranks the mod member");
352        assert!(r.can_manage_member(&admin, owner_ref, &member), "admin outranks a roleless member");
353        assert!(!r.can_manage_member(&admin, owner_ref, &admin), "admin can't manage a peer admin");
354
355        // A mod can't reach up to the Admin position, and a roleless member can manage nothing.
356        assert!(!r.can_manage_position(&moderator, owner_ref, admin_pos));
357        assert!(!r.can_manage_position(&member, owner_ref, mod_pos), "no role, no MANAGE_ROLES → nothing");
358    }
359
360    #[test]
361    fn can_act_on_member_gates_on_permission_and_rank() {
362        // owner (pos 0), a BAN-capable admin (pos 1), a Mod with KICK-only (pos 2, NO ban), a plain
363        // member (no role). Verifies the generalized gate requires BOTH the right permission AND a
364        // strict outrank — the rule ban/hide use, distinct from MANAGE_ROLES.
365        let owner = "00".repeat(32);
366        let admin = "aa".repeat(32);
367        let kicker = "bb".repeat(32);
368        let member = "cc".repeat(32);
369        let admin_role = Role::admin("a".repeat(64)); // pos 1, ADMIN_ALL (incl. BAN + MANAGE_MESSAGES)
370        let kick_role = Role {
371            role_id: "b".repeat(64),
372            name: "Mod".into(),
373            position: 2,
374            permissions: Permissions(Permissions::KICK), // KICK only — NO ban, NO manage-messages
375            scope: RoleScope::Server,
376            color: 0,
377        };
378        let r = CommunityRoles {
379            grants: vec![
380                MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] },
381                MemberGrant { member: kicker.clone(), role_ids: vec![kick_role.role_id.clone()] },
382            ],
383            roles: vec![admin_role, kick_role],
384        };
385        let o = Some(owner.as_str());
386        use Permissions as P;
387
388        // The BAN-capable admin: bans the Mod and a plain member, but NOT a peer admin, NOT the owner.
389        assert!(r.can_act_on_member(&admin, o, &kicker, P::BAN));
390        assert!(r.can_act_on_member(&admin, o, &member, P::BAN));
391        assert!(!r.can_act_on_member(&admin, o, &admin, P::BAN), "no banning a peer admin (equal rank)");
392        assert!(!r.can_act_on_member(&admin, o, &owner, P::BAN), "the owner is never a valid target");
393        assert!(r.can_act_on_member(&admin, o, &member, P::MANAGE_MESSAGES), "admin can hide a member's msg");
394
395        // The Mod has KICK but NOT BAN/MANAGE_MESSAGES → the permission gate refuses even a target it outranks.
396        assert!(!r.can_act_on_member(&kicker, o, &member, P::BAN), "no BAN permission → can't ban");
397        assert!(!r.can_act_on_member(&kicker, o, &member, P::MANAGE_MESSAGES), "no MANAGE_MESSAGES → can't hide");
398        assert!(r.can_act_on_member(&kicker, o, &member, P::KICK), "but it CAN kick a plain member");
399
400        // The owner is supreme for any permission, against anyone.
401        assert!(r.can_act_on_member(&owner, o, &admin, P::BAN));
402        // A plain member can do nothing.
403        assert!(!r.can_act_on_member(&member, o, &kicker, P::BAN));
404    }
405
406    #[test]
407    fn effective_permissions_union_and_position() {
408        let alice = "aa".repeat(32);
409        let r = admin_roster(&alice);
410        assert!(r.has_permission(&alice, Permissions::BAN));
411        assert!(r.has_permission(&alice, Permissions::MANAGE_ROLES));
412        assert_eq!(r.highest_position(&alice), Some(1));
413        assert!(r.is_privileged(&alice));
414
415        // A member with no grant has no permissions and no position.
416        let bob = "bb".repeat(32);
417        assert!(!r.has_permission(&bob, Permissions::BAN));
418        assert_eq!(r.highest_position(&bob), None);
419        assert!(!r.is_privileged(&bob));
420    }
421
422    #[test]
423    fn multiple_roles_union_perms_and_take_highest_position() {
424        let alice = "aa".repeat(32);
425        let mod_role = Role {
426            role_id: "b".repeat(64),
427            name: "Mod".into(),
428            position: 2,
429            permissions: Permissions(Permissions::MANAGE_MESSAGES | Permissions::KICK),
430            scope: RoleScope::Server,
431            color: 0,
432        };
433        let admin = Role::admin("a".repeat(64));
434        let r = CommunityRoles {
435            grants: vec![MemberGrant {
436                member: alice.clone(),
437                role_ids: vec![admin.role_id.clone(), mod_role.role_id.clone()],
438            }],
439            roles: vec![admin, mod_role],
440        };
441        // Union of both roles' bits, and the *highest* (lowest index) position wins.
442        assert!(r.has_permission(&alice, Permissions::BAN)); // from Admin
443        assert!(r.has_permission(&alice, Permissions::MANAGE_MESSAGES)); // from both
444        assert_eq!(r.highest_position(&alice), Some(1));
445    }
446
447    #[test]
448    fn round_trips_json() {
449        let alice = "aa".repeat(32);
450        let r = admin_roster(&alice);
451        let json = serde_json::to_string(&r).unwrap();
452        let back: CommunityRoles = serde_json::from_str(&json).unwrap();
453        assert_eq!(r, back);
454        assert!(json.contains("\"kind\":\"server\""));
455    }
456
457    #[test]
458    fn is_authorized_owner_supreme_admin_by_permission_demoted_rejected() {
459        let owner = "00".repeat(32);
460        let alice = "aa".repeat(32);
461        let r = admin_roster(&alice); // alice granted Admin (has BAN)
462        // Owner is always authorized, even without a role.
463        assert!(r.is_authorized(&owner, Some(&owner), Permissions::BAN));
464        // Admin Alice is authorized for BAN via her role.
465        assert!(r.is_authorized(&alice, Some(&owner), Permissions::BAN));
466        // A member with no role (Bob) is not — and neither is Alice once demoted (no grant).
467        let bob = "bb".repeat(32);
468        assert!(!r.is_authorized(&bob, Some(&owner), Permissions::BAN));
469        let demoted = CommunityRoles { roles: r.roles.clone(), grants: vec![] };
470        assert!(!demoted.is_authorized(&alice, Some(&owner), Permissions::BAN));
471        // ...but the owner stays authorized even with an empty grant set.
472        assert!(demoted.is_authorized(&owner, Some(&owner), Permissions::BAN));
473    }
474
475    #[test]
476    fn channel_scope_round_trips() {
477        let scope = RoleScope::Channel("cc".repeat(32));
478        let json = serde_json::to_string(&scope).unwrap();
479        let back: RoleScope = serde_json::from_str(&json).unwrap();
480        assert_eq!(scope, back);
481    }
482}