Authorization
The permission registry, route decorators, the policy service, group access, and live access revocation.
Staff access is decided in exactly one place, the policy service. Every other piece — the permission registry, the route decorators, the route audit — exists to keep that true. This page documents the pieces and how a module adds itself to them.
The registry
A module declares its permissions once, as a ModulePermissions value built
with definePermissions()
(apps/api/src/authorization/registry/module-permissions.ts), and registers it
as a provider with permissionsProvider(). PermissionRegistry
(apps/api/src/authorization/registry/registry.service.ts) collects every
declaration through Nest's DiscoveryService at start-up
(onModuleInit) and throws if two modules declare the same resource.action
key.
On the api process's boot (onApplicationBootstrap, gated by
RegistryOptions.syncOnBootstrap), PermissionRegistry.sync():
- Upserts every declared key into
permission_definitions(under an advisory lock, so severalapiinstances starting together don't race). Keys that exist in the database but no longer appear in code are left alone and only logged — grants referencing them stay valid. - Grants every key to each tenant's Admin role
(
grantRegistryToAdminRole), paging tenants and skipping already-granted rows (ON CONFLICT DO NOTHING). This is the only place Admin's access comes from; there is no hard-coded bypass. If any grant was new, it bumps that tenant's access version so already-cached Admins pick it up.
The initial set of declarations for every module built so far lives in
apps/api/src/authorization/registry/initial-permissions.ts. Once a module
owns its own permissions it registers them from its own module file instead of
this list.
Adding a module's permissions
-
In the module's own file, declare its resources and actions with
definePermissions():// apps/api/src/webhooks/webhooks.module.ts import { definePermissions, permissionsProvider } from '../authorization/registry/module-permissions.js'; const WebhookPermissions = definePermissions({ module: 'integrations', resources: [ { resource: 'webhook', actions: [ { action: 'view', description: 'View webhooks' }, { action: 'create', description: 'Create a webhook' }, { action: 'edit', description: 'Edit a webhook' }, { action: 'delete', description: 'Delete a webhook' }, ], }, ], });Module, resource and action names must match
^[a-z][a-z_]*$(NAME_PATTERN);definePermissions()throws at start-up otherwise. A permission key isresource.action(e.g.webhook.create), so it must be unique across every module. -
Register it as a provider in the module:
@Module({ providers: [WebhooksService, permissionsProvider(WebhookPermissions)], }) export class WebhooksModule {} -
Guard every route that touches the resource with
@RequirePermission('webhook.create')(see below). The route audit fails start-up if the key isn't in the registry, so a typo is caught immediately rather than at request time. -
On the next
apistart-up,PermissionRegistry.sync()upserts the new keys and grants them to every tenant's Admin role automatically — nothing else to run. Non-Admin roles never get the new permission until an admin edits a role to add it (RolesService.update).
Route decorators and the route audit
Every controller route must carry exactly one access decorator
(apps/api/src/authorization/registry/module-permissions.ts), either on the
handler or, if the handler has none, on the controller:
@RequirePermission(key)— staff only; the permission guard asks the policy service to decidekey. This is the default for any route that touches tenant data.@Public()— no session needed (sign-in, branding, health checks).@StaffApi()— any signed-in staff user, no permission key. Confined to the caller's own account (/auth,/me) — never for tenant data.@CustomerApi()— customer session, authorized by ownership of the caller's own conversation, undercustomer/*.@OperatorApi()— console host with an operator session, underplatform/*.
RouteAudit (apps/api/src/authorization/registry/route-audit.ts) runs on
onApplicationBootstrap and throws (stopping the api process from starting)
if any route:
- has zero or more than one access decorator;
- uses
@RequirePermissionwith a key the registry doesn't know; - uses
@StaffApi()outside/author/me; - uses
@CustomerApi()outside/customer/*, or a non-customer decorator inside it (other than@Public()); - uses
@OperatorApi()outside/platform/*, or a non-operator decorator inside it (other than@Public()).
PermissionGuard (apps/api/src/authorization/permission.guard.ts), the last
guard in the pipeline, enforces the decorator at request time:
@Public()— passes.@OperatorApi()— needs the console host and an operator session, else 404.@StaffApi()— needs a staff actor, else 404.@CustomerApi()— needs a customer actor, else 404.@RequirePermission(key)— a support session (an operator under a support grant) passes every permission route, since the grant is its authorization and the support guard has already confined it to reads. Otherwise it needs a staff actor (else 404) and asksPolicyService.can():allowpasses,not_found→ 404,deny→ 403.
A customer hitting a staff route, or staff hitting a customer route, always gets 404, not 403 — the other surface doesn't exist for them.
The policy service
PolicyService (apps/api/src/authorization/policy.service.ts) is the only
place that decides staff access. Effective access for a user is the union,
over every role they hold, of:
- the role's
role_permissionsrows (a flat set of permission keys), and - the role's
role_group_accessrows: per group id (and the built-in Ungrouped entry,group_id = null),view/create/edit/deleteflags.
Only active staff users (users.status = 'active', users.kind = 'staff')
have any access; anyone else gets emptyAccess().
decide(access, key, resource?) is the pure decision function:
- For a non-ticket key,
access.permissions.has(key). - For a ticket resource (
{ type: 'ticket', groupId }), the action needs both the registry key and the matching group flag (TICKET_ACTION_FLAGS:ticket.view|create|edit|deletemap to the same-named flag;ticket.merge|split|bulk_update|move_messageneededit). If the caller can't view the ticket's group (ticket.viewmissing, or noviewflag on that group), the result isnot_found, neverdeny— a ticket outside the caller's groups doesn't exist for them.
PolicyService.can(ctx, key, resource?) decides for the context's user actor
(ctx.actor.kind === 'user'; anything else throws — the policy service never
decides for system or support actors).
The ticket access filter
ticketAccessFilter(ctx, action, column = 'tickets.group_id') returns a Kysely
expression that scopes a list query to the groups (and Ungrouped) where the
caller can perform action. It's built from the same grantedGroups() used
by decide(), so a list and a single read always agree:
const filter = await policy.ticketAccessFilter(ctx, 'view');
const tickets = await tx
.selectFrom('tickets')
.selectAll()
.where(filter)
.execute();grantedGroups(access, action) returns { groupIds, ungrouped }: empty
(nothing granted) if the caller lacks the matching registry key
(ticket.view for view, etc.), otherwise every group id with that flag set,
plus ungrouped: true if the Ungrouped entry has it. groupFilter() turns
that into column = ANY(groupIds) OR (ungrouped AND column IS NULL), or
false when nothing is granted.
Group access and Ungrouped
A group's access lives in role_group_access, one row per
(role, group_id); group_id = null is the built-in Ungrouped entry — tickets
with no group assigned. apps/api/src/authorization/role-access.ts holds the
pure rules shared by the roles and groups services:
diffGroupAccess(current, next)— whether a role edit changed any flag on any group, and which groups (Ungrouped included) lostedit(lostEdit).RolesService.updateappends arole.updatedevent withgroupsLostEdit(apps/api/src/authorization/roles.service.ts) so the tickets module can unassign owners who can no longer edit those tickets (FR-026).reducesAccess(current, next)— true ifnexttakes away any permission key or group flagcurrentgrants.RolesService.updaterefuses to shrink the Admin role's access this way, with 400ADMIN_ACCESS_FIXED.newGroupAccess(roles, groupId)— a brand-new group starts with full access for the Admin role only, nothing for anyone else (GroupsService.create→GroupsRepository.grantAdminFullAccess, FR-029).
GroupsService.eligibleOwners (apps/api/src/groups/groups.service.ts) is
the reference implementation for checking a group directly: it looks up the
group (404 if unknown), then for a user actor calls
policy.can(ctx, 'ticket.edit', { type: 'ticket', groupId }) — not_found →
404, deny → 403 — before asking PolicyService.eligibleOwners() for active
staff with edit on that group. A support session skips the check; the grant
is its authorization.
The tickets module supplies its ticket counts back to the groups module
through the GROUP_TICKET_STATS injection token (GroupTicketStats
interface in groups.service.ts); until a provider is bound, GroupsService
falls back to a stub that reports zero tickets everywhere.
Access version and caching
Effective access is cached in Redis under
access:{tenantId}:{accessVersion}:{userId} for ACCESS_CACHE_TTL_S (15
minutes). bumpAccessVersion(tx, tenantId, reason, outbox?)
(apps/api/src/authorization/access-version.ts) increments
tenants.access_version and appends an access.changed event to the tenant's
outbox stream, in the same transaction as the change — so the cache key for
every subsequent read changes atomically with the grants it reads, and there
is nothing to explicitly invalidate.
Call bumpAccessVersion in the same transaction as any change to roles, role
permissions, group access, user roles, or user status. It's already called by:
PermissionRegistry.sync()when new keys are granted to Admin roles;RolesService.create/update/delete;GroupsService.create/delete(and implicitly by role updates that touch a group's access).
PolicyService.effectiveAccess() reads tenants.access_version and the
grants in the same read-only transaction, checks the Redis cache first, and on
a miss loads from user_roles joined to role_permissions and
role_group_access, then writes the cache. A Redis failure (read or write) is
swallowed and logged — access still gets decided from the database.
Live revocation flow
sequenceDiagram
participant Tx as Committing transaction
participant Outbox as Outbox / relay
participant Handler as AccessChangeHandler
participant Socket as Staff socket
Tx->>Tx: bumpAccessVersion (access_version++, access.changed appended)
Tx-->>Outbox: commit
Outbox->>Handler: access.changed (per api process)
Handler->>Handler: fetch tenant's local staff sockets
loop each socket
Handler->>Handler: recompute effective access (fresh, new version)
Handler->>Socket: leave tickets:group:* rooms no longer allowed
Handler->>Socket: leave ticket:* rooms no longer visible
Handler->>Socket: join newly granted tickets:group:* rooms
Handler->>Socket: emit access.changed { accessVersion }
Handler->>Socket: emit access.revoked { ticketIds?, groupIds? } (if anything left)
endAccessChangeHandler
(apps/api/src/platform-kernel/realtime/access-change.handler.ts) runs on
every api process that receives the access.changed control message, over
that tenant's locally-connected staff sockets only (server.of(STAFF_NAMESPACE).local.in(tenantRoom(...))).
For each socket it recomputes grantedGroups(effectiveAccess, 'view') — using
the bumped version, so the read is never stale — and:
- Leaves any
tickets:group:*room no longer in the allowed set, and any joinedticket:*room the caller can no longer see (StreamAccess.canSeeTicket). - Joins any newly granted
tickets:group:*room. - Emits
access.changed { accessVersion }on the socket'suserstream unconditionally, so open screens know to refetch. - If anything was revoked, also emits
access.revoked { ticketIds?, groupIds? }so open ticket or list screens can close or redirect.
The outbox relay (apps/api/src/platform-kernel/outbox/relay.ts) carries
these events; the realtime-events skill describes the streams and rooms.
Checklist when adding a module
- Declare the module's resources and actions with
definePermissions()and register them withpermissionsProvider()in the module. - Names (module, resource, action) match
^[a-z][a-z_]*$. - Guard every controller route with exactly one access decorator —
@RequirePermission(key)for anything touching tenant data. - If the resource needs group scoping (like tickets), add its action to a
TICKET_ACTION_FLAGS-style map and usepolicy.can(ctx, key, { type: 'ticket', groupId })orpolicy.ticketAccessFilter()rather than checking the permission key alone. - Any write that changes roles, role permissions, group access, user
roles, or user status calls
bumpAccessVersion()in the same transaction. - Run the app once locally (or the test suite) so
RouteAuditcatches a missing or unknown decorator before it reaches review. - Add a cross-tenant fixture for each new resource in
apps/api/test/cross-tenant/fixtures.ts; the generated suite fails without one (see thetesting-conventionsskill).
Local development
Run the whole ReplyX stack on your machine with Docker Compose, sign in with the seeded accounts, and run the linters, tests and end-to-end suites the way CI does.
Conversation routing
How a customer's message finds its ticket, stays idempotent under concurrency, and reaches the customer as a ticket-free projection.