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>
This commit is contained in:
232
agents-docs/features/access-control.md
Normal file
232
agents-docs/features/access-control.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# 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](./server-directory.md).
|
||||
- Authentication or identity binding → [authentication](./authentication.md).
|
||||
- The wire format of `join_server`, `access_denied`, role/ban update envelopes → [websocket-envelopes](./websocket-envelopes.md).
|
||||
- Online status, voice presence, game activity → [presence](./presence.md).
|
||||
|
||||
## 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](./presence.md).
|
||||
|
||||
---
|
||||
|
||||
## 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](./server-directory.md). 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](./server-directory.md)).
|
||||
- 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](./authentication.md); 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.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- **[server-directory](./server-directory.md)** — owns server catalog, discoverability, and the REST surface for invites/bans/roles.
|
||||
- **[authentication](./authentication.md)** — provides the `oderId` identity that access-control authorizes.
|
||||
- **[presence](./presence.md)** — `user_joined` / `user_left` are emitted only after `authorizeWebSocketJoin` succeeds.
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `join_server`, `access_denied`, role/ban update envelopes.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
Reference in New Issue
Block a user