diff --git a/plugins/alerts/api/api.js b/plugins/alerts/api/api.js index 3707b43a39d..08e8b586cf9 100644 --- a/plugins/alerts/api/api.js +++ b/plugins/alerts/api/api.js @@ -5,10 +5,12 @@ var Promise = require("bluebird"); const JOB = require('../../../api/parts/jobs'); const utils = require('./parts/utils.js'); const _ = require('lodash'); -const { validateCreate, validateRead, validateUpdate, hasCreateRight, getAdminApps, getUserAppsForFeaturePermission } = require('../../../api/utils/rights.js'); +const { validateCreate, validateRead, validateUpdate, hasCreateRight, hasUpdateRight, getAdminApps, getUserAppsForFeaturePermission } = require('../../../api/utils/rights.js'); const FEATURE_NAME = 'alerts'; const commonLib = require("./parts/common-lib.js"); const moment = require('moment-timezone'); +const { memberHasRightForAllApps } = require('./parts/app-authorization.js'); + /** * Alerts that can be triggered when an event is received. @@ -248,22 +250,41 @@ function getScheduleTextExpression(period, offset) { if (params.member.global_admin !== true) { query.createdBy = params.member._id; } - return common.db.collection("alerts").findAndModify( - query, - {}, - {$set: alertConfig}, - function(err, result) { - if (!err) { - if (result && result.value) { - plugins.dispatch("/updateAlert", { method: "alertTrigger", alert: result.value }); + //The guard above only saw the submitted selectedApps, and an update + //may leave that out to keep whatever is stored. So load the alert + //and authorize the apps it currently targets before changing it, + //otherwise an alert for an app the caller has since lost access to + //stays editable through any app they do still hold. + return common.db.collection("alerts").findOne({_id: common.db.ObjectID(id)}, function(findErr, existingAlert) { + if (findErr) { + common.returnMessage(params, 500, "Failed to save an alert"); + return; + } + if (existingAlert && params.member.global_admin !== true + && !memberHasRightForAllApps(hasUpdateRight, params.member, existingAlert.selectedApps)) { + log.d("Rejected alert update" + common.reqInfo(params) + ": alert " + id + + " targets apps [" + (Array.isArray(existingAlert.selectedApps) ? existingAlert.selectedApps.join(", ") : "") + + "] the caller may not update"); + common.returnMessage(params, 403, 'No alerts:update permission on the apps this alert targets'); + return; + } + common.db.collection("alerts").findAndModify( + query, + {}, + {$set: alertConfig}, + function(err, result) { + if (!err) { + if (result && result.value) { + plugins.dispatch("/updateAlert", { method: "alertTrigger", alert: result.value }); + } + + common.returnOutput(params, result && result.value); } - - common.returnOutput(params, result && result.value); - } - else { - common.returnMessage(params, 500, "Failed to save an alert"); - } - }); + else { + common.returnMessage(params, 500, "Failed to save an alert"); + } + }); + }); } if (!alertConfig._id) { alertConfig.createdAt = new Date().getTime(); @@ -317,6 +338,12 @@ function getScheduleTextExpression(period, offset) { try { var query = { "_id": common.db.ObjectID(alertID) }; //If not global admin, limit delete to own alerts only + // + //Deliberately not also requiring rights on the apps the alert targets. + //Deleting only removes what the alert does, so it cannot be used to + //reach another app's data, and someone who has lost access to an app + //needs to remain able to remove the alert they own for it. Refusing here + //would leave them holding an alert they can neither manage nor delete. if (params.member.global_admin !== true) { query.createdBy = params.member._id; } @@ -366,7 +393,7 @@ function getScheduleTextExpression(period, offset) { plugins.register("/i/alert/status", function(ob) { let params = ob.params; - validateUpdate(params, FEATURE_NAME, function() { + validateUpdate(params, FEATURE_NAME, async function() { let statusList; try { statusList = JSON.parse(params.qstring.status); @@ -376,6 +403,50 @@ function getScheduleTextExpression(period, offset) { common.returnMessage(params, 500, "Failed to change alert status" + err.message); return; } + //Enabling an alert for an app the caller may no longer touch is what makes + //this endpoint exploitable, so the apps each stored alert targets are + //authorized before it is switched on. + // + //Switching one off is deliberately still allowed. It only reduces what the + //alert does, and refusing it would leave somebody who lost access to an app + //unable to stop alert mail they no longer want, which is worse than the + //problem being fixed. + const enablingIds = Object.keys(statusList).filter(function(alertID) { + return statusList[alertID] === true || statusList[alertID] === "true"; + }); + if (enablingIds.length > 0 && params.member.global_admin !== true) { + let toEnable = []; + try { + //scoped to the caller's own alerts, the same as the update below. + //An id belonging to somebody else must keep behaving as it did, a + //silent no-op, rather than returning a 403 that would confirm an + //alert with those apps exists. + toEnable = await common.db.collection("alerts").find({ + _id: { + $in: enablingIds.map(function(a) { + return common.db.ObjectID(a); + }) + }, + createdBy: params.member._id + }, { projection: { selectedApps: 1 } }).toArray(); + } + catch (e) { + log.e("Failed to load alerts for a status change", e); + common.returnMessage(params, 500, "Failed to change alert status"); + return; + } + const unauthorized = toEnable.filter(function(alert) { + return !memberHasRightForAllApps(hasUpdateRight, params.member, alert.selectedApps); + }); + if (unauthorized.length > 0) { + log.d("Rejected alert status change" + common.reqInfo(params) + ": alert(s) " + + unauthorized.map(function(a) { + return a._id; + }).join(", ") + " target apps the caller may not update"); + common.returnMessage(params, 403, 'No alerts:update permission on the apps these alerts target'); + return; + } + } const batch = []; for (const alertID in statusList) { var qquery = { _id: common.db.ObjectID(alertID) }; diff --git a/plugins/alerts/api/jobs/monitor.js b/plugins/alerts/api/jobs/monitor.js index fab1bf99b65..10cf074b8dd 100644 --- a/plugins/alerts/api/jobs/monitor.js +++ b/plugins/alerts/api/jobs/monitor.js @@ -4,7 +4,48 @@ const { TRIGGERED_BY_EVENT } = require('../parts/common-lib.js'); const { Job } = require('../../../../api/parts/jobs/job.js'), log = require('../../../../api/utils/log.js')('alert:monitor'), - common = require('../../../../api/utils/common.js'); + common = require('../../../../api/utils/common.js'), + { hasReadRight } = require('../../../../api/utils/rights.js'); + +const { memberMayReadApp } = require('../parts/app-authorization.js'); + +/** + * Whether the member who created an alert may still read the app it is about. + * + * The endpoints authorize a request, but nothing re-checked anything once an alert was + * enabled, so one created before access was revoked kept emailing that app's metrics + * indefinitely with no further action by anybody. This is the check that stops that, and + * it is why fixing only the write endpoints would not have been enough. + * + * @param {object} alert - the stored alert + * @param {string} appID - app the scheduled job is about + * @returns {Promise} true when the alert may still be sent + */ +async function ownerMayStillReadApp(alert, appID) { + if (!alert.createdBy) { + //Alerts predating the createdBy field cannot be attributed to anybody. Refusing + //to send them would silently stop alerts that have worked for years, which is a + //worse outcome than the exposure closed here, so they are reported and left be. + log.w("Alert " + alert._id + " has no createdBy, so its owner's current access cannot be checked. Sending anyway."); + return true; + } + let owner; + try { + owner = await common.db.collection("members").findOne( + { _id: common.db.ObjectID(alert.createdBy + "") }, + { projection: { global_admin: 1, permission: 1, user_of: 1, admin_of: 1 } } + ); + } + catch (e) { + log.e("Could not resolve the owner of alert " + alert._id + ", not sending", e); + return false; + } + if (!owner) { + log.d("Owner of alert " + alert._id + " no longer exists, not sending"); + return false; + } + return memberMayReadApp(hasReadRight, owner, appID); +} const ALERT_MODULES = { "views": require("../alertModules/views.js"), @@ -46,6 +87,10 @@ class MonitorJob extends Job { if (!alert || !app) { throw new Error("Alert", alertID, "or App", appID, "couldn't be found"); } + if (!await ownerMayStillReadApp(alert, appID)) { + log.d("Not sending alert " + alertID + " for app " + appID + ": its owner no longer has access to that app"); + return; + } if (alert.alertDataType === 'profile_groups') { alert.alertDataType = 'cohorts'; } diff --git a/plugins/alerts/api/parts/app-authorization.js b/plugins/alerts/api/parts/app-authorization.js new file mode 100644 index 00000000000..02747b67b89 --- /dev/null +++ b/plugins/alerts/api/parts/app-authorization.js @@ -0,0 +1,95 @@ +/** + * @module plugins/alerts/api/parts/app-authorization + * @description Decides whether a member may act on the apps an alert targets. + * + * The alert endpoints authorize the caller against params.qstring.app_id, which says + * nothing about the apps the alert itself points at. Two further things have to be + * checked and were not: + * + * - the apps the *stored* alert targets, on any mutation addressed by _id. The + * submitted selectedApps is checked separately, but an update may leave that field + * out and keep whatever is stored, so the stored value needs its own check. + * - the owner's *current* access, at the moment an alert is sent. Nothing re-checked + * that once an alert was enabled, so one created before access was revoked kept + * emailing that app's metrics with no further action by anybody. + * + * createdBy is not a substitute for either. It says the member made the alert, not that + * they may still act on the apps it targets. + * + * This lives in its own module because the request path and the scheduled job both need + * it, including the legacy allowance below, and two copies of that would drift. + */ + +'use strict'; + +const FEATURE_NAME = 'alerts'; + +/** + * Apps a member reaches only through the legacy fields. + * + * Members created before the permission object have none, and the right helpers then + * fall through to admin_of alone, so somebody who is merely user_of an app looks + * unauthorized. Without this they would lose the ability to manage their own alerts, and + * their alerts would stop being delivered. /o/alert/list makes the same allowance; all + * three have to agree or an alert could be listed and not editable, or editable and not + * delivered. + * + * @param {object} member - member object + * @returns {string[]} app ids granted by the legacy fields, empty for modern members + */ +function legacyApps(member) { + if (!member || typeof member.permission !== "undefined") { + return []; + } + return Array.isArray(member.user_of) ? member.user_of.map(String) : []; +} + +/** + * Whether the member currently holds a right on every app in the list. + * + * An empty or missing list returns false: an alert that targets nothing is not something + * to authorize permissively. + * + * @param {function} rightFn - hasCreateRight / hasUpdateRight / hasReadRight + * @param {object} member - member object + * @param {Array} apps - app ids the alert targets + * @returns {boolean} true only when the member holds the right for every app + */ +function memberHasRightForAllApps(rightFn, member, apps) { + if (!member || !Array.isArray(apps) || apps.length === 0) { + return false; + } + if (member.global_admin) { + return true; + } + const legacy = legacyApps(member); + return apps.every(function(appId) { + return rightFn(FEATURE_NAME, appId + "", member) || legacy.indexOf(appId + "") > -1; + }); +} + +/** + * Whether the member may read one app's alerts. Used by the scheduled job, which deals + * with a single app rather than an alert's whole target list. + * + * @param {function} readRightFn - hasReadRight from rights.js + * @param {object} member - member object + * @param {string} appId - app id + * @returns {boolean} true when the member may read that app's alerts + */ +function memberMayReadApp(readRightFn, member, appId) { + if (!member) { + return false; + } + if (member.global_admin) { + return true; + } + return readRightFn(FEATURE_NAME, appId + "", member) || legacyApps(member).indexOf(appId + "") > -1; +} + +module.exports = { + FEATURE_NAME, + legacyApps, + memberHasRightForAllApps, + memberMayReadApp +}; diff --git a/plugins/alerts/tests.js b/plugins/alerts/tests.js index d741a43e84b..a379191a803 100644 --- a/plugins/alerts/tests.js +++ b/plugins/alerts/tests.js @@ -129,3 +129,327 @@ describe('Testing Alert', function() { }); +// Regression tests for authorization of the apps an alert targets. +// +// The endpoints authorize the caller against params.qstring.app_id, and the create path +// checks the submitted selectedApps. Neither says anything about the apps the *stored* +// alert points at, and an update may omit selectedApps to keep whatever is stored. So a +// member who created an alert for one app, then lost access to it while keeping alerts +// rights elsewhere, could still edit and re-enable that alert by sending the request with +// the app they do still hold. +// +// Both directions are covered here. The point of the fix is not only that the exploit +// stops working, but that everything a member is entitled to do still does. +describe('Testing Alert app authorization', function() { + var API_KEY_ADMIN = ""; + var APP_A = ""; + var APP_B = ""; + var memberApiKey = ""; + var memberId = ""; + var alertOnA = ""; + var alertOnB = ""; + var uniq = Date.now(); + + /** + * Build an alert config + * @param {object} over - fields to override + * @returns {object} alert config + */ + function alertFor(over) { + return Object.assign({ + alertName: "authz-" + uniq, + alertDataType: "metric", + alertDataSubType: "Total users", + compareType: "increased by at least", + compareValue: "1", + period: "every 1 hour on the 59th min", + alertBy: "email", + enabled: false, + compareDescribe: "Total users increased by at least 1%", + alertValues: ["authz-" + uniq + "@mail.test"] + }, over); + } + + /** + * Set the member's alerts permissions to exactly the given apps + * @param {Array} apps - app ids to grant + * @param {function} cb - callback + * @returns {void} + */ + function grantAlertsOn(apps, cb) { + var permission = {_: {a: [], u: [apps]}, c: {}, r: {}, u: {}, d: {}}; + apps.forEach(function(a) { + permission.c[a] = {all: false, allowed: {alerts: true}}; + permission.r[a] = {all: false, allowed: {alerts: true}}; + permission.u[a] = {all: false, allowed: {alerts: true}}; + permission.d[a] = {all: false, allowed: {alerts: true}}; + }); + request.get('/i/users/update?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({user_id: memberId, permission: permission}))) + .end(function() { + cb(); + }); + } + + it('should set up two apps and a member with alerts rights on both', function(done) { + API_KEY_ADMIN = testUtils.get("API_KEY_ADMIN"); + APP_A = testUtils.get("APP_ID"); + request.get('/i/apps/create?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({name: "authzAppB" + uniq, country: "TR", type: "mobile", category: "6", timezone: "Europe/Istanbul"}))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + APP_B = res.body._id; + should.exist(APP_B); + var permission = {_: {a: [], u: [[APP_A, APP_B]]}, c: {}, r: {}, u: {}, d: {}}; + [APP_A, APP_B].forEach(function(a) { + permission.c[a] = {all: false, allowed: {alerts: true}}; + permission.r[a] = {all: false, allowed: {alerts: true}}; + permission.u[a] = {all: false, allowed: {alerts: true}}; + permission.d[a] = {all: false, allowed: {alerts: true}}; + }); + var userParams = { + full_name: "alertauthz" + uniq, + username: "alertauthz" + uniq, + password: "p4ssw0rD!", + email: "alertauthz" + uniq + "@mail.test", + permission: permission + }; + request.get('/i/users/create?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify(userParams))) + .expect(200) + .end(function(e, r) { + if (e) { + return done(e); + } + memberApiKey = r.body.api_key; + memberId = r.body._id; + should.exist(memberApiKey); + done(); + }); + }); + }); + + it('should let the member create an alert on each app while they have both', function(done) { + request.get('/i/alert/save?api_key=' + memberApiKey + '&app_id=' + APP_A + '&alert_config=' + encodeURIComponent(JSON.stringify(alertFor({selectedApps: [APP_A]})))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + alertOnA = (res.body && (res.body._id || res.body)) + ""; + should.exist(alertOnA); + request.get('/i/alert/save?api_key=' + memberApiKey + '&app_id=' + APP_B + '&alert_config=' + encodeURIComponent(JSON.stringify(alertFor({selectedApps: [APP_B], alertName: "authzB-" + uniq})))) + .expect(200) + .end(function(e, r) { + if (e) { + return done(e); + } + alertOnB = (r.body && (r.body._id || r.body)) + ""; + should.exist(alertOnB); + done(); + }); + }); + }); + + it('should revoke access to the first app, keeping alerts rights on the second', function(done) { + grantAlertsOn([APP_B], function() { + done(); + }); + }); + + // the exploit + + it('should refuse to update the revoked app alert through the app still held', function(done) { + // selectedApps is omitted on purpose: that is what kept the stored target and + // skipped the submitted-apps guard + var payload = {_id: alertOnA, alertValues: ["attacker-" + uniq + "@mail.test"], compareValue: "0"}; + request.get('/i/alert/save?api_key=' + memberApiKey + '&app_id=' + APP_B + '&alert_config=' + encodeURIComponent(JSON.stringify(payload))) + .expect(403) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should refuse to enable the revoked app alert', function(done) { + var status = {}; + status[alertOnA] = true; + request.get('/i/alert/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&status=' + encodeURIComponent(JSON.stringify(status))) + .expect(403) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should not have changed the alert despite the attempts', function(done) { + request.get('/o/alert/list?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var list = (res.body && res.body.alertsList) || []; + var found = list.filter(function(a) { + return a._id + "" === alertOnA; + })[0]; + should.exist(found); + found.enabled.should.not.equal(true); + JSON.stringify(found.alertValues).should.not.match(/attacker-/); + done(); + }); + }); + + it('should still hide the revoked app alert from the member list', function(done) { + request.get('/o/alert/list?api_key=' + memberApiKey + '&app_id=' + APP_B) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var list = (res.body && res.body.alertsList) || []; + list.filter(function(a) { + return a._id + "" === alertOnA; + }).length.should.equal(0); + done(); + }); + }); + + // the happy paths, which have to keep working + + it('should still let the member update their own alert on the app they hold', function(done) { + var payload = {_id: alertOnB, compareValue: "7"}; + request.get('/i/alert/save?api_key=' + memberApiKey + '&app_id=' + APP_B + '&alert_config=' + encodeURIComponent(JSON.stringify(payload))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should still let the member enable and disable their own alert', function(done) { + var on = {}; + on[alertOnB] = true; + request.get('/i/alert/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&status=' + encodeURIComponent(JSON.stringify(on))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + var off = {}; + off[alertOnB] = false; + request.get('/i/alert/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&status=' + encodeURIComponent(JSON.stringify(off))) + .expect(200) + .end(function(e) { + if (e) { + return done(e); + } + done(); + }); + }); + }); + + it('should still let the member create a new alert on the app they hold', function(done) { + request.get('/i/alert/save?api_key=' + memberApiKey + '&app_id=' + APP_B + '&alert_config=' + encodeURIComponent(JSON.stringify(alertFor({selectedApps: [APP_B], alertName: "authzB2-" + uniq})))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should let the member switch OFF the revoked app alert, so they are not stuck with it', function(done) { + // deliberately allowed: disabling only reduces what the alert does, and refusing + // would leave them unable to stop mail they no longer want + var off = {}; + off[alertOnA] = false; + request.get('/i/alert/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&status=' + encodeURIComponent(JSON.stringify(off))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should let a global admin update and enable any alert', function(done) { + var payload = {_id: alertOnA, compareValue: "3"}; + request.get('/i/alert/save?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&alert_config=' + encodeURIComponent(JSON.stringify(payload))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + var on = {}; + on[alertOnA] = true; + request.get('/i/alert/status?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&status=' + encodeURIComponent(JSON.stringify(on))) + .expect(200) + .end(function(e) { + if (e) { + return done(e); + } + done(); + }); + }); + }); + + it('should let the member delete the revoked app alert they own', function(done) { + // also deliberately allowed: deleting cannot reach another app's data, and the + // owner needs a way to clean up + request.get('/i/alert/delete?api_key=' + memberApiKey + '&app_id=' + APP_B + '&alertID=' + alertOnA) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + after(function(done) { + request.get('/o/alert/list?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A) + .end(function(listErr, listRes) { + var list = ((listRes && listRes.body && listRes.body.alertsList) || []).filter(function(a) { + return typeof a.alertName === "string" && a.alertName.indexOf("" + uniq) > -1; + }); + var pending = list.length; + /** + * remove the member and the extra app once alerts are gone + * @returns {void} + */ + function cleanupRest() { + request.get('/i/users/delete?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({user_ids: [memberId]}))) + .end(function() { + if (!APP_B) { + return done(); + } + request.get('/i/apps/delete?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({app_id: APP_B}))) + .end(function() { + done(); + }); + }); + } + if (!pending) { + return cleanupRest(); + } + list.forEach(function(a) { + request.get('/i/alert/delete?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&alertID=' + a._id) + .end(function() { + pending--; + if (!pending) { + cleanupRest(); + } + }); + }); + }); + }); +}); diff --git a/test/unit-tests/plugins.alerts.app-authorization.js b/test/unit-tests/plugins.alerts.app-authorization.js new file mode 100644 index 00000000000..18e17db2fa8 --- /dev/null +++ b/test/unit-tests/plugins.alerts.app-authorization.js @@ -0,0 +1,109 @@ +require("should"); +var authz = require("../../plugins/alerts/api/parts/app-authorization.js"); +var rights = require("../../api/utils/rights.js"); + +// These decide whether a member may act on the apps an alert targets. Both directions +// matter equally: too permissive and an alert for a revoked app stays editable and keeps +// being delivered, too strict and members lose the ability to manage alerts they own. +// +// The legacy cases are the ones worth having tests for. Members created before the +// permission object reach apps through user_of, and the right helpers fall through to +// admin_of alone, so a strict reading would judge them unauthorized and both stop their +// alerts and lock them out of their own configuration. +describe("alerts app authorization", function() { + var modernMember = { + _id: "m1", + permission: { + _: {a: [], u: [["appA"]]}, + c: {appA: {all: false, allowed: {alerts: true}}}, + r: {appA: {all: false, allowed: {alerts: true}}}, + u: {appA: {all: false, allowed: {alerts: true}}}, + d: {} + } + }; + var legacyMember = {_id: "m2", user_of: ["appL"], admin_of: []}; + var legacyAdminMember = {_id: "m3", user_of: ["appL2"], admin_of: ["appL2"]}; + var globalAdmin = {_id: "m4", global_admin: true}; + + describe("legacyApps", function() { + it("returns nothing for a member with a permission object", function() { + authz.legacyApps(modernMember).should.eql([]); + }); + it("returns user_of for a member without one", function() { + authz.legacyApps(legacyMember).should.eql(["appL"]); + }); + it("tolerates a member with neither", function() { + authz.legacyApps({_id: "x"}).should.eql([]); + authz.legacyApps(null).should.eql([]); + }); + it("stringifies ids, since they may be stored as ObjectIds", function() { + var oid = { + toString: function() { + return "appZ"; + } + }; + authz.legacyApps({user_of: [oid]}).should.eql(["appZ"]); + }); + }); + + describe("memberHasRightForAllApps", function() { + it("allows an app the member holds the right on", function() { + authz.memberHasRightForAllApps(rights.hasUpdateRight, modernMember, ["appA"]).should.equal(true); + }); + it("refuses an app the member does not", function() { + authz.memberHasRightForAllApps(rights.hasUpdateRight, modernMember, ["appB"]).should.equal(false); + }); + it("requires every app, not just one of them", function() { + // an alert targeting two apps is only editable by someone who may touch both + authz.memberHasRightForAllApps(rights.hasUpdateRight, modernMember, ["appA", "appB"]).should.equal(false); + }); + it("allows a legacy member their user_of app", function() { + // without this a legacy member could not edit an alert they own + authz.memberHasRightForAllApps(rights.hasUpdateRight, legacyMember, ["appL"]).should.equal(true); + }); + it("refuses a legacy member an app outside user_of", function() { + authz.memberHasRightForAllApps(rights.hasUpdateRight, legacyMember, ["appOther"]).should.equal(false); + }); + it("allows a legacy admin_of member", function() { + authz.memberHasRightForAllApps(rights.hasUpdateRight, legacyAdminMember, ["appL2"]).should.equal(true); + }); + it("allows a global admin anything", function() { + authz.memberHasRightForAllApps(rights.hasUpdateRight, globalAdmin, ["anything", "else"]).should.equal(true); + }); + it("refuses an empty or missing target list", function() { + // an alert that targets nothing is not something to wave through + authz.memberHasRightForAllApps(rights.hasUpdateRight, modernMember, []).should.equal(false); + authz.memberHasRightForAllApps(rights.hasUpdateRight, modernMember, undefined).should.equal(false); + authz.memberHasRightForAllApps(rights.hasUpdateRight, modernMember, null).should.equal(false); + }); + it("refuses a missing member", function() { + authz.memberHasRightForAllApps(rights.hasUpdateRight, null, ["appA"]).should.equal(false); + }); + it("distinguishes the rights, so read access does not grant update", function() { + var readOnly = { + _id: "m5", + permission: {_: {a: [], u: [["appR"]]}, c: {}, r: {appR: {all: false, allowed: {alerts: true}}}, u: {}, d: {}} + }; + authz.memberHasRightForAllApps(rights.hasReadRight, readOnly, ["appR"]).should.equal(true); + authz.memberHasRightForAllApps(rights.hasUpdateRight, readOnly, ["appR"]).should.equal(false); + }); + }); + + describe("memberMayReadApp", function() { + it("allows the app a member can read", function() { + authz.memberMayReadApp(rights.hasReadRight, modernMember, "appA").should.equal(true); + }); + it("refuses an app the member cannot read", function() { + authz.memberMayReadApp(rights.hasReadRight, modernMember, "appB").should.equal(false); + }); + it("allows a legacy member their user_of app, so their alerts keep arriving", function() { + authz.memberMayReadApp(rights.hasReadRight, legacyMember, "appL").should.equal(true); + }); + it("allows a global admin", function() { + authz.memberMayReadApp(rights.hasReadRight, globalAdmin, "whatever").should.equal(true); + }); + it("refuses a missing member, which is how a deleted owner looks", function() { + authz.memberMayReadApp(rights.hasReadRight, null, "appA").should.equal(false); + }); + }); +});