From 671dc24214ca855ae2fca51e7f6cee4bdfa4292b Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Wed, 22 Jul 2020 15:45:39 -0400 Subject: [PATCH] (core) Allow the support user to access everyone's billing pages Summary: Give specialPermit to the support user for page loads and API requests needed to serve billing pages. Test Plan: Added new test cases Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2554 --- app/gen-server/ApiServer.ts | 9 ++++--- app/gen-server/lib/HomeDBManager.ts | 41 ++++++++++++++++++++--------- app/server/lib/FlexServer.ts | 6 +++-- app/server/lib/requestUtils.ts | 8 ++++++ 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index a58adb3c..6e780178 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -10,7 +10,7 @@ import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession'; import {expressWrap} from 'app/server/lib/expressWrap'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import * as log from 'app/server/lib/log'; -import {getDocScope, getScope, integerParam, isParameterOn, sendOkReply, +import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; import {Request} from 'express'; @@ -289,7 +289,8 @@ export class ApiServer { // Get user access information regarding an org this._app.get('/api/orgs/:oid/access', expressWrap(async (req, res) => { const org = getOrgKey(req); - const query = await this._dbManager.getOrgAccess(getScope(req), org); + const scope = addPermit(getScope(req), this._dbManager.getSupportUserId(), {org}); + const query = await this._dbManager.getOrgAccess(scope, org); return sendReply(req, res, query); })); @@ -375,7 +376,9 @@ export class ApiServer { this._app.get('/api/session/access/active', expressWrap(async (req, res) => { const fullUser = await this._getFullUser(req); const domain = getOrgFromRequest(req); - const org = domain ? (await this._dbManager.getOrg(getScope(req), domain || null)) : null; + // Allow the support user enough access to every org to see the billing pages. + const scope = domain ? addPermit(getScope(req), this._dbManager.getSupportUserId(), {org: domain}) : null; + const org = scope ? (await this._dbManager.getOrg(scope, domain)) : null; const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined; return sendOkReply(req, res, { user: {...fullUser, helpScoutSignature: helpScoutSign(fullUser.email)}, diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 9cbde39e..9ae645ee 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -635,7 +635,11 @@ export class HomeDBManager extends EventEmitter { needRealOrg: true }); qb = this._addBillingAccount(qb, scope.userId); - qb = this._withAccess(qb, scope.userId, 'orgs'); + let effectiveUserId = scope.userId; + if (scope.specialPermit && scope.specialPermit.org === orgKey) { + effectiveUserId = this.getPreviewerUserId(); + } + qb = this._withAccess(qb, effectiveUserId, 'orgs'); qb = qb.leftJoinAndSelect('orgs.owner', 'owner'); const result = await this._verifyAclPermissions(qb); if (result.status === 200) { @@ -661,11 +665,13 @@ export class HomeDBManager extends EventEmitter { * To include `managers` and `orgs` fields listing all billing account managers * and organizations linked to the account, set `includeOrgsAndManagers`. */ - public async getBillingAccount(userId: number, orgKey: string|number, + public async getBillingAccount(scope: Scope, orgKey: string|number, includeOrgsAndManagers: boolean, transaction?: EntityManager): Promise { - const org = this.unwrapQueryResult(await this.getOrg({userId}, orgKey, transaction)); - if (!org.billingAccount.isManager && userId !== this.getPreviewerUserId()) { + const org = this.unwrapQueryResult(await this.getOrg(scope, orgKey, transaction)); + if (!org.billingAccount.isManager && scope.userId !== this.getPreviewerUserId() && + // The special permit (used for the support user) allows access to the billing account. + scope.specialPermit?.org !== orgKey) { throw new ApiError('User does not have access to billing account', 401); } if (!includeOrgsAndManagers) { return org.billingAccount; } @@ -1544,7 +1550,7 @@ export class HomeDBManager extends EventEmitter { callback: (billingAccount: BillingAccount, transaction: EntityManager) => void|Promise ): Promise> { return await this._connection.transaction(async transaction => { - const billingAccount = await this.getBillingAccount(userId, orgKey, false, transaction); + const billingAccount = await this.getBillingAccount({userId}, orgKey, false, transaction); const billingAccountCopy = Object.assign({}, billingAccount); await callback(billingAccountCopy, transaction); // Pick out properties that are allowed to be changed, to prevent accidental updating @@ -1575,7 +1581,7 @@ export class HomeDBManager extends EventEmitter { } return await this._connection.transaction(async transaction => { - const billingAccount = await this.getBillingAccount(userId, orgKey, true, transaction); + const billingAccount = await this.getBillingAccount({userId}, orgKey, true, transaction); // At this point, we'll have thrown an error if userId is not a billing account manager. // Now check if the billing account has mutable managers (individual account does not). if (billingAccount.individual) { @@ -1796,7 +1802,8 @@ export class HomeDBManager extends EventEmitter { public async getOrgAccess(scope: Scope, orgKey: string|number): Promise> { const orgQuery = this.org(scope, orgKey, { markPermissions: Permissions.VIEW, - needRealOrg: true + needRealOrg: true, + allowSpecialPermit: true }) // Join the org's ACL rules (with 1st level groups/users listed). .leftJoinAndSelect('orgs.aclRules', 'acl_rules') @@ -2242,26 +2249,34 @@ export class HomeDBManager extends EventEmitter { */ public org(scope: Scope, org: string|number|null, options: QueryOptions = {}): SelectQueryBuilder { - return this._org(scope.userId, scope.includeSupport || false, org, options); + return this._org(scope, scope.includeSupport || false, org, options); } - private _org(userId: number|null, includeSupport: boolean, org: string|number|null, + private _org(scope: Scope|null, includeSupport: boolean, org: string|number|null, options: QueryOptions = {}): SelectQueryBuilder { let query = this._orgs(options.manager); // merged pseudo-org must become personal org. if (org === null || (options.needRealOrg && this.isMergedOrg(org))) { - if (!userId) { throw new Error('_org: requires userId'); } - query = query.where('orgs.owner_id = :userId', {userId}); + if (!scope || !scope.userId) { throw new Error('_org: requires userId'); } + query = query.where('orgs.owner_id = :userId', {userId: scope.userId}); } else { query = this._whereOrg(query, org, includeSupport); } if (options.markPermissions) { - if (!userId) { + if (!scope || !scope.userId) { throw new Error(`_orgQuery error: userId must be set to mark permissions`); } + let effectiveUserId = scope.userId; + let threshold = options.markPermissions; + // TODO If the specialPermit is used across the network, requests could refer to orgs in + // different ways (number vs string), causing this comparison to fail. + if (options.allowSpecialPermit && scope.specialPermit && scope.specialPermit.org === org) { + effectiveUserId = this.getPreviewerUserId(); + threshold = Permissions.VIEW; + } // Compute whether we have access to the doc query = query.addSelect( - this._markIsPermitted('orgs', userId, options.markPermissions), + this._markIsPermitted('orgs', effectiveUserId, threshold), 'is_permitted' ); } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 9c3c7bf8..48a94c84 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -37,7 +37,7 @@ import {getLoginMiddleware} from 'app/server/lib/logins'; import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places'; import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint'; import {PluginManager} from 'app/server/lib/PluginManager'; -import {adaptServerUrl, optStringParam, RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, +import {adaptServerUrl, addPermit, getScope, optStringParam, RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils'; import {ISendAppPageOptions, makeSendAppPage} from 'app/server/lib/sendAppPage'; import * as ServerMetrics from 'app/server/lib/ServerMetrics'; @@ -897,7 +897,9 @@ export class FlexServer implements GristServer { if (!orgDomain) { return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}}); } - const query = await this.dbManager.getOrg({userId: mreq.userId!}, orgDomain); + // Allow the support user access to billing pages. + const scope = addPermit(getScope(mreq), this.dbManager.getSupportUserId(), {org: orgDomain}); + const query = await this.dbManager.getOrg(scope, orgDomain); const org = this.dbManager.unwrapQueryResult(query); // This page isn't availabe for personal site. if (org.owner) { diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index cb9921cb..319521b0 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -4,6 +4,7 @@ import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import * as log from 'app/server/lib/log'; +import {Permit} from 'app/server/lib/Permit'; import {Request, Response} from 'express'; import {URL} from 'url'; @@ -132,6 +133,13 @@ export function getScope(req: Request): Scope { return {urlId, userId, org, includeSupport, showRemoved, specialPermit}; } +/** + * If scope is for the given userId, return a new Scope with the special permit added. + */ +export function addPermit(scope: Scope, userId: number, specialPermit: Permit): Scope { + return {...scope, ...(scope.userId === userId ? {specialPermit} : {})}; +} + // Return a JSON response reflecting the output of a query. // Filter out keys we don't want crossing the api. // Set req to null to not log any information about request.