From dea603b093d4939ec4750ccf2b15417490bc9026 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:43:15 +0300 Subject: [PATCH 1/2] security(core): scope auth tokens with explicit CRUD permissions and 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. This branch also takes hasAdminAccess from 5e6014496da, which is on later versions but was never backported here. It treated an app with no permission entry for the member as one they administer. The permission model depends on that being correct, since an intersected member expresses "no access" as the absence of an entry. 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 --- CHANGELOG.md | 4 + api/parts/mgmt/mail.js | 2 + api/utils/authorizer.js | 77 +++- api/utils/requestProcessor.js | 100 +++- api/utils/rights.js | 436 +++++++++++++++++- frontend/express/libs/members.js | 35 +- .../javascripts/countly.views.js | 169 +++++-- .../templates/token-manager-drawer.html | 78 ++-- .../templates/token-manager.html | 6 +- .../countly/countly.token.manager.js | 34 +- .../dashboard/dashboard.properties | 19 + plugins/dashboards/api/api.js | 2 + test/2.api/16.token.manager.js | 245 ++++++++++ .../api.utils.authorizer.tokenFields.js | 179 +++++++ .../api.utils.rights.tokenPermissions.js | 159 +++++++ 15 files changed, 1418 insertions(+), 127 deletions(-) create mode 100644 test/unit-tests/api.utils.authorizer.tokenFields.js create mode 100644 test/unit-tests/api.utils.rights.tokenPermissions.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7703fab7885..b95eee4a773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Fixes: Enterprise Fixes: - [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table +Security Fixes: +- [core] A user is no longer treated as an administrator of an app when no permission is defined for them on it (already fixed in later versions, backported here) +- [core] Auth tokens are now granted explicit create/read/update/delete permissions per app and feature, enforced on every request as the intersection of the token's permissions and its owner's. A token can only be granted permissions that the credential creating it already holds. Permission to sign in to the dashboard is a separate property of the token, assigned by the server, instead of being inferred from the token's purpose. Token creation, listing and deletion require a full-permission credential. Tokens created before this change keep working under the previous app and endpoint restrictions + ## Version 24.05.51 Fixes: diff --git a/api/parts/mgmt/mail.js b/api/parts/mgmt/mail.js index 8f262213183..7a16cf64acf 100644 --- a/api/parts/mgmt/mail.js +++ b/api/parts/mgmt/mail.js @@ -269,6 +269,8 @@ mail.sendTimeBanWarning = function(member, db) { multi: false, owner: member._id, app: "", + //the mailed link logs the member back in, so this token carries login permission + can_login: true, callback: function(err, token) { mail.lookup(function(err2, host) { localize.getProperties(member.lang, function(err3, properties) { diff --git a/api/utils/authorizer.js b/api/utils/authorizer.js index 252559e241f..bb5d6b8c184 100644 --- a/api/utils/authorizer.js +++ b/api/utils/authorizer.js @@ -19,7 +19,9 @@ const log = require('./log.js')('core:authorizer'); * @param {string} options.token - token to store, if not provided, will be generated * @param {string} options.owner - id of the user who created this token * @param {string} options.app - list of the apps for which token was created -* @param {string} options.endpoint - regexp of endpoint(any string - is used as substring,to mach exact ^{yourpath}$) +* @param {string} options.endpoint - regexp of endpoint(any string - is used as substring,to mach exact ^{yourpath}$). Deprecated as an authorization mechanism, kept for tokens created before the permission model. +* @param {object} [options.token_permission] - CRUD permissions granted to this token, in the same shape as member.permission ({_:{a,u}, c/r/u/d:{appId:{all, allowed}}}). When set, the token authorizes only the intersection of this object and its owner's own permissions. When omitted, the token inherits the owner's full permissions. +* @param {boolean} [options.can_login=false] - if true, the token may be redeemed for a dashboard session at /login/token. SERVER-ASSIGNED ONLY - see the note below. * @param {string} options.tryReuse - if true - tries to find not expired token with same parameters. If not founds cretes new token. If found - updates token expiration time to new one and returns token. * @param {bool} [options.temporary=false] - If logged in with temporary token. Doesn't kill other sessions on logout. * @param {function} options.callback - function called when saving was completed or errored, providing error object as first param and token string as second @@ -34,6 +36,13 @@ authorizer.save = function(options) { options.purpose = options.purpose || ""; options.temporary = options.temporary || false; //If logged in with temporary token. Doesn't kill other sessions on logout + // Login capability is a property of the token, never of its purpose string. It is set only + // where the server itself establishes or propagates a session (setLoggedInVariables, the + // renderer, the ban-warning mail, OIDC login), and by /i/token/create only when the creating + // credential already holds it. Defaulting to false here means any caller that does not ask + // for it explicitly produces a token that cannot be redeemed at /login/token. + options.can_login = options.can_login === true; + if (options.endpoint !== "" && !Array.isArray(options.endpoint)) { options.endpoint = [options.endpoint]; } @@ -53,8 +62,40 @@ authorizer.save = function(options) { } else if (member) { authorizer.clearExpiredTokens(options); + /** + * Build the token document to store. token_permission is only written when the + * token is actually scoped, so an unscoped token stays absent-means-unrestricted. + * @returns {object} document to insert into auth_tokens + */ + var buildTokenDoc = function() { + var doc = { + _id: options.token, + ttl: options.ttl, + ends: options.ttl + Math.round(Date.now() / 1000), + multi: options.multi, + owner: options.owner, + app: options.app, + endpoint: options.endpoint, + purpose: options.purpose, + temporary: options.temporary, + can_login: options.can_login + }; + if (options.token_permission) { + doc.token_permission = options.token_permission; + } + return doc; + }; if (options.tryReuse === true) { - var rules = {"multi": options.multi, "endpoint": options.endpoint, "app": options.app, "owner": options.owner, "purpose": options.purpose}; + var rules = {"multi": options.multi, "endpoint": options.endpoint, "app": options.app, "owner": options.owner, "purpose": options.purpose, "can_login": options.can_login}; + // Never reuse a token that grants something different from what is being asked + // for: an identical-looking token with a wider (or narrower) permission set is a + // different credential. + if (options.token_permission) { + rules.token_permission = options.token_permission; + } + else { + rules.token_permission = {$exists: false}; + } if (options.purpose === "LoggedInAuth") { //Login token, allow switching from expiring to not expiring(and other way around) //If there is changes to session expiration - this will allow to treat those tokens as same token. @@ -78,17 +119,7 @@ authorizer.save = function(options) { options.callback(err_token, token.value._id); } else { - options.db.collection("auth_tokens").insert({ - _id: options.token, - ttl: options.ttl, - ends: options.ttl + Math.round(Date.now() / 1000), - multi: options.multi, - owner: options.owner, - app: options.app, - endpoint: options.endpoint, - purpose: options.purpose, - temporary: options.temporary - }, function(err1) { + options.db.collection("auth_tokens").insert(buildTokenDoc(), function(err1) { if (typeof options.callback === "function") { options.callback(err1, options.token); } @@ -97,17 +128,7 @@ authorizer.save = function(options) { }); } else { - options.db.collection("auth_tokens").insert({ - _id: options.token, - ttl: options.ttl, - ends: options.ttl + Math.round(Date.now() / 1000), - multi: options.multi, - owner: options.owner, - app: options.app, - endpoint: options.endpoint, - purpose: options.purpose, - temporary: options.temporary - }, function(err1) { + options.db.collection("auth_tokens").insert(buildTokenDoc(), function(err1) { if (typeof options.callback === "function") { options.callback(err1, options.token); } @@ -249,7 +270,13 @@ var verify_token = function(options, return_owner, return_data) { if (Array.isArray(res.endpoint) && res.endpoint.length === 0) { res.endpoint = ""; } - if (res.endpoint && res.endpoint !== "") { + // The endpoint regex is the pre-permission-model scoping mechanism. It only ever + // constrained which paths a token could call, never what the resolved member was + // allowed to do, so it is not an authorization boundary. Tokens issued under the + // 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 !== "") { //keep backwards compability if (!Array.isArray(res.endpoint)) { res.endpoint = [res.endpoint]; diff --git a/api/utils/requestProcessor.js b/api/utils/requestProcessor.js index d76fe573e01..d6e209710e7 100644 --- a/api/utils/requestProcessor.js +++ b/api/utils/requestProcessor.js @@ -7,7 +7,7 @@ const Promise = require('bluebird'); const url = require('url'); const common = require('./common.js'); const countlyCommon = require('../lib/countly.common.js'); -const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getAdminApps, getUserAppsForFeaturePermission } = require('./rights.js'); +const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getAdminApps, getUserAppsForFeaturePermission, isPermissionSubset, getPermissionApps, isScopedCredential } = require('./rights.js'); const authorize = require('./authorizer.js'); const taskmanager = require('./taskmanager.js'); const plugins = require('../../plugins/pluginManager.js'); @@ -497,6 +497,8 @@ const processRequest = (params) => { owner: params.member._id, ttl: 300, purpose: "LoginAuthToken", + //the headless renderer authenticates by redeeming this at /login/token + can_login: true, callback: function(err2, token) { if (err2) { common.returnMessage(params, 400, 'Error creating token: ' + err2); @@ -2615,6 +2617,12 @@ const processRequest = (params) => { */ case 'delete': validateUser(() => { + //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"); + return; + } if (params.qstring.tokenid) { common.db.collection("auth_tokens").remove({ "_id": params.qstring.tokenid, @@ -2661,7 +2669,80 @@ const processRequest = (params) => { */ case 'create': validateUser(params, () => { - let ttl, multi, endpoint, purpose, apps; + let ttl, multi, endpoint, purpose, apps, tokenPermission; + + // The credential doing the creating, not its owner, is the ceiling for what + // may be granted. params.member is already that ceiling: rights.js bounds the + // member by the authenticating token's permissions before any handler runs, so + // for an api_key it is the full member, and for a scoped token it is exactly + // that token's authority. A token therefore cannot mint a child that reaches + // an app or feature it cannot reach itself, even though their owner can. + const creatorToken = params.token_data; + /** + * Whether a legacy app/endpoint scope value actually restricts anything. + * @param {string|Array} scope - the app or endpoint field of a token + * @returns {boolean} true if the token is restricted by it + */ + const isScopeRestricted = function(scope) { + return !(scope === undefined || scope === null || scope === "" || (Array.isArray(scope) && scope.length === 0)); + }; + // Tokens created before the permission model carry an app/endpoint regex scope + // instead of permissions. That scope is not carried into params.member, so + // there is nothing to bound such a token's grants by - refuse rather than + // issue a child that could be wider than its parent. + const creatorIsLegacyRestricted = !!creatorToken && !creatorToken.token_permission && (isScopeRestricted(creatorToken.app) || isScopeRestricted(creatorToken.endpoint)); + if (creatorIsLegacyRestricted) { + common.returnMessage(params, 403, "A restricted token cannot create tokens"); + return; + } + + if (params.qstring.permission && params.qstring.permission !== "") { + if (typeof params.qstring.permission === "string") { + try { + tokenPermission = JSON.parse(params.qstring.permission); + } + catch (ex) { + common.returnMessage(params, 400, "Invalid permission object"); + return; + } + } + else { + tokenPermission = params.qstring.permission; + } + if (typeof tokenPermission !== "object" || Array.isArray(tokenPermission)) { + common.returnMessage(params, 400, "Invalid permission object"); + return; + } + // The endpoint regex is not an authorization boundary and is ignored for + // permission-scoped tokens. Reject rather than store an inert restriction + // that would read as if it were enforced. + if (params.qstring.endpoint || params.qstring.endpointquery) { + common.returnMessage(params, 400, "Endpoint restrictions cannot be combined with permissions"); + return; + } + if (!isPermissionSubset(tokenPermission, params.member)) { + common.returnMessage(params, 403, "Token permissions must be a subset of the creating credential's permissions"); + return; + } + } + + // Login is a capability of the token, never of its purpose string, and it is + // only ever passed on by a credential that holds it, to a child that is not + // narrowed. That makes a scoped token unable to produce a session, which is + // what the purpose allowlist used to (and could not) guarantee. + const requestedLogin = params.qstring.can_login === true || params.qstring.can_login === "true" || params.qstring.can_login === "1"; + let canLogin = false; + if (requestedLogin) { + //a token created before this model that carries no restriction at all is a + //full-permission credential, and keeps the login authority it has today + const creatorHasLogin = !creatorToken || creatorToken.can_login === true || (typeof creatorToken.can_login === "undefined" && !creatorToken.token_permission); + if (!creatorHasLogin || tokenPermission) { + common.returnMessage(params, 403, "The creating credential cannot grant login permission"); + return; + } + canLogin = true; + } + if (params.qstring.ttl) { ttl = parseInt(params.qstring.ttl); } @@ -2676,6 +2757,11 @@ const processRequest = (params) => { if (params.qstring.apps) { apps = params.qstring.apps.split(','); } + //keep the app list in step with the granted permissions, so the token list + //shows what the token reaches and the app check stays a useful early filter + if (tokenPermission && !params.qstring.apps) { + apps = getPermissionApps(tokenPermission); + } if (params.qstring.endpointquery && params.qstring.endpointquery !== "") { try { @@ -2705,6 +2791,8 @@ const processRequest = (params) => { app: apps, endpoint: endpoint, purpose: purpose, + token_permission: tokenPermission, + can_login: canLogin, callback: (err, token) => { if (err) { common.returnMessage(params, 404, err); @@ -2794,6 +2882,14 @@ const processRequest = (params) => { */ case 'list': validateUser(params, function() { + // Every document here includes its _id, which is the token itself. Listing + // them from a scoped token would hand it the owner's other credentials - + // including the unrestricted session token - and let it act as any of them, + // which is the escalation the permission model exists to prevent. + if (isScopedCredential(params)) { + common.returnMessage(params, 403, "A restricted token cannot list tokens"); + return; + } common.db.collection("auth_tokens").find({"owner": params.member._id + ""}).toArray(function(err, res) { if (err) { common.returnMessage(params, 404, err.message); diff --git a/api/utils/rights.js b/api/utils/rights.js index 6cb3024ff6e..33b85679528 100644 --- a/api/utils/rights.js +++ b/api/utils/rights.js @@ -31,10 +31,14 @@ function validate_token_if_exists(params) { qstring: params.qstring, token: token, req_path: params.fullPath, + //the whole token document is needed, not just the owner, so the token's own + //permissions can bound what the resolved member is allowed to do + return_data: true, callback: function(valid) { - //false or owner.id + //false or the token document if (valid) { - resolve(valid); + params.token_data = valid; + resolve(valid.owner); } else { resolve('token-invalid'); @@ -48,6 +52,28 @@ function validate_token_if_exists(params) { } }); } + +/** +* Bound a member by the permissions of the token used to authenticate as them. +* +* Authenticating with a token resolves to the token's owner, and every validator then decides +* what to allow from that member. Without this step the member is loaded at full strength, so a +* token deliberately scoped to one app authorizes everything its owner can do - the escalation +* this model exists to prevent. Applied as soon as the member is loaded, so that the validators' +* own permission checks already see the bounded member. +* +* A token with no token_permission (an api_key request, a dashboard session token, or a token +* created before this model) is returned unchanged, so existing integrations are unaffected. +* @param {params} params - {@link params} object, carrying token_data when a token was used +* @param {object} member - member document loaded for the token owner +* @returns {object} the member, bounded by the token's permissions when the token is scoped +*/ +function applyTokenScope(params, member) { + if (params.token_data && params.token_data.token_permission) { + return exports.intersectPermission(member, params.token_data.token_permission); + } + return member; +} /** * Validate user for read access by api_key for provided app_id (both required parameters for the request). * User must exist, must not be locked, must pass plugin validation (if any) and have at least user access to the provided app (which also must exist). @@ -87,6 +113,9 @@ exports.validateUserForRead = function(params, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (typeof params.qstring.app_id === "undefined") { common.returnMessage(params, 401, 'No app_id provided'); reject('No app_id provided'); @@ -183,6 +212,9 @@ exports.validateUserForWrite = function(params, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (!(module.exports.hasAdminAccess(member, params.qstring.app_id))) { common.returnMessage(params, 401, 'User does not have right'); reject('User does not have right'); @@ -272,6 +304,9 @@ exports.validateGlobalAdmin = function(params, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (!member.global_admin) { common.returnMessage(params, 401, 'User does not have right'); reject('User does not have right'); @@ -343,6 +378,9 @@ exports.validateAppAdmin = function(params, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (!params.qstring.app_id) { common.returnMessage(params, 400, 'No app id provided'); return false; @@ -428,6 +466,9 @@ exports.validateUser = function(params, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (member && member.locked) { common.returnMessage(params, 401, 'User is locked'); reject('User is locked'); @@ -826,6 +867,9 @@ exports.validateRead = function(params, feature, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (!member.global_admin && typeof params.qstring.app_id === "undefined") { common.returnMessage(params, 401, 'No app_id provided'); reject('No app_id provided'); @@ -980,6 +1024,9 @@ function validateWrite(params, feature, accessType, callback, callbackParam) { return false; } + //bound the member by the token used to authenticate, before anything is authorized + member = applyTokenScope(params, member); + if (!member.global_admin && /*appIdExceptions.indexOf(feature) === -1 && */ typeof params.qstring.app_id === "undefined") { common.returnMessage(params, 401, 'No app_id provided'); reject('No app_id provided'); @@ -1130,15 +1177,26 @@ exports.hasAdminAccess = function(member, app_id, type) { return true; } - var isAdmin = true; + var isAdmin = false; // check users who has permission property if (hasPermissionObject) { var types = type ? [type] : ["c", "r", "u", "d"]; + // an app the member has no permission entry for is not an app they administer: assuming + // admin from the absence of a rule granted access to every app the member was never given + var passesAllRules = true; for (var i = 0; i < types.length; i++) { - if (member.permission[types[i]] && member.permission[types[i]][app_id] && !member.permission[types[i]][app_id].all) { - isAdmin = false; + if (member.permission[types[i]] && member.permission[types[i]][app_id]) { + if (!member.permission[types[i]][app_id].all) { + passesAllRules = false; + } + } + else { + passesAllRules = false; } } + if (passesAllRules) { + isAdmin = true; + } } // check legacy users who has admin_of property // users should have at least one app in admin_of array @@ -1176,6 +1234,374 @@ exports.hasDeleteRight = function(feature, app_id, member) { return hasAppSpecificRight || hasGlobalAdminRight || hasAppAdminRight; }; +/** +* Access types in a permission object, in their canonical order. +*/ +var PERMISSION_TYPES = ["c", "r", "u", "d"]; + +/** +* Whether a principal grants a single feature on an app for one access type. +* +* A principal is anything holding authority: a member (with global_admin / permission, or the +* legacy admin_of / user_of arrays), or a bare token permission set wrapped as {permission: obj}. +* This is the same rule the hasCreateRight / hasReadRight / hasUpdateRight / hasDeleteRight +* helpers apply to a member, expressed once so it can also be applied to a token. +* @param {object} principal - object with permission (and optionally global_admin) +* @param {string} type - access type (c, r, u, d) +* @param {string} appId - id of the app +* @param {string} feature - feature name +* @returns {boolean} true if the principal allows that feature +*/ +function principalAllows(principal, type, appId, feature) { + if (!principal) { + return false; + } + if (principal.global_admin) { + return true; + } + var permission = principal.permission; + if (typeof permission === "undefined") { + //legacy member: admin_of grants everything on the app, user_of grants reads + if (Array.isArray(principal.admin_of) && principal.admin_of.indexOf(appId) !== -1) { + return true; + } + return type === "r" && Array.isArray(principal.user_of) && principal.user_of.indexOf(appId) !== -1; + } + if (permission._ && Array.isArray(permission._.a) && permission._.a.indexOf(appId) !== -1) { + return true; + } + var forType = permission[type]; + if (!forType || !forType[appId]) { + return false; + } + if (forType[appId].all === true) { + return true; + } + return !!(forType[appId].allowed && forType[appId].allowed[feature] === true); +} + +/** +* Whether a principal grants every feature on an app for one access type. +* +* Distinct from principalAllows because "all" also covers features that do not exist yet, so it +* may only be granted by a principal that itself holds "all". +* @param {object} principal - object with permission (and optionally global_admin) +* @param {string} type - access type (c, r, u, d) +* @param {string} appId - id of the app +* @returns {boolean} true if the principal allows everything for that app and type +*/ +function principalAllowsAll(principal, type, appId) { + if (!principal) { + return false; + } + if (principal.global_admin) { + return true; + } + var permission = principal.permission; + if (typeof permission === "undefined") { + if (Array.isArray(principal.admin_of) && principal.admin_of.indexOf(appId) !== -1) { + return true; + } + return type === "r" && Array.isArray(principal.user_of) && principal.user_of.indexOf(appId) !== -1; + } + if (permission._ && Array.isArray(permission._.a) && permission._.a.indexOf(appId) !== -1) { + return true; + } + var forType = permission[type]; + return !!(forType && forType[appId] && forType[appId].all === true); +} + +/** +* Every app id a principal refers to, whether through the _ grouping or a c/r/u/d entry. +* @param {object} principal - object with permission (and optionally the legacy arrays) +* @returns {string[]} list of app ids +*/ +function principalApps(principal) { + var apps = []; + /** + * Add an app id once. + * @param {string} appId - id of the app + * @returns {void} + */ + var push = function(appId) { + if (apps.indexOf(appId) === -1) { + apps.push(appId); + } + }; + if (!principal) { + return apps; + } + var permission = principal.permission; + if (typeof permission === "undefined") { + (principal.admin_of || []).forEach(push); + (principal.user_of || []).forEach(push); + return apps; + } + if (permission._) { + if (Array.isArray(permission._.a)) { + permission._.a.forEach(push); + } + if (Array.isArray(permission._.u)) { + for (var g = 0; g < permission._.u.length; g++) { + (permission._.u[g] || []).forEach(push); + } + } + } + for (var t = 0; t < PERMISSION_TYPES.length; t++) { + var forType = permission[PERMISSION_TYPES[t]]; + for (var appId in forType || {}) { + push(appId); + } + } + return apps; +} + +/** +* App ids a permission object actually grants something on. +* +* Distinct from principalApps: the permission editor emits an entry for every app the editing user +* can see, most of them granting nothing, and an empty entry is not a grant. Apps listed under _.u +* do count, because some validators authorize on app membership alone. +* @param {object} permission - permission object +* @returns {string[]} list of app ids the permission grants something on +*/ +function grantingApps(permission) { + var apps = []; + /** + * Add an app id once. + * @param {string} appId - id of the app + * @returns {void} + */ + var push = function(appId) { + if (apps.indexOf(appId) === -1) { + apps.push(appId); + } + }; + if (!permission) { + return apps; + } + if (permission._) { + if (Array.isArray(permission._.a)) { + permission._.a.forEach(push); + } + if (Array.isArray(permission._.u)) { + for (var g = 0; g < permission._.u.length; g++) { + (permission._.u[g] || []).forEach(push); + } + } + } + for (var t = 0; t < PERMISSION_TYPES.length; t++) { + var forType = permission[PERMISSION_TYPES[t]] || {}; + for (var appId in forType) { + var entry = forType[appId]; + if (!entry) { + continue; + } + if (entry.all === true) { + push(appId); + continue; + } + for (var feature in entry.allowed || {}) { + if (entry.allowed[feature] === true) { + push(appId); + break; + } + } + } + } + return apps; +} + +/** +* Feature names a principal explicitly allows for an app and access type. +* @param {object} principal - object with permission +* @param {string} type - access type (c, r, u, d) +* @param {string} appId - id of the app +* @returns {string[]} list of feature names +*/ +function principalFeatures(principal, type, appId) { + var features = []; + var permission = principal && principal.permission; + var entry = permission && permission[type] && permission[type][appId]; + if (entry && entry.allowed) { + for (var feature in entry.allowed) { + if (entry.allowed[feature] === true) { + features.push(feature); + } + } + } + return features; +} + +/** +* Whether one permission set grants nothing beyond what a ceiling principal already holds. +* +* This is what bounds a grant to the credential that creates it: a token may only pass on +* authority it holds itself. The ceiling is the member for an api_key or an unrestricted session, +* and the parent token's own permissions when a token creates a token - so a token scoped to app A +* cannot produce a child that reaches app B, even though their common owner can. +* @param {object} childPermission - permission object being granted +* @param {object} ceiling - principal that must already hold everything the child grants +* @returns {boolean} true if childPermission is a subset of the ceiling's authority +*/ +exports.isPermissionSubset = function(childPermission, ceiling) { + //a malformed permission grants nothing recognisable, and an empty-looking value must not be + //mistaken for "grants nothing, therefore a subset" + if (!childPermission || typeof childPermission !== "object" || Array.isArray(childPermission)) { + return false; + } + var t, appId, i; + //an app the child administers implies every feature of every type, present and future + var childAdminApps = (childPermission._ && Array.isArray(childPermission._.a)) ? childPermission._.a : []; + for (i = 0; i < childAdminApps.length; i++) { + for (t = 0; t < PERMISSION_TYPES.length; t++) { + if (!principalAllowsAll(ceiling, PERMISSION_TYPES[t], childAdminApps[i])) { + return false; + } + } + } + //an app the child can see at all must be an app the ceiling can see, since some validators + //authorize on app membership alone + var ceilingApps = principalApps(ceiling); + var childApps = grantingApps(childPermission); + for (i = 0; i < childApps.length; i++) { + if (!ceiling.global_admin && ceilingApps.indexOf(childApps[i]) === -1) { + return false; + } + } + for (t = 0; t < PERMISSION_TYPES.length; t++) { + var forType = childPermission[PERMISSION_TYPES[t]]; + for (appId in forType || {}) { + var entry = forType[appId]; + if (!entry) { + continue; + } + if (entry.all === true && !principalAllowsAll(ceiling, PERMISSION_TYPES[t], appId)) { + return false; + } + for (var feature in entry.allowed || {}) { + if (entry.allowed[feature] === true && !principalAllows(ceiling, PERMISSION_TYPES[t], appId, feature)) { + return false; + } + } + } + } + return true; +}; + +/** +* Whether the credential that authenticated this request is narrower than its owner. +* +* True for a token carrying token_permission, and for a token restricted by the legacy +* app/endpoint scope. False for an api_key (the member itself) and for an unrestricted token such +* as a dashboard session token. Used to keep credential management - which hands out and revokes +* the owner's other credentials - to credentials that are not themselves narrowed. +* @param {params} params - {@link params} object +* @returns {boolean} true if a scoped credential authenticated the request +*/ +exports.isScopedCredential = function(params) { + var token = params && params.token_data; + if (!token) { + return false; + } + if (token.token_permission) { + return true; + } + /** + * Whether a legacy scope value restricts anything. + * @param {string|Array} scope - the app or endpoint field of a token + * @returns {boolean} true if restricted + */ + var isScopeRestricted = function(scope) { + return !(scope === undefined || scope === null || scope === "" || (Array.isArray(scope) && scope.length === 0)); + }; + return isScopeRestricted(token.app) || isScopeRestricted(token.endpoint); +}; + +/** +* Every app id referenced by a permission object. +* @param {object} permission - permission object ({_:{a,u}, c/r/u/d:{appId:...}}) +* @returns {string[]} list of app ids +*/ +exports.getPermissionApps = function(permission) { + return principalApps({permission: permission}); +}; + +/** +* Bound a member by a token's permissions, returning a member that holds only what both allow. +* +* The subset check at creation time bounds a token to its creator, but the owner's own +* permissions can be reduced afterwards, so the intersection is recomputed on every request. +* The returned member never carries global_admin: that is precisely the authority a scoped token +* was narrowed away from, and leaving it set would let every global_admin check bypass the scope. +* @param {object} member - member document for the token owner +* @param {object} tokenPermission - permission object stored on the token +* @returns {object} a copy of the member holding only the intersection +*/ +exports.intersectPermission = function(member, tokenPermission) { + var scoped = Object.assign({}, member); + scoped.global_admin = false; + //the legacy arrays are an alternative expression of authority, so they cannot be carried over + delete scoped.admin_of; + delete scoped.user_of; + + var result = {_: {a: [], u: [[]]}, c: {}, r: {}, u: {}, d: {}}; + var tokenPrincipal = {permission: tokenPermission}; + var userApps = []; + var memberApps = principalApps(member); + var tokenAdminApps = (tokenPermission._ && Array.isArray(tokenPermission._.a)) ? tokenPermission._.a : []; + var tokenUserApps = []; + if (tokenPermission._ && Array.isArray(tokenPermission._.u)) { + for (var g = 0; g < tokenPermission._.u.length; g++) { + tokenUserApps = tokenUserApps.concat(tokenPermission._.u[g] || []); + } + } + + grantingApps(tokenPermission).forEach(function(appId) { + //an app the owner can no longer reach grants the token nothing, whatever the token says + if (!member.global_admin && memberApps.indexOf(appId) === -1) { + return; + } + var grantsAnything = false; + for (var t = 0; t < PERMISSION_TYPES.length; t++) { + var type = PERMISSION_TYPES[t]; + var tokenAll = principalAllowsAll(tokenPrincipal, type, appId); + if (tokenAll && principalAllowsAll(member, type, appId)) { + result[type][appId] = {all: true, allowed: {}}; + grantsAnything = true; + continue; + } + //whichever side is not "all" has a finite feature list, and that is what to walk + var candidates = tokenAll ? principalFeatures(member, type, appId) : principalFeatures(tokenPrincipal, type, appId); + var allowed = {}; + var any = false; + candidates.forEach(function(feature) { + if (principalAllows(tokenPrincipal, type, appId, feature) && principalAllows(member, type, appId, feature)) { + allowed[feature] = true; + any = true; + } + }); + if (any) { + result[type][appId] = {all: false, allowed: allowed}; + grantsAnything = true; + } + } + var isAdmin = tokenAdminApps.indexOf(appId) !== -1 && exports.hasAdminAccess(member, appId); + if (isAdmin) { + result._.a.push(appId); + } + else if (grantsAnything || tokenUserApps.indexOf(appId) !== -1) { + //app membership alone is what some validators check, so an app the token grants + //anything on - or names as a user app - stays visible + userApps.push(appId); + } + }); + + result._.u = [userApps]; + scoped.permission = result; + return scoped; +}; + exports.getUserApps = function(member) { let userApps = []; if (member.global_admin) { diff --git a/frontend/express/libs/members.js b/frontend/express/libs/members.js index dc013d55a95..0f476fb1642 100755 --- a/frontend/express/libs/members.js +++ b/frontend/express/libs/members.js @@ -225,6 +225,9 @@ function setLoggedInVariables(req, member, countlyDb, callback) { tryReuse: reuse, ttl: getSessionTimeoutInMs(req) / 1000, purpose: "LoggedInAuth", + //the server is establishing an interactive session here, which is the one place login + //permission originates; the token is unscoped, so it stays as capable as the session it is + can_login: true, callback: function(err2, token) { if (err2) { console.log(err2); @@ -487,17 +490,27 @@ membersUtility.loginWithToken = function(req, callback) { return callback(undefined); } - // Only allow tokens whose purpose is explicitly "log this member in". - // - "LoggedInAuth" — legitimate session tokens (set by setLoggedInVariables; - // mail.sendTimeBanWarning). - // - "LoginAuthToken" — short-lived (TTL=300, multi:false) tokens used by the - // server-side Puppeteer renderer to authenticate the - // headless Chrome session at /login/token/:token. - // Any other purpose — including arbitrary purposes settable via /i/token/create - // by a global admin — MUST NOT be redeemable here. - var allowedLoginPurposes = ["LoggedInAuth", "LoginAuthToken"]; - if (allowedLoginPurposes.indexOf(valid.purpose) === -1) { - plugins.callMethod("tokenLoginFailed", {req: req, data: {token: token, token_owner: valid.owner, reason: "wrong_purpose"}}); + // Redeeming a token here grants the owner's full dashboard session, so it is allowed + // only for a token that explicitly carries login permission. can_login is set by the + // server where a session is established or propagated (setLoggedInVariables, the + // renderer, the ban-warning mail, OIDC), and by /i/token/create only when the creating + // credential already holds it and the child is not narrowed. Purpose is a description, + // not a capability: it used to be the gate here while being freely settable at create + // time, which is what let a scoped token forge its way into a full session. + var canLogin = valid.can_login === true; + if (!canLogin && typeof valid.can_login === "undefined") { + // Tokens minted before this model have no can_login field. Honour the ones that + // were legitimately redeemable then - a login purpose and no restriction at all - + // so live sessions and existing login-token integrations keep working. Scoped + // legacy tokens stay locked out. + var isLoginScopeRestricted = function(scope) { + return !(scope === undefined || scope === null || scope === "" || (Array.isArray(scope) && scope.length === 0)); + }; + var legacyLoginPurposes = ["LoggedInAuth", "LoginAuthToken"]; + canLogin = legacyLoginPurposes.indexOf(valid.purpose) !== -1 && !isLoginScopeRestricted(valid.app) && !isLoginScopeRestricted(valid.endpoint) && !valid.token_permission; + } + if (!canLogin) { + plugins.callMethod("tokenLoginFailed", {req: req, data: {token: token, token_owner: valid.owner, reason: "no_login_permission"}}); return callback(undefined); } diff --git a/frontend/express/public/core/token-manager/javascripts/countly.views.js b/frontend/express/public/core/token-manager/javascripts/countly.views.js index b6be2a2e366..6a7327bdefe 100644 --- a/frontend/express/public/core/token-manager/javascripts/countly.views.js +++ b/frontend/express/public/core/token-manager/javascripts/countly.views.js @@ -1,5 +1,7 @@ -/*global app, countlyVue, countlyCommon, CV, $, countlyGlobal, countlyTokenManager, CountlyHelpers */ +/*global app, countlyVue, countlyCommon, CV, $, countlyGlobal, countlyTokenManager, countlyUserManagement, countlyAuth, CountlyHelpers */ (function() { + var PERMISSION_TYPES = ["c", "r", "u", "d"]; + var TokenDrawer = countlyVue.views.create({ template: CV.T('/core/token-manager/templates/token-manager-drawer.html'), data: function() { @@ -7,6 +9,11 @@ tokenUsage: '0', tokenExpiration: '0', title: '', + features: [], + filteredFeatures: [], + searchQuery: '', + allByType: {c: false, r: false, u: false, d: false}, + permissionSet: countlyAuth.permissionSetGenerator(1)[0], constants: { "availableProps": [ { label: CV.i18n('token_manager.limit.h'), value: "hours" }, @@ -17,6 +24,13 @@ } }; }, + mounted: function() { + var self = this; + $.when(countlyUserManagement.fetchFeatures()).then(function() { + self.features = countlyUserManagement.getFeatures() || []; + self.filteredFeatures = self.features; + }); + }, methods: { appsData: function() { var apps = []; @@ -25,51 +39,81 @@ } return apps; }, - addEndpoint: function(endpoints) { - endpoints.push({parameters: [{}]}); + featureBeautifier: function(feature) { + return countlyAuth.featureBeautifier(feature); }, - addParameter: function(parameters) { - parameters.push({}); + search: function() { + var self = this; + var query = (self.searchQuery || "").toLowerCase(); + if (query !== "") { + self.filteredFeatures = self.features.filter(function(feature) { + return self.featureBeautifier(feature).toLowerCase().indexOf(query) !== -1; + }); + } + else { + self.filteredFeatures = self.features; + } }, - removeEndpoint: function(endpoints, endpointIndex) { - if (endpoints.length > 1) { - endpoints.splice(endpointIndex, 1); + clearSearch: function() { + this.searchQuery = ''; + this.filteredFeatures = this.features; + }, + //granting anything on a feature implies being able to read it, mirroring how a user's + //own permissions are edited in user management + setPermissionByFeature: function(type, feature) { + if (type !== 'r' && this.permissionSet[type].allowed[feature] && !this.permissionSet.r.allowed[feature]) { + this.$set(this.permissionSet.r.allowed, feature, true); } + if (type === 'r' && !this.permissionSet.r.allowed[feature]) { + for (var i = 0; i < PERMISSION_TYPES.length; i++) { + this.$set(this.permissionSet[PERMISSION_TYPES[i]].allowed, feature, false); + } + } + this.syncAllFlags(); }, - removeParameter: function(parameters, parameterIndex) { - if (parameters.length > 1) { - parameters.splice(parameterIndex, 1); + setPermissionByType: function(type) { + var on = this.allByType[type]; + for (var i = 0; i < this.filteredFeatures.length; i++) { + var feature = this.filteredFeatures[i]; + this.$set(this.permissionSet[type].allowed, feature, on); + if (on && type !== 'r') { + this.$set(this.permissionSet.r.allowed, feature, true); + } + if (!on && type === 'r') { + for (var j = 0; j < PERMISSION_TYPES.length; j++) { + this.$set(this.permissionSet[PERMISSION_TYPES[j]].allowed, feature, false); + } + } } + this.syncAllFlags(); + }, + //"all" must mean every feature, including ones added later, so it is only set when the + //whole list is selected - the server refuses an "all" grant the creator does not hold + syncAllFlags: function() { + var self = this; + PERMISSION_TYPES.forEach(function(type) { + var every = self.features.length > 0 && self.features.every(function(feature) { + return self.permissionSet[type].allowed[feature] === true; + }); + self.permissionSet[type].all = every; + self.allByType[type] = every; + }); + }, + buildPermission: function(apps) { + var permission = {_: {a: [], u: [apps]}, c: {}, r: {}, u: {}, d: {}}; + return countlyAuth.combinePermissionObject([apps], [this.permissionSet], permission); }, onClose: function() { this.tokenUsage = '0'; this.tokenExpiration = '0'; + this.searchQuery = ''; + this.filteredFeatures = this.features; + this.permissionSet = countlyAuth.permissionSetGenerator(1)[0]; + this.allByType = {c: false, r: false, u: false, d: false}; }, onSubmit: function(doc) { var self = this; var ttl = 0; - var selectApps = doc.selectApps; - var endpoints = doc.endpoints; - var newEndpoints = []; - endpoints.forEach(function(element) { - if (element.endpointName !== "" && element.endpointName) { - var obj = {params: {}} ; - obj.endpoint = element.endpointName; - element.parameters.forEach(function(item) { - var key = item.queryParameters1; - var value = item.queryParameters2; - obj.params[key] = value; - }); - newEndpoints.push(obj); - } - }); - endpoints = JSON.stringify(newEndpoints); - - if (self.tokenUsage === "1") { - if (doc.selectApps.length > 0) { - selectApps = doc.selectApps.join(","); - } - } if (self.tokenExpiration === "1") { if (doc.selectTime === "hours") { ttl = doc.timeInput * 3600; @@ -81,7 +125,26 @@ ttl = doc.timeInput * 3600 * 24 * 30; } } - countlyTokenManager.createTokenWithQuery(doc.description, endpoints, doc.checkboxMultipleTimes, selectApps, ttl, function() { + + var options = { + purpose: doc.description, + multi: doc.checkboxMultipleTimes, + ttl: ttl + }; + if (self.tokenUsage === "1") { + options.permission = self.buildPermission(doc.selectApps || []); + } + else { + //an unlimited token carries the creator's own permissions, and only such a + //token may be granted permission to sign in + options.canLogin = doc.checkboxCanLogin === true; + } + + countlyTokenManager.createTokenWithPermissions(options, function(err) { + if (err) { + CountlyHelpers.alert(CV.i18n('token_manager.create-error'), "red"); + return; + } self.$emit("create"); }); } @@ -155,6 +218,8 @@ row.purpose = row.purpose + ""; row.purpose = row.purpose[0].toUpperCase() + row.purpose.substring(1); } + row.canLogin = row.can_login === true; + row.permissionSummary = this.describePermission(row); if (Array.isArray(row.endpoint)) { var lines = []; for (var p = 0; p < row.endpoint.length; p++) { @@ -186,6 +251,40 @@ } this.tableData = tableData; }, + //describe what a token may do, so the list distinguishes a limited token from one that + //carries the owner's own permissions + describePermission: function(row) { + if (!row.token_permission) { + return CV.i18n('token_manager.permission.full'); + } + var counts = {c: 0, r: 0, u: 0, d: 0}; + var labels = []; + PERMISSION_TYPES.forEach(function(type) { + var forType = row.token_permission[type] || {}; + for (var appId in forType) { + var entry = forType[appId]; + if (!entry) { + continue; + } + var grants = entry.all === true; + for (var feature in entry.allowed || {}) { + if (entry.allowed[feature] === true) { + grants = true; + break; + } + } + if (grants) { + counts[type]++; + } + } + }); + PERMISSION_TYPES.forEach(function(type) { + if (counts[type] > 0) { + labels.push(CV.i18n('token_manager.permission.' + type)); + } + }); + return labels.length ? labels.join(", ") : CV.i18n('token_manager.permission.none'); + }, getColor: function(status) { if (status === "active") { return "green"; @@ -196,7 +295,7 @@ }, onCreateClick: function() { this.openDrawer("main", { - description: "", checkboxMultipleTimes: false, endpoints: [{parameters: [{}]}], selectApps: [] + description: "", checkboxMultipleTimes: false, checkboxCanLogin: false, selectApps: [] }); }, onDelete: function(row) { diff --git a/frontend/express/public/core/token-manager/templates/token-manager-drawer.html b/frontend/express/public/core/token-manager/templates/token-manager-drawer.html index 0c1e1b3aba0..dffe9c3e385 100644 --- a/frontend/express/public/core/token-manager/templates/token-manager-drawer.html +++ b/frontend/express/public/core/token-manager/templates/token-manager-drawer.html @@ -10,53 +10,28 @@ - -
-

{{i18n('token_manager.table.endpoints')}}

-

{{i18n('common.optional')}}

-
-

{{i18n('token_manager.table.endpoints-description')}}

-
- -
-

{{i18n('token_manager.table.endpoint-detail')}}

- - Remove - -
- - - - -
- - - - - -
- + {{i18n('token_manager.add-param')}} -
-
- - - + {{i18n('token_manager.add-new-endpoint')}} - -
-

{{i18n('token_manager.table.apps-title')}}

+

{{i18n('token_manager.permission.title')}}

+

{{i18n('token_manager.permission.description')}}

- - {{i18n('token_manager.table.apps-allow')}} + + {{i18n('token_manager.permission.full-label')}} +

{{i18n('token_manager.permission.full-text')}}

- - {{i18n('token_manager.table.apps-limit')}} + + {{i18n('token_manager.permission.limited-label')}} +

{{i18n('token_manager.permission.limited-text')}}

+ + + {{i18n('token_manager.permission.can-login')}} + +

{{i18n('token_manager.permission.can-login-text')}}

+

{{i18n('token_manager.select-apps')}}

@@ -66,6 +41,31 @@ + +
+

{{i18n('token_manager.permission.features')}}

+
+

{{i18n('token_manager.permission.features-description')}}

+ + + + +
+ {{i18n('management-users.create').toUpperCase()}} + {{i18n('management-users.read').toUpperCase()}} + {{i18n('management-users.update').toUpperCase()}} + {{i18n('management-users.delete').toUpperCase()}} +
+
+
{{featureBeautifier(feature)}}
+
+ + + + +
+
+

{{i18n('token_manager.table.limit-title')}}

diff --git a/frontend/express/public/core/token-manager/templates/token-manager.html b/frontend/express/public/core/token-manager/templates/token-manager.html index d93a97adc2c..7c062f1c6b1 100644 --- a/frontend/express/public/core/token-manager/templates/token-manager.html +++ b/frontend/express/public/core/token-manager/templates/token-manager.html @@ -38,9 +38,11 @@ {{scope.row.app}} - + diff --git a/frontend/express/public/javascripts/countly/countly.token.manager.js b/frontend/express/public/javascripts/countly/countly.token.manager.js index ec2c44f79a6..bf5cb848ead 100644 --- a/frontend/express/public/javascripts/countly/countly.token.manager.js +++ b/frontend/express/public/javascripts/countly/countly.token.manager.js @@ -44,17 +44,35 @@ }); }; - countlyTokenManager.createTokenWithQuery = function(purpose, endpoint, multi, apps, ttl, callback) { + /** + * Create a token, optionally limited to a set of CRUD permissions. + * @param {object} options - token options + * @param {string} options.purpose - description of the token + * @param {boolean} options.multi - can the token be used more than once + * @param {number} options.ttl - seconds until the token expires, 0 never expires + * @param {object=} options.permission - permission object limiting the token, omitted for a + * token that carries the creator's own permissions + * @param {boolean=} options.canLogin - request permission to sign in to the dashboard. Granted + * only when the creating credential holds it and the token is not limited + * @param {function} callback - called with (error, response) + * @returns {object} jQuery ajax object + */ + countlyTokenManager.createTokenWithPermissions = function(options, callback) { + var data = { + "purpose": options.purpose, + "multi": options.multi, + "ttl": options.ttl + }; + if (options.permission) { + data.permission = JSON.stringify(options.permission); + } + if (options.canLogin) { + data.can_login = true; + } return $.ajax({ type: "GET", url: countlyCommon.API_URL + "/i/token/create", - data: { - "purpose": purpose, - "endpointquery": endpoint, - "multi": multi, - "apps": apps, - "ttl": ttl - }, + data: data, success: function(json) { //token created callback(null, json); diff --git a/frontend/express/public/localization/dashboard/dashboard.properties b/frontend/express/public/localization/dashboard/dashboard.properties index bb6aa52274c..2e8704c098c 100644 --- a/frontend/express/public/localization/dashboard/dashboard.properties +++ b/frontend/express/public/localization/dashboard/dashboard.properties @@ -1138,6 +1138,25 @@ token_manager.select-time-unit = Select time unit token_manager.token-expiration-time = Expiration Time token_manager.LoginAuthToken-description = This token is created when creating dashboard screenshots.
If you are not currently rendering dashboard images, you can delete this token. token_manager.LoggedInAuth-description = This token is used for keeping users session.
Deleting it will log out user currently using it to keep session. +token_manager.create-error = Creating token failed +token_manager.table.permission = Permissions +token_manager.permission.title = Permissions +token_manager.permission.description = Choose what this token is allowed to do. A token can never be given more access than the credential creating it has. +token_manager.permission.full-label = Same access as my account +token_manager.permission.full-text = The token can do anything you can do, and its access shrinks if yours does. +token_manager.permission.limited-label = Limited access +token_manager.permission.limited-text = The token is restricted to the apps and permissions selected below. +token_manager.permission.features = Feature permissions +token_manager.permission.features-description = Select what the token may create, read, update and delete on the selected apps. +token_manager.permission.can-login = Allow this token to sign in to the dashboard +token_manager.permission.can-login-text = Available only for a token with the same access as your account, and only if the credential you are signed in with may sign in itself. +token_manager.permission.can-login-tag = Can sign in +token_manager.permission.full = Same access as owner +token_manager.permission.none = No access +token_manager.permission.c = Create +token_manager.permission.r = Read +token_manager.permission.u = Update +token_manager.permission.d = Delete version_history.page-title = Countly version history diff --git a/plugins/dashboards/api/api.js b/plugins/dashboards/api/api.js index d4a1fcd8a16..eb5d9a03d7b 100644 --- a/plugins/dashboards/api/api.js +++ b/plugins/dashboards/api/api.js @@ -1487,6 +1487,8 @@ plugins.setConfigs("dashboards", { purpose: "LoginAuthToken", temporary: true, ttl: 540, //9 minutes + //the headless renderer authenticates by redeeming this at /login/token + can_login: true, callback: function(er, token) { if (er) { return resolve(); diff --git a/test/2.api/16.token.manager.js b/test/2.api/16.token.manager.js index eceac22a375..29106c75aab 100644 --- a/test/2.api/16.token.manager.js +++ b/test/2.api/16.token.manager.js @@ -379,4 +379,249 @@ describe('Testing token manager', function() { }); }); }); + + describe('Token permissions bound every grant to the creating credential', function() { + var limitedToken = ""; + var fullToken = ""; + var legacyRestrictedToken = ""; + + //a token allowed only to read the "core" feature of APP_ID, and nothing else + var readCorePermission = function(appId) { + var permission = {_: {a: [], u: [[appId]]}, c: {}, r: {}, u: {}, d: {}}; + permission.r[appId] = {all: false, allowed: {core: true}}; + return encodeURIComponent(JSON.stringify(permission)); + }; + + //a token that additionally claims delete on everything, which its creator must not pass on + var deleteAllPermission = function(appId) { + var permission = {_: {a: [], u: [[appId]]}, c: {}, r: {}, u: {}, d: {}}; + permission.r[appId] = {all: false, allowed: {core: true}}; + permission.d[appId] = {all: true, allowed: {}}; + return encodeURIComponent(JSON.stringify(permission)); + }; + + var deleteToken = function(id, done) { + if (!id) { + return done(); + } + request + .get('/i/token/delete?api_key=' + API_KEY_ADMIN + '&tokenid=' + id) + .end(function() { + done(); + }); + }; + + it('setup: api_key creates a token limited to reading core on one app', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&purpose=integration&multi=true&ttl=3600&permission=' + readCorePermission(APP_ID)) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var ob = JSON.parse(res.text); + ob.should.have.property('result'); + limitedToken = ob.result; + (limitedToken !== "").should.equal(true); + done(); + }); + }); + + it('the limited token can read the feature it was granted', function(done) { + request + .get('/o/users/permissions?app_id=' + APP_ID + '&auth_token=' + limitedToken) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('the limited token cannot act as a global admin, even though its owner is one', function(done) { + request + .get('/o/users/all?auth_token=' + limitedToken) + .expect(401) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('the limited token cannot create an unrestricted child', function(done) { + request + .get('/i/token/create?auth_token=' + limitedToken + '&multi=true&ttl=300') + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + //an unrestricted child would carry the owner's full permissions + JSON.parse(res.text).should.have.property('result'); + done(); + }); + }); + + it('the limited token cannot grant a permission it does not hold', function(done) { + request + .get('/i/token/create?auth_token=' + limitedToken + '&multi=true&ttl=300&permission=' + deleteAllPermission(APP_ID)) + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + JSON.parse(res.text).should.have.property('result', "Token permissions must be a subset of the creating credential's permissions"); + done(); + }); + }); + + it('the limited token can create a child within its own permissions', function(done) { + request + .get('/i/token/create?auth_token=' + limitedToken + '&multi=true&ttl=300&permission=' + readCorePermission(APP_ID)) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + deleteToken(JSON.parse(res.text).result, done); + }); + }); + + it('the limited token cannot be granted login permission', function(done) { + request + .get('/i/token/create?auth_token=' + limitedToken + '&multi=true&ttl=300&can_login=true') + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + JSON.parse(res.text).should.have.property('result', 'The creating credential cannot grant login permission'); + done(); + }); + }); + + it('the limited token cannot list the owner tokens, which would hand it their secrets', function(done) { + request + .get('/o/token/list?auth_token=' + limitedToken) + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + JSON.parse(res.text).should.have.property('result', 'A restricted token cannot list tokens'); + done(); + }); + }); + + it('the limited token cannot delete the owner tokens', function(done) { + request + .get('/i/token/delete?auth_token=' + limitedToken + '&tokenid=' + limitedToken) + .expect(403) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('a purpose string alone never grants login permission', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&purpose=LoggedInAuth&multi=true&ttl=300') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var forged = JSON.parse(res.text).result; + //the token exists, but carries no login permission, so it cannot open a session + request + .get('/login/token/' + forged) + .expect(302) + .end(function(err2, res2) { + if (err2) { + return done(err2); + } + //rejected logins are redirected back to the login page + res2.headers.location.should.not.containEql('/dashboard'); + deleteToken(forged, done); + }); + }); + }); + + it('api_key can still create a token, and grant it login permission', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&multi=true&ttl=300&can_login=true') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + fullToken = JSON.parse(res.text).result; + (fullToken !== "").should.equal(true); + done(); + }); + }); + + it('a full-permission token can create a child, as the dashboard session does', function(done) { + request + .get('/i/token/create?auth_token=' + fullToken + '&purpose=child&multi=true&ttl=300') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + deleteToken(JSON.parse(res.text).result, done); + }); + }); + + it('setup: a token restricted the legacy way, by app', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&purpose=legacy&multi=true&ttl=3600&apps=' + APP_ID) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + legacyRestrictedToken = JSON.parse(res.text).result; + done(); + }); + }); + + it('a legacy app-restricted token cannot create tokens', function(done) { + request + .get('/i/token/create?auth_token=' + legacyRestrictedToken + '&multi=true&ttl=300') + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + JSON.parse(res.text).should.have.property('result', 'A restricted token cannot create tokens'); + done(); + }); + }); + + it('an endpoint restriction cannot be combined with permissions', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&multi=true&ttl=300&endpoint=/o/users&permission=' + readCorePermission(APP_ID)) + .expect(400) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('cleanup: remove the tokens created here', function(done) { + deleteToken(limitedToken, function() { + deleteToken(fullToken, function() { + deleteToken(legacyRestrictedToken, done); + }); + }); + }); + }); }); \ No newline at end of file diff --git a/test/unit-tests/api.utils.authorizer.tokenFields.js b/test/unit-tests/api.utils.authorizer.tokenFields.js new file mode 100644 index 00000000000..3e86a0af786 --- /dev/null +++ b/test/unit-tests/api.utils.authorizer.tokenFields.js @@ -0,0 +1,179 @@ +require("should"); +var authorizer = require("../../api/utils/authorizer.js"); + +var OWNER = "60e42efa5c23ee7ec6259af0"; + +/** +* Minimal in-memory stand-in for the countly db handle, so the token record can be exercised +* without a running MongoDB. +* @param {object[]} tokens - documents the auth_tokens collection starts with +* @returns {object} db stub, with the stored documents on .stored +*/ +function dbStub(tokens) { + var stored = tokens || []; + return { + stored: stored, + ObjectID: function(id) { + return id; + }, + collection: function(name) { + return { + findOne: function(query, callback) { + if (name === "members") { + return callback(null, {_id: OWNER}); + } + var found = stored.filter(function(doc) { + return doc._id === query._id; + })[0]; + callback(null, found || null); + }, + insert: function(doc, callback) { + stored.push(doc); + callback(null, doc); + }, + remove: function() { }, + update: function() { }, + findAndModify: function(rules, sort, update, callback) { + callback(null, null); + } + }; + } + }; +} + +describe("authorizer token record", function() { + it("stores can_login and token_permission when they are asked for", function(done) { + var db = dbStub([]); + var permission = {_: {a: [], u: [["app1"]]}, c: {}, r: {app1: {all: false, allowed: {core: true}}}, u: {}, d: {}}; + authorizer.save({ + db: db, + owner: OWNER, + ttl: 300, + token_permission: permission, + can_login: true, + callback: function(err) { + (!err).should.equal(true); + db.stored.length.should.equal(1); + db.stored[0].can_login.should.equal(true); + db.stored[0].token_permission.should.eql(permission); + done(); + } + }); + }); + + it("defaults to no login permission and no permission scope", function(done) { + var db = dbStub([]); + authorizer.save({ + db: db, + owner: OWNER, + ttl: 300, + callback: function() { + //login permission is never acquired by omission + db.stored[0].can_login.should.equal(false); + db.stored[0].should.not.have.property("token_permission"); + done(); + } + }); + }); + + it("never treats a truthy non-true value as login permission", function(done) { + var db = dbStub([]); + authorizer.save({ + db: db, + owner: OWNER, + ttl: 300, + can_login: "yes", + callback: function() { + db.stored[0].can_login.should.equal(false); + done(); + } + }); + }); + + describe("verify_token", function() { + var future = Math.round(Date.now() / 1000) + 3600; + + it("still applies the endpoint regex to a legacy token", function(done) { + var db = dbStub([{ + _id: "legacy", + ttl: 300, + ends: future, + multi: true, + owner: OWNER, + app: "", + endpoint: ["^/o/users"], + purpose: "legacy" + }]); + authorizer.verify_return({ + db: db, + token: "legacy", + req_path: "/o/apps/all", + return_data: true, + callback: function(valid) { + //path outside the allowed regex is refused, as before + (valid === false).should.equal(true); + authorizer.verify_return({ + db: db, + token: "legacy", + req_path: "/o/users/all", + return_data: true, + callback: function(valid2) { + valid2.should.have.property("_id", "legacy"); + done(); + } + }); + } + }); + }); + + it("ignores the endpoint regex once a token carries permissions", function(done) { + //the regex was never an authorization boundary; permissions are enforced in rights.js + var db = dbStub([{ + _id: "scoped", + ttl: 300, + ends: future, + multi: true, + owner: OWNER, + app: "", + endpoint: ["^/o/users"], + purpose: "integration", + token_permission: {_: {a: [], u: [[]]}, c: {}, r: {}, u: {}, d: {}} + }]); + authorizer.verify_return({ + db: db, + token: "scoped", + req_path: "/o/apps/all", + return_data: true, + callback: function(valid) { + valid.should.have.property("_id", "scoped"); + valid.should.have.property("token_permission"); + done(); + } + }); + }); + + it("still refuses a request for an app the token is not scoped to", function(done) { + var db = dbStub([{ + _id: "appscoped", + ttl: 300, + ends: future, + multi: true, + owner: OWNER, + app: ["app1"], + endpoint: "", + purpose: "integration" + }]); + authorizer.verify_return({ + db: db, + token: "appscoped", + req_path: "/o", + qstring: {app_id: "app2"}, + return_data: true, + callback: function(valid) { + (valid === false).should.equal(true); + done(); + } + }); + }); + }); +}); diff --git a/test/unit-tests/api.utils.rights.tokenPermissions.js b/test/unit-tests/api.utils.rights.tokenPermissions.js new file mode 100644 index 00000000000..df3671067ba --- /dev/null +++ b/test/unit-tests/api.utils.rights.tokenPermissions.js @@ -0,0 +1,159 @@ +require("should"); +var rights = require("../../api/utils/rights.js"); + +var APP_A = "aaaaaaaaaaaaaaaaaaaaaaaa"; +var APP_B = "bbbbbbbbbbbbbbbbbbbbbbbb"; + +/** +* Build a permission object of the shape the permission editor and the token manager produce. +* @param {object} spec - {adminApps, userApps, grants:[{type, app, all, allowed}]} +* @returns {object} permission object +*/ +function permission(spec) { + var perm = {_: {a: spec.adminApps || [], u: [spec.userApps || []]}, c: {}, r: {}, u: {}, d: {}}; + (spec.grants || []).forEach(function(grant) { + perm[grant.type][grant.app] = grant.all ? {all: true, allowed: {}} : {all: false, allowed: grant.allowed}; + }); + return perm; +} + +var globalAdmin = {global_admin: true}; + +//can create+read core on A, read core and events on A, read core on B +var member = { + global_admin: false, + permission: permission({ + userApps: [APP_A, APP_B], + grants: [ + {type: "c", app: APP_A, allowed: {core: true}}, + {type: "r", app: APP_A, allowed: {core: true, events: true}}, + {type: "r", app: APP_B, allowed: {core: true}} + ] + }) +}; + +var adminOfA = {global_admin: false, permission: permission({adminApps: [APP_A]})}; + +var readCoreOnA = permission({userApps: [APP_A], grants: [{type: "r", app: APP_A, allowed: {core: true}}]}); + +describe("token permissions", function() { + describe("isPermissionSubset", function() { + it("allows a grant the ceiling holds", function() { + rights.isPermissionSubset(readCoreOnA, member).should.equal(true); + }); + + it("refuses a grant on an app the ceiling cannot reach", function() { + //the escalation this model exists to prevent: the owner has A and B, but a token + //scoped to A must not be able to produce a child that reaches B + var childOnB = permission({userApps: [APP_B], grants: [{type: "r", app: APP_B, allowed: {core: true}}]}); + rights.isPermissionSubset(childOnB, {permission: readCoreOnA}).should.equal(false); + rights.isPermissionSubset(childOnB, member).should.equal(true); + }); + + it("refuses a feature the ceiling does not hold", function() { + var update = permission({userApps: [APP_A], grants: [{type: "u", app: APP_A, allowed: {core: true}}]}); + rights.isPermissionSubset(update, member).should.equal(false); + }); + + it("refuses an all grant from a ceiling that holds only named features", function() { + //"all" covers features that do not exist yet, so only an "all" holder may pass it on + var readAll = permission({userApps: [APP_A], grants: [{type: "r", app: APP_A, all: true}]}); + rights.isPermissionSubset(readAll, member).should.equal(false); + rights.isPermissionSubset(readAll, adminOfA).should.equal(true); + rights.isPermissionSubset(readAll, globalAdmin).should.equal(true); + }); + + it("refuses app administration unless the ceiling administers that app", function() { + var adminChild = permission({adminApps: [APP_A]}); + rights.isPermissionSubset(adminChild, member).should.equal(false); + rights.isPermissionSubset(adminChild, adminOfA).should.equal(true); + }); + + it("ignores entries that grant nothing", function() { + //the permission editor emits an entry for every visible app, most of them empty + var editorShaped = permission({userApps: [APP_A], grants: [{type: "r", app: APP_A, allowed: {core: true}}]}); + editorShaped.d[APP_B] = {all: false, allowed: {}}; + editorShaped.c[APP_B] = {all: false, allowed: {core: false}}; + rights.isPermissionSubset(editorShaped, {permission: readCoreOnA}).should.equal(true); + }); + + it("refuses anything that is not a permission object", function() { + rights.isPermissionSubset(undefined, member).should.equal(false); + rights.isPermissionSubset([], member).should.equal(false); + }); + }); + + describe("intersectPermission", function() { + it("strips global admin, so a scoped token cannot act as one", function() { + var scoped = rights.intersectPermission(globalAdmin, readCoreOnA); + scoped.global_admin.should.equal(false); + rights.hasReadRight("core", APP_A, scoped).should.equal(true); + rights.hasReadRight("core", APP_B, scoped).should.equal(false); + }); + + it("keeps only what the token and the owner both allow", function() { + //the token asks for everything; the owner has only named features + var wide = permission({ + userApps: [APP_A], + grants: [ + {type: "r", app: APP_A, all: true}, + {type: "d", app: APP_A, all: true} + ] + }); + var scoped = rights.intersectPermission(member, wide); + rights.hasReadRight("core", APP_A, scoped).should.equal(true); + rights.hasReadRight("events", APP_A, scoped).should.equal(true); + rights.hasDeleteRight("core", APP_A, scoped).should.equal(false); + }); + + it("drops access the owner lost after the token was created", function() { + var reduced = { + global_admin: false, + permission: permission({ + userApps: [APP_A], + grants: [{type: "r", app: APP_A, allowed: {core: true}}] + }) + }; + var tokenOnBoth = permission({ + userApps: [APP_A, APP_B], + grants: [ + {type: "r", app: APP_A, allowed: {core: true}}, + {type: "r", app: APP_B, allowed: {core: true}} + ] + }); + var scoped = rights.intersectPermission(reduced, tokenOnBoth); + rights.hasReadRight("core", APP_A, scoped).should.equal(true); + rights.hasReadRight("core", APP_B, scoped).should.equal(false); + rights.getUserApps(scoped).indexOf(APP_B).should.equal(-1); + }); + + it("does not leave an app administrator administering it through a narrower token", function() { + var scoped = rights.intersectPermission(adminOfA, readCoreOnA); + rights.hasAdminAccess(scoped, APP_A).should.equal(false); + rights.hasReadRight("core", APP_A, scoped).should.equal(true); + rights.hasDeleteRight("core", APP_A, scoped).should.equal(false); + }); + + it("reports only the apps the token reaches", function() { + var scoped = rights.intersectPermission(member, readCoreOnA); + rights.getUserApps(scoped).should.eql([APP_A]); + }); + }); + + describe("isScopedCredential", function() { + it("treats an api_key request as unscoped", function() { + rights.isScopedCredential({}).should.equal(false); + }); + + it("treats a session style token as unscoped", function() { + rights.isScopedCredential({token_data: {app: "", endpoint: ""}}).should.equal(false); + rights.isScopedCredential({token_data: {app: [], endpoint: []}}).should.equal(false); + }); + + it("treats a permission scoped or legacy restricted token as scoped", function() { + rights.isScopedCredential({token_data: {token_permission: readCoreOnA}}).should.equal(true); + rights.isScopedCredential({token_data: {app: [APP_A], endpoint: ""}}).should.equal(true); + rights.isScopedCredential({token_data: {app: "", endpoint: ["^/o/users"]}}).should.equal(true); + }); + }); +}); From 31c72bdfa0af612580df33e30379e0de201536ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 10:24:05 +0300 Subject: [PATCH 2/2] security(core): require a scoped token to state the permissions it grants 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 (cherry picked from commit 7d70a17559914ed47929c2deec490ac55e5dd0c6) --- api/utils/requestProcessor.js | 10 ++++++++++ test/2.api/16.token.manager.js | 10 ++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/utils/requestProcessor.js b/api/utils/requestProcessor.js index d6e209710e7..6af926c2682 100644 --- a/api/utils/requestProcessor.js +++ b/api/utils/requestProcessor.js @@ -2726,6 +2726,16 @@ const processRequest = (params) => { } } + // A scoped credential has to say what it is granting. A token with no + // token_permission is bounded only by its owner, so a child created without + // one would reach everything the owner can - wider than the parent that + // created it. The subset check above cannot catch this, because there is no + // permission object to compare; the omission itself is the escalation. + if (!tokenPermission && creatorToken && creatorToken.token_permission) { + common.returnMessage(params, 403, "A scoped token must state the permissions it grants"); + return; + } + // Login is a capability of the token, never of its purpose string, and it is // only ever passed on by a credential that holds it, to a child that is not // narrowed. That makes a scoped token unable to produce a session, which is diff --git a/test/2.api/16.token.manager.js b/test/2.api/16.token.manager.js index 29106c75aab..f5b2e6e6bcf 100644 --- a/test/2.api/16.token.manager.js +++ b/test/2.api/16.token.manager.js @@ -201,10 +201,14 @@ describe('Testing token manager', function() { validate_token(token1, {"app": [APP_ID], "multi": true, "ttl": 300, "endpoint": ["/o/token"], "purpose": "My test token"}, 1, done); }); + //Deliberate change of behaviour: /o/token/list returns whole token documents, and a + //document's _id is the token itself, so listing hands the caller credentials that may be + //wider than the one it used. Token listing is therefore restricted to a full-permission + //credential, and this endpoint-scoped token is refused even though its endpoint matches. it('using token' + token1, function(done) { request .get('/o/token/list?auth_token=' + token1) - .expect(200) + .expect(403) .end(function(err, res) { if (err) { return done(err); @@ -491,8 +495,10 @@ describe('Testing token manager', function() { }); it('the limited token cannot be granted login permission', function(done) { + //the permission the token already holds is supplied, so a narrowing child is the only + //thing being asked for beyond login - what is refused here is the login grant itself request - .get('/i/token/create?auth_token=' + limitedToken + '&multi=true&ttl=300&can_login=true') + .get('/i/token/create?auth_token=' + limitedToken + '&multi=true&ttl=300&can_login=true&permission=' + readCorePermission(APP_ID)) .expect(403) .end(function(err, res) { if (err) {