Files
backspace/docs/systems/permissions.md
T
Jannis Braun 85e1975fa5 fix(permissions): deny space permissions to non-members (invite-bypass)
computePermissions() returned the space @everyone role's permissions without
verifying the caller had joined the space. Because CREATE_INVITE is in
DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code
for a request-only space — whose id is listed by /api/spaces/explore — and then
self-join via /api/spaces/:id/join, bypassing the join-request approval flow.
The same gap let non-members read message history and search default channels.

Root cause:
- computePermissions now returns 0n for non-members (space owner and instance
  admin still short-circuit first, so they are unaffected).

Defense in depth (request-only spaces are approval-gated, never invite-joinable):
- both invite-code join endpoints reject visibility='request' (private stays
  invite-joinable — its only entry path; public too).
- POST /api/spaces/:id/invite refuses to hand out a code for request spaces.
- POST /api/dm/space-invite refuses to card a local request space, checked by
  space id against the local table so a spoofed spaceInstanceOrigin can't slip
  past it.
- InviteModal hides the invite affordances for request spaces.

Also removes the unused computeCategoryPermissions(), which duplicated the
resolution algorithm without the membership gate.

Adds unit + route + component tests covering non-member/member/owner/admin
resolution and the request/private/public visibility matrix.

Reported-by: BadAtCaptchas (#2)
2026-07-07 19:45:51 +02:00

4.0 KiB

Permission System

Source files:

  • packages/shared/src/permissions.ts — Bit definitions, constants
  • packages/server/src/utils/permissions.ts — Server-side resolution
  • packages/web/src/utils/permissions.ts — Client-side helpers

Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).


Permission Bits

Bit Name Description
0 ADMINISTRATOR Full access, bypasses all checks
1 VIEW_CHANNEL See channel, read messages
2 MANAGE_CHANNELS Create/edit/delete channels + categories
3 MANAGE_ROLES Create/edit/delete roles, assign roles
4 MANAGE_SPACE Edit space settings, manage join requests
5 CREATE_INVITE Generate invite codes
6 KICK_MEMBERS Remove members
7 BAN_MEMBERS Ban members
10 SEND_MESSAGES Post in text channels
11 MANAGE_MESSAGES Delete others' messages
12 ATTACH_FILES Upload files
13 READ_MESSAGE_HISTORY View message history
14 ADD_REACTIONS Add emoji reactions
20 CONNECT Join voice channels
21 SPEAK Transmit audio
22 MUTE_MEMBERS Space-mute others
23 DEAFEN_MEMBERS Space-deafen others
24 MOVE_MEMBERS Move between voice channels
25 STREAM Screen share
26 DISCONNECT_MEMBERS Disconnect from voice

Default @everyone: VIEW_CHANNEL, SEND_MESSAGES, CREATE_INVITE, CONNECT, SPEAK, ATTACH_FILES, READ_MESSAGE_HISTORY, ADD_REACTIONS, STREAM


Resolution Algorithm

computePermissions(userId, spaceId, channelId?) → bigint

Step 1: Owner/Admin Check

  • Space owner OR instance admin (isAdmin === 1) → return ALL_PERMISSIONS

Step 1b: Membership Gate

  • If the user is not a member of the space (getMember returns nothing) → return 0n
  • A non-member has no permissions in a space they have not joined. Without this, the @everyone role in Step 2 would leak default member rights (VIEW_CHANNEL, READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated non-member — allowing them to read channels and mint invite codes for spaces they never joined. Owner and instance admin are already resolved in Step 1, so they are unaffected.

Step 2: Compute Base (space-level)

  • Start with @everyone role permissions (role where id === spaceId)
  • OR together all permissions from user's assigned roles
  • If ADMINISTRATOR bit set → return ALL_PERMISSIONS

Step 3: Apply Overrides (if channelId provided)

Three tiers, each applied category-first then channel-second:

Tier 1 — @everyone override (targetType='role', targetId=spaceId):

if categoryOverride: base = (base & ~deny) | allow
if channelOverride:  base = (base & ~deny) | allow

Tier 2 — Role overrides (combined across all assigned roles):

catAllow = 0, catDeny = 0
for each role: catAllow |= roleOverride.allow; catDeny |= roleOverride.deny
base = (base & ~catDeny) | catAllow

chanAllow = 0, chanDeny = 0
for each role: chanAllow |= roleOverride.allow; chanDeny |= roleOverride.deny
base = (base & ~chanDeny) | chanAllow

Tier 3 — Member override (targetType='member', targetId=userId):

if categoryOverride: base = (base & ~deny) | allow
if channelOverride:  base = (base & ~deny) | allow

Key rule: Channel bits always win — applied after category, overwriting conflicting bits. Deny applied first (clears bits), then allow (sets bits).


Helper Functions

Function Purpose
hasPermissionBit(perms, bit) Check if bit is set; true if ADMINISTRATOR
permissionsToString(perms) Bigint → decimal string for JSON
stringToPermissions(str) Decimal string → bigint (supports legacy JSON array format)
computePermissions(userId, spaceId, channelId?) Full resolution algorithm
hasPermission(userId, spaceId, permission, channelId?) Boolean wrapper
getMember/isMember/isSpaceOwner Membership checks
isDmMember/isBanned DM/ban checks
getChannelSpaceId(channelId) Resolve channel's space