Files
Toju/agents-docs/features/access-control.md
brogeby 47beed01ca docs: add cross-context feature docs for auth, presence, access-control, messaging, attachments
Fills the five highest-value gaps under agents-docs/features/ so the index covers
the system's main cross-context contracts. Each doc follows the feature-template
structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage
or behavior couldn't be confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:41 +02:00

15 KiB
Raw Blame History

Access Control

Area: access-control Status: Active Last updated: 2026-05-25

Overview

Access control is the permission engine under every Toju server: roles and their assignments, per-channel permission overrides, server membership, invites, bans, and slowmode. It runs in two places at once. The signaling server enforces it as the source of truth on REST mutations and on the join_server WebSocket gate. The Angular product client maintains a parallel resolution path so the UI can disable controls the user is not allowed to use, but those client-side guards are advisory — the server is authoritative.

Most concepts here were introduced over two migrations: 1000000000001-ServerAccessControl.ts added memberships, bans, and invites; 1000000000005-ServerRoleAccessControl.ts introduced first-class roles, role assignments, and per-channel overrides. The client carries a back-compat path that re-derives the legacy RoomPermissions booleans from the new role state for older UI code paths.

Responsibilities

  • Define and persist roles, role assignments, and per-channel permission overrides.
  • Decide whether an identity is allowed to enter a server (authorizeWebSocketJoin) and, if not yet a member, what step is required (open join, password, invite).
  • Maintain memberships, bans, and invites with their lifecycles (invite expiry, ban expiry).
  • Resolve the effective permission state for a (user, channel) pair via role-position precedence and channel overrides.
  • Hydrate the room model on join so the client can apply the same resolution locally.
  • Guard moderation actions against privilege escalation via role-position checks.

This area does not own:

  • Server catalog, discoverability, search, or moderation reports → server-directory.
  • Authentication or identity binding → authentication.
  • The wire format of join_server, access_denied, role/ban update envelopes → websocket-envelopes.
  • Online status, voice presence, game activity → presence.

Key concepts

  • Role — named row in server_role with a position (higher number = higher precedence) and a PermissionStatePayload of per-key overrides (allow | deny | inherit).
  • System role — bootstrap roles seeded by 1000000000005-ServerRoleAccessControl.ts: system-everyone, system-moderator, system-admin. system-everyone applies to all members; the others are assignable.
  • RoleAssignment — (server, user, role) row in server_user_role.
  • ChannelPermissionOverride — (server, channel, role) row in server_channel_permission carrying allow / deny / inherit per permission key on top of the role's base state.
  • Membership — (server, user) row in server_membership with createdAt and optional metadata.
  • Invite — single-use or multi-use server invite in server_invite; default expiry 10 days from creation.
  • Ban — (server, user) entry in server_ban with optional expiresAt and reason. Persists across reconnects.
  • Slowmode — per-channel send interval (slowModeInterval on the channel) — currently a client-rendered hint, not enforced server-side (see TODOs).

Permission keys

Defined as a PermissionStatePayload in server/src/cqrs/types.ts. ~11 keys gate distinct capabilities:

  • manageServer — edit server metadata (name, icon, settings).
  • manageChannels — create / edit / delete channels.
  • manageRoles — create / edit / delete roles (subject to role-position guard, see below).
  • moderateMember — kick / ban / mute / change-nick on lower-positioned members.
  • inviteMember — create invites.
  • viewChannel — see a channel exists.
  • readMessages — read message history in a channel.
  • writeMessages — send messages.
  • manageMessages — delete / pin others' messages.
  • connectVoice — join a voice room.
  • speakVoice — un-mute in a voice room.

Each key may be allow, deny, or inherit on a given role. The default state on system-everyone is permissive for "read / view / write" and restrictive for "manage / moderate"; see the migration for the seed values.

Resolution algorithm

For a (user, channel) lookup:

  1. Collect the user's role assignments for the server, plus the implicit system-everyone.
  2. Order roles by position ascending — lowest position resolved first, highest position resolved last (last-writer-wins).
  3. For each role, apply its base PermissionStatePayload to a running accumulator: allow and deny overwrite; inherit leaves the prior value intact.
  4. Apply the channel's per-role overrides (server_channel_permission) on top, in the same position order.
  5. The accumulator's final value per key is the effective permission.

