Skip to main content

Source of truth: AUTHORIZATION.md — do not edit this generated copy.

Authorization Standard

This is the law for admin authorization in this codebase. Every protected endpoint and every change to access logic must follow it. It is descriptive of the current architecture and prescriptive for new code — where current code deviates, the deviation is listed under Known deviations and is a bug to be closed, not a precedent to copy.

One-line summary: every protected action is Capability × Scope, and both are derived from a single A/G/M/D level matrix resolved in exactly one file.


1. The model: Capability × Scope

Every protected action resolves to two independent checks. Neither is optional for a record-scoped endpoint.

COARSE (capability) FINE (scope)
"may this role do this "may it touch THIS
action on this resource at all?" specific record?"
│ │
@RouteName('verb.resource') @RequireChatRecord(...) /
│ access-filter on the query
│ │
PermissionGuard record guard / row filter
│ │
└──────────── both derived from ──────┘
A/G/M/D level matrix
(resolver: access-level.util.ts)
  • Coarse answers "does the role hold the capability?" — checked by permission.guard.ts against user.permissions (the JWT snapshot). Wrong answer ⇒ 403.
  • Fine answers "does the role's scope include this record?" — A/G/M/D narrows which rows are visible/mutable. Wrong answer ⇒ 403 (single record) or the row is filtered out (lists).

The two storage tiers (kept split on purpose)

TierTableShapeGoverns
Binary capabilitiesrole_permissionspermissionsgranted / notSystem features (settings, billing, user mgmt)
Graded matrixrole_object_rightslevel per (role, object, action)CRM record access
Graded matrixrole_chat_rightslevel per (role, provider, action)Messaging access

A/G/M/D levels

Highest across an admin's roles wins: A > G > M > D.

  • A — All records on the resource.
  • G — records owned by anyone in my Group.
  • M — records I own (Mine). The safe default when no row exists (for read-type actions).
  • D — Denied (hard deny).

Some actions are binary (scope-less, collapsed to A/D): object add, chat create. The rest carry full A/G/M/D scope.


2. The 5 invariants (the law)

Every line of authz code must obey all five. PRs that break one are rejected.

Invariant 1 — One resolver, zero re-implementation

All fold/resolve logic lives only in access-level.util.ts: LEVEL_PRIORITY, foldHighestLevel, foldLevelsByKey, the declarative OBJECT_CELL_POLICY / CHAT_CELL_POLICY tables, and resolveObjectCell / resolveChatCell.

❌ No controller, guard, service, or filter may compare two levels, pick a "highest", or hardcode a fallback. Import from access-level.util.ts instead.

Invariant 2 — Capability is derived from the level, never stored twice

The endpoint capability (user.permissions route_names) is projected from the level matrix in the JWT snapshot (admin-jwt.strategy.tsbuildAuthResult), via desiredObjectRouteNames / desiredChatRouteNames. Legacy binary per-object/per-chat perms are dropped from the snapshot (isObjectGateRoute).

❌ Do not add a permissions catalog row that duplicates something a level already governs. Object endpoint access is owned by the level (the "single gate", Kommo-style) — there is no per-object binary perm and no AutoAssignmentService.

Invariant 3 — Action → route mapping lives in ONE table per resource

The mapping from a resource action to the route_name verb(s) it gates is a single declarative table, never a per-endpoint decision:

❌ A controller method must not invent its capability by hand-picking a @RouteName string. It declares which resource action it is; the table decides the verb. Hand-picking is exactly how a write action ends up gated by read (see Known deviations).

The cell-policy tables are exhaustive Record<Action, …>, so adding an action is a compile error until its policy and route mapping are set. Keep them that way.

Invariant 4 — Coarse + fine are applied together, or the exception is labeled

A record-scoped endpoint needs both the @RouteName capability gate and a record-scope guard/filter. A route that has only the coarse gate is a deliberate capability-only exception (e.g. cross-provider bulk ops like link-all, sync/history) and MUST carry a comment saying so and why no record scope applies.

@SkipPermissions() on a data-touching admin route is forbidden unless the handler is genuinely public/global and documents why. "It was easier" is not a reason.

Mechanically enforced. authorization-coverage.spec.ts reflects over every messaging controller handler and fails CI if any route is missing a @RouteName, uses @SkipPermissions(), or carries a route name that isn't a valid chat.{action}[.{providerType}] capability. A new ungated or typo'd route fails this test, not a reviewer's vigilance.

