Skip to content

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

Open
ar2rsawseen wants to merge 16 commits 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 16 commits 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 keep working on data endpoints: 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.

One deliberate exception. The three token-management endpoints now require a full-permission credential, so a legacy token restricted by app or endpoint is refused at /i/token/create, /o/token/list and /i/token/delete - including on /o/token/list where its endpoint restriction would previously have matched. That is the point of the change rather than a side effect: listing returns whole token documents, and a document's _id is the token, so a scoped credential could otherwise read out credentials wider than itself. The existing test that asserted the old behaviour is updated in this PR.

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 - 16 cases covering the create, login, list and delete boundaries.

Run against a clean instance (isolated dashboard + API on their own MongoDB), the full test/1.frontend + test/2.api chain gives 238 passing / 21 failing, with all 16 token cases and all 21 unit tests passing. The 21 failures are Testing event settings and are pre-existing: pristine origin/master fails the same 21 on the same instance. Note api/parts/data/usage.js requires offline-geocoder, which is declared but not installed by a plain npm install here, and pluginManager.connectToAllDatabases hardcodes the database names, so isolating a test instance needs a separate mongod rather than a different database name.

The regression tests earned their keep: they caught a real hole in the first version of this change. A scoped credential could still mint an unrestricted child by simply omitting the permission parameter - the subset check had no object to compare and so never ran. Fixed in the second commit; a scoped credential must now state what it grants.

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>
claude and others added 9 commits August 19, 2026 10:24
…ants

A token created without an explicit permission carries no token_permission, and a token
with no token_permission is bounded only by its owner. A scoped credential could therefore
create a child wider than itself by simply omitting the parameter: the subset check had no
permission object to compare and so never ran, which meant the omission itself was the way
around it. Refuse instead - a credential that is itself scoped has to say what it grants.

Also brings the existing token-list test to the behaviour this model defines. /o/token/list
returns whole token documents and a document's _id is the token itself, so listing is
restricted to a full-permission credential; an endpoint-scoped token is refused there even
when its endpoint matches. The login test now supplies the permission the token already
holds, so what it asserts is the login grant being refused rather than the missing
permission.

Found by the regression tests added with the permission model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three layout defects in the permission drawer.

- The two permission cards overflowed the drawer. is-autosized gives each width:100%, and a
  flex item's default min-width:auto stops it shrinking below its own text, so the card with
  the longer description pushed the other one off screen. They now share the line evenly and
  are allowed to shrink.
- The feature grid's column headers and its per-feature checkboxes were laid out
  independently, flex-end against space-between, so the checkboxes did not sit under the
  headers. Both now use one name column plus four equal cells, and in the header the column
  name sits above its checkbox rather than beside it, since inline the label widens the cell
  and pushes the checkbox off the centre the rows align to.
- The app selector was a plain el-select and showed no app icon. It now uses cly-app-select,
  which is the component that renders them.

That last one needs cly-checklistbox to accept an option-prefix slot. cly-select-x forwards
the slot only to cly-listbox, which serves single-select, so no multi-select app dropdown in
the dashboard can show icons today. The slot is additive and renders only when a caller
passes one, so every existing checklistbox is unchanged.

The token-manager stylesheet existed but was empty and was never imported by manifest.scss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The existing cases cover the token manager itself - what a token may create, list, delete and
sign in with. They say nothing about what a token can actually reach, which is the point of
the model, so this adds the same checks against endpoints that go through the ordinary
validators.

A token is scoped along three axes, and each is now exercised on a real endpoint:

- app: a token granted read on one app is refused on another app of the same owner
- CRUD type: a read token is refused an update, and an update token is refused a delete
- feature: a token granted core is refused an events endpoint, and vice versa

with the granted combination allowed through in each case.

Another app is refused by two independent layers, and both are covered. The token's app list
is derived from its permission, so verify_token normally rejects the request before a member
is loaded ("Token not valid"). Passing apps explicitly widens that list without widening the
permission, which gets past the app check and leaves the permission intersection to refuse it
("User does not have right") - so the second layer is not merely shadowed by the first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hanging under a token

