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..6af926c2682 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,90 @@ 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; + } + } + + // 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 + // 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 +2767,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 +2801,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 +2892,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..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); @@ -379,4 +383,251 @@ 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) { + //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&permission=' + readCorePermission(APP_ID)) + .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); + }); + }); +});