Because there is no inherent deny > allow priority, a higher-positioned role's allow will override a lower-positioned role's deny on the same key. This is the intentional Discord-style "promote to override" model. Document any deviation from this when introducing a new key.


Membership state machine

Entry into a server runs through joinServerWithAccess in server/src/services/server-access.service.ts:

  1. Look up the server. If missing → reject.
  2. Look up the user's membership. If active → fast-path success.
  3. Look up an active ban for (server, user). If present and not expired → reject with banned.
  4. If the server is public (isPublic = true, no password, no invite required) → create membership, return success.
  5. If the server is password-protected (hasPassword = true) → require the supplied password to hash-match. If mismatch → reject with password_required / bad_password.
  6. If the server is invite-only → require a valid (server_invite.code) and non-expired invite. Consume the invite if single-use.
  7. On success → insert a server_membership row, return ok.

authorizeWebSocketJoin is the lighter gate used on the join_server WebSocket envelope: it short-circuits to "allowed" if a membership already exists, otherwise reports the access mode needed. Unlike joinServerWithAccess, it does not consume invites or process passwords — those flow through dedicated REST routes on the server before the client retries the WebSocket join.

handleJoinServer (server/src/websocket/handler.ts:155) is the call site: on rejection the server sends access_denied with a reason (banned, password_required, invite_required, ...); on success it broadcasts presence (user_joined) per the rules documented in presence.


Moderation actions

Moderation is gated by two helpers in server/src/services/server-permissions.service.ts:

  • canManageServerUpdate(actorRoles, requested) — maps the requested change (rename, icon change, permission edit, role create, invite, etc.) to the permission key it needs, then resolves the actor's effective state.
  • canModerateServerMember(actorHighestRole, targetHighestRole) — privilege-escalation guard: the actor's highest-position role must be strictly greater than the target's highest-position role. Two moderators at the same position cannot ban each other.

Bans are written via banServerUser. The ban entity supports an optional expiresAt for time-limited bans and a reason string for moderator-facing UI.

Kick is implemented as "delete membership"; the connection is dropped via a subsequent WebSocket close on the next envelope.


Client-side hydration

When the client joins a server, the server sends the room model with normalized access-control fields:

roles:                ServerRolePayload[]
roleAssignments:      RoleAssignmentPayload[]
channelPermissions:   ChannelPermissionPayload[]
permissions:          legacy RoomPermissions bools  (back-compat)
slowModeInterval:     number | undefined            (per-channel)

normalizeRoomAccessControl in toju-app/src/app/shared-kernel/room.models.ts is the single normalization helper. It:

  • Backfills the legacy permissions booleans from the role state so older NgRx selectors keep working.
  • Sorts roles by position ascending.
  • De-duplicates assignments by (userId, roleId).

Client-side resolution mirrors the server algorithm and lives under toju-app/src/app/domains/access-control/domain/rules/. Selectors expose canSendMessage(channelId), canManageRole(roleId), etc., which UI components consume to disable controls.

canManageRole enforces the same privilege-escalation guard as the server: a user cannot edit a role at or above their own highest position.


Invites

server_invite rows have:

  • code — opaque token used in invite URLs.
  • serverId, createdById.
  • expiresAt — default 10 days from creation.
  • maxUses / uses — single-use or multi-use semantics.

The REST surface for invite creation, lookup, and consumption is part of server-directory. Invite consumption is transactional: a single-use invite is decremented before the membership row is created so a race cannot create two memberships off one invite.


Bans

server_ban rows carry userId, serverId, optional expiresAt, optional reason, and createdById. Active bans are matched on (serverId, userId) with the expiry filter applied at read time. A ban broadcast envelope notifies connected peers so the client can drop the banned user from the local room model — TODO: confirm whether such an envelope exists today or whether clients only learn of bans on the next server_users snapshot.


Slowmode

slowModeInterval is a per-channel hint expressed in seconds. The client renders the cooldown UI and is expected to gate the send button locally. The server does not enforce slowmode today — a non-cooperating client can ignore the interval. This is a known gap; see TODOs.