Three areas the earlier cases left open.

Token shapes. A token is created in a number of shapes - one feature, several features, several
apps, a whole CRUD type - and each is now minted and then exercised on a real endpoint of a real
feature: alerts carries create, read and update, events carries update and delete, so every CRUD
type is covered on a plugin and on core. Each token reaches the endpoint it was granted, is
refused another access type of the same feature, and is refused another feature entirely. The
"all" grant is checked to reach features that were never named, and a two-app token to reach
both apps while still being held to its one feature.

The login path. /login/token is the only way a token becomes a dashboard session and /session is
what that session is then checked with, and neither is reached through the api_key. A token
carrying login permission is redeemed and the session it opens is then confirmed live; a scoped
token is refused at the same door.

Permissions changing under a token. The intersection is recomputed per request rather than frozen
at creation, so a member is created, mints a token with the permissions they hold, loses the app,
and the token loses it with them without being touched.

One note on the assertion used for the granted case. "Not 401" is too weak on its own: an endpoint
whose plugin is not loaded answers 400 "Invalid path", which let an earlier draft of these tests
pass without ever reaching a validator. The check now rejects that answer explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things that made these cases depend on the environment rather than on the code.

The plugin cases need that plugin installed. Where it is not, its endpoints answer 400
"Invalid path", so they are now skipped explicitly after a single probe rather than run against
an endpoint that was never there. The core cases do not depend on any plugin and always run.

The two core cases checked their "another feature" refusal against /o/app_users/download, which
rejects the request on its own parameters before any validator sees it, so the refusal proved
nothing. They now check it against a core read, which reaches the validator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding the option-prefix slot wrapped the option label in two spans across several lines, so the
rendered el-checkbox__label gained nested nodes and surrounding whitespace. Callers that assert on
that label exactly stopped matching: the alerts UI test looks for /^8.0.9$/ inside
el-checkbox__label and timed out.

The slot is now emitted bare, with no wrapper and no v-if, immediately before the label
expression. A slot nobody fills renders nothing at all - not even the placeholder a falsy v-if
leaves behind - so for every existing checklistbox the label is byte-identical to before. The
app icon supplied by cly-app-select brings its own spacing, so the stylesheet rule the wrapper
needed is gone too.

Caught by the dashboard UI tests in CI, which pass on the base branch and failed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The granted case for update on alerts called /i/alert/status with no parameters of its own. The
handler parses qstring.status, and where that parse is not guarded it throws inside the callback
and never replies, so the request hung and the case timed out after 50s rather than failing on an
authorization outcome.

Each probed endpoint is now given whatever its handler needs to answer, which is what a real
caller would send. The assertion is unchanged and still about authorization only.

Found by CI on the 24.05 backport, where that parse is unguarded; master guards it with a
try/catch and answered 500, so the same case passed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eterministically

That case needs a member of its own, and the cleanup suite later asserts that exactly one user
remains, so a member left behind fails a test in a different file. The cleanup went through
/i/users/delete and ignored its result, which made the whole thing conditional on that call
succeeding.

The member and any tokens they own are now removed directly, which is what the rest of this file
already does for tokens.

