security(core): scope auth tokens with explicit CRUD permissions and server-assigned login - #7963
Open
ar2rsawseen wants to merge 10 commits into
Open
security(core): scope auth tokens with explicit CRUD permissions and server-assigned login#7963ar2rsawseen wants to merge 10 commits into
ar2rsawseen wants to merge 10 commits into
Conversation
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the token
app/endpointscoping 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/endpointfields are a data-scoping control:authorizer.verify_tokenmatchesendpointagainst the request path andappagainst the request'sapp_id, and only when one is present. They say nothing about what the resolved member may do, andrights.jsdid 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 frompurpose, a string the caller supplies at creation.The model
Two new fields on
auth_tokens, both optional, so existing tokens are untouched.token_permission- amember.permission-shaped grant ({_:{a,u}, c/r/u/d:{appId:{all, allowed}}}).rights.jsintersects 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.global_admin; that is the authority a scoped token was narrowed away from.can_login- server-assigned only.setLoggedInVariables, the renderer (/o/render, dashboards reports), the ban-warning mail, and OIDC (enterprise-plugins, linked below)./i/token/creategrants it only when the creating credential holds it and the child is not narrowed. It never readspurpose./login/token/:tokengates on it.purposereturns to being a description.Grants are bounded by the creating credential, not the owner. Because the intersection has already happened,
params.memberis the creating credential's authority, soisPermissionSubsetneeds 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. Anallgrant may only be passed on by a holder ofall, since it covers features that do not exist yet.Token creation, listing and deletion require a full-permission credential.
/o/token/listreturns whole documents and a document's_idis 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_permissionthey are neither intersected nor re-scoped, theendpointregex still applies to them, and/login/tokenstill 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/listand/i/token/delete- including on/o/token/listwhere 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_idis 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/tokenstill 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 legacyendpointvalue read-only.Tests
test/unit-tests/api.utils.rights.tokenPermissions.js- the permission algebra: subset boundaries,allpropagation, admin apps, the intersection,global_adminstripping, and permissions reduced after issue.test/unit-tests/api.utils.authorizer.tokenFields.js- the token record: field persistence,can_loginstrictness, 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.apichain gives 238 passing / 21 failing, with all 16 token cases and all 21 unit tests passing. The 21 failures areTesting event settingsand are pre-existing: pristineorigin/masterfails the same 21 on the same instance. Noteapi/parts/data/usage.jsrequiresoffline-geocoder, which is declared but not installed by a plainnpm installhere, andpluginManager.connectToAllDatabaseshardcodes 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
permissionparameter - 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
release.24.05: security(core): scope auth tokens with explicit CRUD permissions and server-assigned login (24.05) #7964Supersedes 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