Business rules and invariants

  • Server is authoritative. Client-side canX selectors are advisory and exist to prevent UI confusion, not to gate security.
  • Last-writer-wins by position — there is no inherent allow/deny priority; a higher-positioned role can override a lower-positioned role's deny.
  • Privilege escalation is blocked by requiring strict position-greater on the moderator. Same-position moderation is rejected on both sides.
  • Invite consumption is atomic for single-use invites.
  • Bans persist independently of memberships — a banned user without a membership row is still banned.
  • system-everyone always applies at position 0 (or whatever the migration seeds); it cannot be removed.
  • Channel overrides resolve last after role base state.

Technical implementation

Server

  • Services — server/src/services/server-access.service.ts (authorizeWebSocketJoin, joinServerWithAccess, ensureServerMembership, banServerUser, leaveServerUser); server/src/services/server-permissions.service.ts (getServerRoles, getServerAssignments, resolveRolePermissionState, resolveHighestRole, canManageServerUpdate, canModerateServerMember).
  • Entities — server/src/entities/ServerMembershipEntity.ts, ServerBanEntity.ts, ServerInviteEntity.ts, ServerRoleEntity.ts, ServerUserRoleEntity.ts, ServerChannelPermissionEntity.ts.
  • Migrations — 1000000000001-ServerAccessControl.ts, 1000000000005-ServerRoleAccessControl.ts.
  • CQRS — payload types in server/src/cqrs/types.ts (AccessRolePayload, PermissionStatePayload, RoleAssignmentPayload, ChannelPermissionPayload); normalization in server/src/cqrs/relations.ts (normalizeServerRoles, normalizeServerRoleAssignments).
  • REST — server / role / invite / ban routes are mounted under server/src/routes/servers.ts (catalog endpoints are documented in server-directory).
  • WS — server/src/websocket/handler.ts::handleJoinServer (line 155), access_denied emission.

Product client

  • Domain — toju-app/src/app/domains/access-control/.
  • Shared kernel — toju-app/src/app/shared-kernel/access-control.models.ts, moderation.models.ts, room.models.ts (normalizeRoomAccessControl).
  • NgRx — access-control reducers / selectors under toju-app/src/app/store/access-control/.

Testing

  • TODO: no spec was located for server-access.service.ts or server-permissions.service.ts.
  • TODO: no spec was located for the authorizeWebSocketJoin rejection paths.
  • Client-side rule specs likely exist under toju-app/src/app/domains/access-control/domain/rules/*.spec.ts — confirm and list when filling in.
  • TODO: E2E coverage for invite consumption, ban enforcement, and role-position escalation.

Security considerations

  • Slowmode is not server-enforced. A modified client can spam. Treat slowmode as an anti-accident measure, not abuse mitigation.
  • No server-side channel-permission enforcement on message send — only role-state is checked at the join gate. TODO: verify whether per-channel overrides are applied on the message-send path.
  • No audit log of moderation actions. TODO.
  • Password-protected servers rely on the same SHA-256 hashing used for AuthUser passwords — see Security in authentication; the same caveats apply.
  • Position-based escalation guard works only if positions are well-ordered. Position assignment is on manageRoles-bearing users; misconfiguration can produce moderators who can demote each other arbitrarily.
  • Bans are not always broadcast in real time — clients may discover an ongoing ban only on the next server_users snapshot. TODO: confirm the live ban envelope.

Performance considerations

  • Role and assignment lookups are point queries on server_role / server_user_role indexed by serverId.
  • Per-channel override resolution is O(roles × channels) at hydration time; happens once on join_server and is cached client-side.
  • authorizeWebSocketJoin is O(1) for existing members (single membership lookup), O(1)O(invites) for invite consumption.

Known issues and limitations

  • No server-side slowmode enforcement.
  • No dedicated ban-broadcast envelope (or unconfirmed).
  • No audit log for moderation actions.
  • Channel-permission overrides may not be applied on the message-send path server-side — TODO.
  • Unsalted SHA-256 for password-protected server passwords — same gap as user passwords.

  • server-directory — owns server catalog, discoverability, and the REST surface for invites/bans/roles.
  • authentication — provides the oderId identity that access-control authorizes.
  • presenceuser_joined / user_left are emitted only after authorizeWebSocketJoin succeeds.
  • websocket-envelopes — owns the wire shape of join_server, access_denied, role/ban update envelopes.

Changelog

Date Change
2026-05-25 Initial documentation