Found by CI on master: "Verify user deletion should return one user" failed while the token cases
themselves passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The render endpoint mints an owner-authority login token and drives a headless dashboard
session with it. Gate it with isScopedCredential so a scoped token_permission token, or a
legacy app/endpoint-restricted one, cannot have a view rendered with more authority than the
token itself carries. api_key callers and unrestricted session tokens are unaffected. This is
the same rule already applied to token create, list and delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread api/utils/authorizer.js
// permission model carry token_permission instead, which rights.js intersects with
// the owner's own permissions on every request. Legacy tokens (no token_permission)
// keep being matched exactly as before, so existing integrations are unaffected.
if (!res.token_permission && res.endpoint && res.endpoint !== "") {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not bypass endpoint scoping until every verifier applies token permissions

This disables the legacy endpoint check for any token carrying token_permission, assuming the request will be intersected in rights.js. /o/actions is a counterexample: its countly-token branch calls authorize.verify_return() directly, receives only the owner, and then invokes getHeatmap() without any rights validator. A token scoped to another feature on the same app therefore passes its derived app list and reads heatmap data it was not granted. Return/apply the token document in that branch, or centralize permission enforcement in authorization before skipping endpoint restrictions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — /o/actions was the one route where applyTokenScope never ran. Fixed in ad5a444.

It resolves its own token: authorize.verify_return in the countly-token branch, then straight to getHeatmap. So only the token's app restriction was ever checked, and its permissions were not consulted at all — a token scoped to another feature on the same app passed, exactly as you describe.

Took your first option, since the second (centralising in authorization) would mean the authorizer deciding feature-level rights, which is what rights.js is for:

return_data: true,
callback: function(tokenData, expires_after) {
    
    params.token_data = tokenData;
    common.db.collection('members').findOne({_id: common.db.ObjectID(tokenData.owner + "")}, function(memberErr, member) {
        var scoped = (memberErr || !member) ? null : applyTokenScope(params, member);
        if (!viewsUtils.ownerCanRead(scoped, app._id + "", FEATURE_NAME)) {
            common.returnMessage(params, 401, 'User does not have view right for this application');
            return false;
        }
        

applyTokenScope is exported for it, so this route bounds the member the same way and in the same order rights.js does — before anything is authorized.

Two details in the read check, so it does not over-correct:

  • Legacy user_of membership is honoured. hasReadRight alone does not cover it, but validateRead does, so refusing it here would take away access the same member's api_key still has.
  • A locked account is refused, as validateRead refuses it. This branch checked neither before.

Endpoint scoping stays disabled for permission-carrying tokens, which I read as the intent of the change rather than the problem — the problem was that nothing applied the permissions in its place on this route. Now something does.

Unit cases cover applyTokenScope directly: unchanged for a token with no permissions, narrowed for one with them (events on the app is refused though the owner has it), never widened, and a global admin still bounded by their own token's scope.

//revoking the owner's other credentials is credential management, not
//something a token narrowed to a subset of the owner's access may do
if (isScopedCredential(params)) {
common.returnMessage(params, 403, "A restricted token cannot delete tokens");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Block scoped tokens from account-level 2FA management too

This protects token deletion as credential management, but the same scoped token can call /i/two-factor-auth?method=disable, enable, or generate-qr-code: those routes use only validateUser, which applies the app permission intersection but does not reject the request, and disable needs no current TOTP code. A token narrowed to one app/feature can therefore weaken or replace its owner's second factor. Apply isScopedCredential to these account-level authentication routes (and audit other validateUser-only credential mutations), not just the token manager.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on all three methods — fixed in ad5a444.

disable is the sharp one: it takes no current TOTP code, so a token narrowed to one feature on one app could simply switch off the factor protecting everything its owner can reach. enable replaces the secret and generate-qr-code hands out a fresh one to enable with, so all three are credential management in the sense the token manager already uses.

All three now refuse a scoped credential through one helper:

function refuseScopedCredential(params, what) {
    if (isScopedCredential(params)) {
        common.returnMessage(params, 403, "A restricted token cannot " + what);
        return true;
    }
    return false;
}

You are right about the mechanism as well as the routes: validateUser bounds what a scoped token may touch per app, and an account-level route has no app to bound — so bounding is not the same as refusing, and only refusing helps here.

On the audit you asked for, the other validateUser-only routes in requestProcessor.js:

route verdict
/i/token/create already ceilinged — params.member is the creating credential's authority, so a scoped token cannot mint a wider child
/o/token/check reports on a token supplied as a parameter; the caller must already hold it
/o/countly_version, getCurrentUserApps read-only, no credential state
/i/users/update, admin_check, admin_disable global admin, not validateUser

So the 2FA trio was the whole set. The 24.05 branch has no generate-qr-code method, so it gets the two it has.

Integration tests cover all three refusing a scoped token, and an unrestricted api_key still getting through — a guard that also blocks the legitimate path would be its own bug.


Separately, while adding those: on the platform PR both unit suites this branch adds sit in test/unit-tests/, which the platform's runner does not glob —

"test:unit": "… mocha … 'test/unit/*.unit.js' 'plugins/*/tests/**/*.unit.js' …"

— so neither api.utils.rights.tokenPermissions.js nor api.utils.authorizer.tokenFields.js has ever run in platform CI. Moved to test/unit/ with the .unit.js suffix; 25 passing once they actually run. (The server repo globs test/unit-tests/, so they were fine there.)

…its own token, and close the second factor to a scoped token

Two ways a scoped token still reached more than it was granted.

/o/actions. Its countly-token branch calls authorize.verify_return directly and never
reaches a rights validator, so applyTokenScope never ran for it: only the token's app
restriction was checked, and its permissions were not consulted at all. A token scoped to
another feature on the same app read heatmaps. The branch now asks for the token document
(return_data), keeps it on params.token_data, resolves the owner, bounds that member with
rights.applyTokenScope exactly as rights.js does, and requires a read right for the views
feature before it goes any further. Endpoint scoping stays disabled for permission-carrying
tokens as intended - the point of the review note was that something had to apply the
permissions instead, and now something does.

applyTokenScope is exported for it. The read right check honours the legacy user_of
membership validateRead still honours, and refuses a locked account, so an owner who could
read this through their api_key is not refused here.

/i/two-factor-auth. enable, disable and generate-qr-code go through validateUser, which
bounds what a scoped token may touch per app but does not reject the request - and an
account level route has no app to bound. disable asks for no current code. A token narrowed
to one feature on one app could therefore switch off, or replace, the factor protecting
everything its owner can reach. All three now refuse a scoped credential, the same rule the
token manager and /o/render already apply.

Audited the rest of the validateUser only routes for the same shape: /i/token/create is
already ceilinged by the creating credential, /o/token/check takes the token it reports on
as a parameter, /o/countly_version and getCurrentUserApps mutate nothing. The 24.05 branch
has no generate-qr-code method, so it gets the two it has.

Also: on the platform, both unit suites this PR adds live in test/unit-tests, which the
platform's test:unit glob ('test/unit/*.unit.js' and 'plugins/*/tests/**/*.unit.js') does
not match, so neither has ever run in CI there. Moved to test/unit with the .unit.js
suffix. 25 passing once they do.
The four two-factor-auth cases I added were the only new failures in test-api-core on
this branch. They allowed 403 or 404, on the assumption that a build without the plugin
answers 404. It does not: requestProcessor answers 400 "Invalid path", so the cases
failed everywhere the plugin is not enabled - which is the CI test build.

Widening the allowed set to include 400 would have been the wrong repair. A case that
accepts 400, 403 and 404 passes whether or not the guard exists, which is worse than no
case at all. They now probe once for the route and skip with a reason when it is absent,
and assert exactly 403 when it is there.

Skipping leaves the guard unproven in this build, so the coverage that does not depend on
the plugin being installed is added beside it: a unit suite that reads the plugin source
and requires refuseScopedCredential at each method that mutates the factor, before the
write rather than after it, and requires the global-admin methods to keep using
validateUserForGlobalAdmin. Removing the guard from one arm fails it, checked by doing
exactly that. On 24.05 the generate-qr-code case reports pending, because that branch has
no such method.

For the record on the rest of this branch's test-api-core run: the other three failures
("user permission when app is deleted", "correct admins and users", "should return one
user") are not from this change. The same three fail on #7930, which carries none of it,
and test-api-core passed in the same hour on #7923, #7935, #7941, #7955, #7970, #7894 and
#7871. That is the flaky trio, not a regression.
The changelog is generated from PR and commit titles later, so an entry written by hand
here is duplicated work at best. It is also the single worst file in this wave for
conflicts: every merge to the base appends a line, which re-conflicts every open branch
that also appends one. Eight of the sixteen conflicts across these security PRs today
were this file and nothing else, and two of them came back within the hour.

Only the lines this branch added are removed - the file is otherwise the base's, and the
change here was purely additive, so nothing else moves.
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