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/Dnarrows 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)
| Tier | Table | Shape | Governs |
|---|---|---|---|
| Binary capabilities | role_permissions → permissions | granted / not | System features (settings, billing, user mgmt) |
| Graded matrix | role_object_rights | level per (role, object, action) | CRM record access |
| Graded matrix | role_chat_rights | level 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.tsinstead.
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.ts →
buildAuthResult), via desiredObjectRouteNames /
desiredChatRouteNames. Legacy
binary per-object/per-chat perms are dropped from the snapshot
(isObjectGateRoute).
❌ Do not add a
permissionscatalog 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 noAutoAssignmentService.
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:
- Objects:
OBJECT_ACTION_TO_ROUTE_VERBSin object-route-grants.util.ts. - Chat: the equivalent table in chat-route-grants.util.ts.
❌ A controller method must not invent its capability by hand-picking a
@RouteNamestring. It declares which resource action it is; the table decides the verb. Hand-picking is exactly how a write action ends up gated byread(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 → denyfallthrough that hides an invalid decorator combo. Narrow the option types so bad combos don't compile.
3. Enforcement layers (where each check physically runs)
| Layer | Object path | Chat path |
|---|---|---|
| Snapshot (per request, cached 5-min) | buildAuthResult folds levels → desiredObjectRouteNames | same → desiredChatRouteNames |
| Coarse gate | PermissionGuard + @RouteName('{verb}.{objectSlug}') | PermissionGuard + @RouteName('chat.{action}[.{providerType}]') |
| Placeholder resolution | {objectSlug} ← URL param | {providerType} ← URL param (substitutePlaceholders) |
| Fine scope (lists) | access-filter.builder.ts | chat-access-filter.builder.ts + chat-visibility.policy.ts |
| Fine scope (single record) | service-level level check | chat-record.guard.ts via @RequireChatRecord |
A ⇒ no filter, G ⇒ group members, M ⇒ admin_id = me, D ⇒ 1=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
objectorchataction? Which action (view/add/edit/delete/exportfor objects;read/create/update/deletefor 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
readroute_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
-
✅ Message writes now require write capability. messages.controller.ts gates
send/edit/retry/ackonchat.update,deleteonchat.delete, andsync/historyonchat.create. Read-type ops (read/search/played) stay onchat.read. The fine layer remains message visibility (canAccessMessage). -
✅
ParseEnumPiperestored on every session route. All sessions.controller.ts handlers validateproviderTypeat the pipe (unused bindings are_providerTypeper the lint convention), andcreateSessionrejects a non-WAHA URL provider up front so the URL can't disagree with what's created. -
✅
ChatRecordGuardfails loud, not silent. chat-record.guard.ts switches on the discriminated-union action with a compile-timeassertNever; the decorator'sRequireChatRecordOptionsunion makes unsupportedkind:actioncombos a compile error (Invariant 5). -
✅ Docs are accurate. messaging/ARCHITECTURE.md and messaging/CHANGES.md already describe
assertProviderActionas removed and notecanProviderActionis still used internally byChatAccessService. No drift. -
✅ Cross-provider bulk ops are labeled (Invariant 4's documented exception).
link-alland bothsync/historyroutes carry the coarse-capability-only comment; chatsync/historyadditionally callsassertCanSyncChatSessionfor a provider-precise check whensession_slugis 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.ts ↔ chat-route-grants.util.ts,
access-filter.builder.ts ↔ chat-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.