Skip to content

security(core): scope auth tokens with explicit CRUD permissions and server-assigned login - #7963

Open
ar2rsawseen wants to merge 1 commit into
masterfrom
security/token-permission-model
Open

security(core): scope auth tokens with explicit CRUD permissions and server-assigned login#7963
ar2rsawseen wants to merge 1 commit into
masterfrom
security/token-permission-model

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the token app/endpoint scoping model with explicit CRUD permissions, and makes permission to open a dashboard session an explicit, server-assigned property of the token.

Why the old model could not express what it looked like it expressed. A token's app/endpoint fields are a data-scoping control: authorizer.verify_token matches endpoint against the request path and app against the request's app_id, and only when one is present. They say nothing about what the resolved member may do, and rights.js did not carry them past token resolution, so once a token resolved to its owner it authorized whatever that owner could. Login capability had the same shape: it was inferred from purpose, a string the caller supplies at creation.

The model

Two new fields on auth_tokens, both optional, so existing tokens are untouched.

token_permission - a member.permission-shaped grant ({_:{a,u}, c/r/u/d:{appId:{all, allowed}}}).

  • rights.js intersects it with the owner's own permissions as soon as the member is loaded, before any validator authorizes anything. Every existing feature check therefore becomes scope-aware without being modified - one line at each of the 7 member-load sites.
  • The intersected member does not carry global_admin; that is the authority a scoped token was narrowed away from.
  • Recomputed per request, so it follows the owner's current permissions rather than those they held at creation.

can_login - server-assigned only.

  • Set where the server establishes or propagates a session: setLoggedInVariables, the renderer (/o/render, dashboards reports), the ban-warning mail, and OIDC (enterprise-plugins, linked below).
  • /i/token/create grants it only when the creating credential holds it and the child is not narrowed. It never reads purpose.
  • /login/token/:token gates on it. purpose returns to being a description.

Grants are bounded by the creating credential, not the owner. Because the intersection has already happened, params.member is the creating credential's authority, so isPermissionSubset needs no second lookup. An owner with apps A and B may hold an A-scoped token, and that token cannot produce a child that reaches B. An all grant may only be passed on by a holder of all, since it covers features that do not exist yet.

Token creation, listing and deletion require a full-permission credential. /o/token/list returns whole documents and a document's _id is the token itself, so these three endpoints hand out, expose and revoke the owner's credentials respectively.

Compatibility

Tokens created before this change are unaffected: with no token_permission they are neither intersected nor re-scoped, the endpoint regex still applies to them, and /login/token still accepts the unrestricted login-purpose tokens it accepted before. No migration, no backfill.

The two documented flows keep working: the dashboard session token still authenticates API calls in place of the api_key, and creating a login token and redirecting through /login/token still works from a credential that may itself sign in.

Token manager

The drawer now grants app, CRUD and feature permissions instead of endpoint regexes, reusing the same permission grid helpers as user management (countlyAuth.permissionSetGenerator / combinePermissionObject) and the same feature list (GET /o/users/permissions). Existing tokens still render their legacy endpoint value read-only.

Tests

  • test/unit-tests/api.utils.rights.tokenPermissions.js - the permission algebra: subset boundaries, all propagation, admin apps, the intersection, global_admin stripping, and permissions reduced after issue.
  • test/unit-tests/api.utils.authorizer.tokenFields.js - the token record: field persistence, can_login strictness, and that the endpoint regex still applies to legacy tokens but not to permission-scoped ones.
  • test/2.api/16.token.manager.js - create, login, list and delete boundaries.

21 unit tests pass; lint clean.

Related

Supersedes the earlier containment PRs #7957 / #7958, which were closed in favour of this model.

Reported through the security bug bounty program (received 2026-08-18).

🤖 Generated with Claude Code

…server-assigned login

Token scope was expressed as an app list and an endpoint regex, which verify_token compared
against the request path and, when present, the request's app_id. That is a data-scoping
control: it does not describe what the resolved member may do, and rights.js did not carry
it past token resolution, so a token authorized whatever its owner could. Login capability
was likewise inferred from the token's purpose string, which is supplied by the caller at
creation.

Tokens now carry explicit permissions and an explicit login capability.

- token_permission stores a member.permission-shaped grant on the token. rights.js
  intersects it with the owner's own permissions as soon as the member is loaded, before any
  validator authorizes anything, so every existing feature check becomes scope-aware without
  being touched. The intersected member does not carry global_admin. The intersection is
  recomputed per request, so it follows the owner's current permissions.
- A grant is bounded by the credential that creates it, not by the owner. Because the
  intersection has already happened, params.member is that credential's authority, and
  isPermissionSubset refuses anything wider. An "all" grant may only be passed on by a
  holder of "all", since it covers features that do not exist yet.
- can_login is a server-assigned property of the token. It is set where the server
  establishes or propagates a session (setLoggedInVariables, the renderer, the ban-warning
  mail), and by /i/token/create only when the creating credential holds it and the child is
  not narrowed. purpose returns to being a description.
- Token creation, listing and deletion require a full-permission credential, since each of
  them hands out, exposes or revokes the owner's credentials.

Tokens created before this change are unaffected: with no token_permission they are neither
intersected nor re-scoped, the endpoint regex still applies to them, and /login/token still
accepts the unrestricted login-purpose tokens it accepted previously.

The token manager grants app, CRUD and feature permissions using the same permission grid
and helpers as user management, instead of endpoint regexes.

Covered by unit tests for the permission algebra and the token record, and by API tests for
the create, login, list and delete boundaries.

Reported through the security bug bounty program (received 2026-08-18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants