(core) Allowing installation admins to manage billing.

Summary:
Permissions for admin billing endpoints were changed

- Support user can't use admin subscription endpoints
- Installation admin (as support user) can see billing details on any site
- Installation admin (unlike support user) can replace subscription (or attach payment) on any site, regardless permissions

Installation admin is any user that belongs to a special `admin` org. If `admin` org is not defined, it defaults to
support user. In that case, with this diff, the support user receives admin's permissions, and now can replace subscription on
any site (without being billing manager).

Test Plan: Added new test

Reviewers: dsagal, paulfitz

Reviewed By: dsagal, paulfitz

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4338
dependabot/npm_and_yarn/express-4.20.0
Jarosław Sadziński 1 week ago
parent 1d2cf3de49
commit 14718120bd

@ -196,13 +196,17 @@ export class AccountWidget extends Disposable {
if (deploymentType !== 'saas') { return null; }
const {currentValidUser, currentOrg, isTeamSite} = this._appModel;
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
(currentOrg.billingAccount.isManager || currentValidUser?.isSupport));
const canViewBillingPage = Boolean(
currentOrg && // have accecc to org
currentOrg.billingAccount && // have access to billing account
(currentOrg.billingAccount.isManager // is billing manager
|| currentValidUser?.isSupport // or support
|| this._appModel.isInstallAdmin())); // or install admin
return isTeamSite ?
// For links, disabling with just a class is hard; easier to just not make it a link.
// TODO weasel menus should support disabling menuItemLink.
(isBillingManager ?
(canViewBillingPage ?
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) :
menuItem(() => null, t('Billing Account'), dom.cls('disabled', true))
) :

@ -17,7 +17,7 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import log from 'app/server/lib/log';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
import {clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {getCookieDomain} from 'app/server/lib/gristSessions';
@ -392,7 +392,7 @@ 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._withSupportUserAllowedToView(
const query = await this._withPrivilegedViewForUser(
org, req, (scope) => this._dbManager.getOrgAccess(scope, org)
);
return sendReply(req, res, query);
@ -534,7 +534,7 @@ export class ApiServer {
this._app.get('/api/session/access/active', expressWrap(async (req, res) => {
const fullUser = await this._getFullUser(req, {includePrefs: true});
const domain = getOrgFromRequest(req);
const org = domain ? (await this._withSupportUserAllowedToView(
const org = domain ? (await this._withPrivilegedViewForUser(
domain, req, (scope) => this._dbManager.getOrg(scope, domain)
)) : null;
const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined;
@ -617,26 +617,32 @@ export class ApiServer {
/**
* Run a query, and, if it is denied and the user is the support
* Run a query, and, if it is denied and the user is the support or admin
* user, rerun the query with permission to view the current
* org. This is a bit inefficient, but only affects the support
* org. This is a bit inefficient, but only affects the support/admin
* user. We wait to add the special permission only if needed, since
* it will in fact override any other access the support user has
* it will in fact override any other access the special user has
* been granted, which could reduce their apparent access if that is
* part of what is returned by the query.
*/
private async _withSupportUserAllowedToView<T>(
private async _withPrivilegedViewForUser<T>(
org: string|number, req: express.Request,
op: (scope: Scope) => Promise<QueryResult<T>>
): Promise<QueryResult<T>> {
const scope = getScope(req);
const userId = getUserId(req);
const result = await op(scope);
if (result.status === 200 || userId !== this._dbManager.getSupportUserId()) {
if (result.status === 200) {
return result;
}
const extendedScope = addPermit(scope, this._dbManager.getSupportUserId(), {org});
return await op(extendedScope);
if (userId === this._dbManager.getSupportUserId() ||
await this._gristServer.getInstallAdmin()?.isAdminReq(req)) {
const extendedScope: Scope = {...scope, specialPermit: {org}};
return await op(extendedScope);
}
return result;
}
private _logInvitedDocUserTelemetryEvents(mreq: RequestWithLogin, delta: PermissionDelta) {

@ -1810,12 +1810,13 @@ export class HomeDBManager extends EventEmitter {
//
// Returns an empty query result with status 200 on success.
public async updateBillingAccount(
userId: number,
scopeOrUser: number|Scope,
orgKey: string|number,
callback: (billingAccount: BillingAccount, transaction: EntityManager) => void|Promise<void>
): Promise<QueryResult<void>> {
): Promise<QueryResult<void>> {
return await this._connection.transaction(async transaction => {
const billingAccount = await this.getBillingAccount({userId}, orgKey, false, transaction);
const scope = typeof scopeOrUser === 'number' ? {userId: scopeOrUser} : scopeOrUser;
const billingAccount = await this.getBillingAccount(scope, 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

@ -18,7 +18,7 @@ export abstract class InstallAdmin {
// the Grist installation. This should not fail, only return true or false.
public async isAdminReq(req: express.Request): Promise<boolean> {
const user = (req as RequestWithLogin).user;
return user ? this.isAdminUser(user) : false;
return user ? (await this.isAdminUser(user)) : false;
}
// Returns middleware that fails unless the request includes an authenticated user and this user

@ -117,7 +117,9 @@ async function openWebhookPageWithoutWaitForServer() {
async function waitForWebhookPage() {
await driver.findContentWait('button', /Clear Queue/, 3000);
// No section, so no easy utility for setting focus. Click on a random cell.
await gu.getDetailCell({col: 'Webhook Id', rowNum: 1}).click();
await gu.waitToPass(async () => {
await gu.getDetailCell({col: 'Webhook Id', rowNum: 1}).click();
});
}
export async function openAccountMenu() {

Loading…
Cancel
Save