Invariant 5 — Fail loud on misconfiguration, closed on denial

Denial is 403/filtered. But a guard that cannot classify its own metadata (unknown action/kind combo) is a programming error and must be impossible by type, not swallowed by a runtime default: return false.

❌ No default → deny fallthrough that hides an invalid decorator combo. Narrow the option types so bad combos don't compile.


3. Enforcement layers (where each check physically runs)

LayerObject pathChat path
Snapshot (per request, cached 5-min)buildAuthResult folds levels → desiredObjectRouteNamessame → desiredChatRouteNames
Coarse gatePermissionGuard + @RouteName('{verb}.{objectSlug}')PermissionGuard + @RouteName('chat.{action}[.{providerType}]')
Placeholder resolution{objectSlug} ← URL param{providerType} ← URL param (substitutePlaceholders)
Fine scope (lists)access-filter.builder.tschat-access-filter.builder.ts + chat-visibility.policy.ts
Fine scope (single record)service-level level checkchat-record.guard.ts via @RequireChatRecord

A ⇒ no filter, G ⇒ group members, Madmin_id = me, D1=0. Super-admins bypass the coarse gate (PermissionGuard) entirely.


4. Checklist — adding a protected admin endpoint

Copy this into the PR description and tick every box.

  • Pick the resource + action. Is this an object or chat action? Which action (view/add/edit/delete/export for objects; read/create/update/delete for chat)?
  • Coarse gate: add @RouteName('<verb>.<resource>'). Use the placeholder form ({objectSlug} / {providerType}) if the route is keyed by one — never a hardcoded slug/provider.
  • The verb comes from the action→route table (Invariant 3). If your action has no verb yet, add it to the table — do not invent a string.
  • Fine scope: add the record-scope guard/filter for the SAME action. - Single record → @RequireChatRecord({ kind, action, param }) (chat) or a service-level level check (object). - List → run the query through the access-filter builder.
  • Write actions are gated by write capabilities. Sending/editing/deleting must NOT resolve to a read route_name.
  • No @SkipPermissions() unless the route is genuinely public/global and a comment explains why.
  • Validate path enums (@Param('providerType', new ParseEnumPipe(...))) even if the handler doesn't bind the value — super-admins skip the coarse gate, so the pipe is the only validation left for them.
  • No level math in your code — import from access-level.util.ts.
  • Tests: cover the denied case, the M-default case, and (for derivation changes) the all-denied / cross-provider-survival cases — see chat-route-grants.util.spec.ts.

5. Known deviations

Resolved

  1. Message writes now require write capability. messages.controller.ts gates send / edit / retry / ack on chat.update, delete on chat.delete, and sync/history on chat.create. Read-type ops (read / search / played) stay on chat.read. The fine layer remains message visibility (canAccessMessage).

  2. ParseEnumPipe restored on every session route. All sessions.controller.ts handlers validate providerType at the pipe (unused bindings are _providerType per the lint convention), and createSession rejects a non-WAHA URL provider up front so the URL can't disagree with what's created.

  3. ChatRecordGuard fails loud, not silent. chat-record.guard.ts switches on the discriminated-union action with a compile-time assertNever; the decorator's RequireChatRecordOptions union makes unsupported kind:action combos a compile error (Invariant 5).

  4. Docs are accurate. messaging/ARCHITECTURE.md and messaging/CHANGES.md already describe assertProviderAction as removed and note canProviderAction is still used internally by ChatAccessService. No drift.

  5. Cross-provider bulk ops are labeled (Invariant 4's documented exception). link-all and both sync/history routes carry the coarse-capability-only comment; chat sync/history additionally calls assertCanSyncChatSession for a provider-precise check when session_slug is given. Intentional — revisit only if per-provider scoping on bulk ops is required.

Open

None. All identified deviations are closed; the model is fully conformant across both resources. The next architectural step is the Rule-of-Three engine extraction (§6), triggered by a third protected resource — not before.


6. When to extract a generic engine (Rule of Three)

Objects and chat are currently two parallel implementations of this model (object-route-grants.util.tschat-route-grants.util.ts, access-filter.builder.tschat-access-filter.builder.ts). This duplication is allowed — two instances do not justify a generic ResourceAccessPolicy engine. Extract the engine only when a third protected resource appears (e.g. calls, files, dashboards). Until then, keep the twins in lockstep by following the invariants above; premature abstraction for two cases is itself a violation of the project's code principles.