(core) Admin Panel and InstallAdmin class to identify installation admins.

Summary:
- Add InstallAdmin class to identify users who can manage Grist installation.

  This is overridable by different Grist flavors (e.g. different in SaaS).
  It generalizes previous logic used to decide who can control Activation
  settings (e.g. enable telemetry).

- Implement a basic Admin Panel at /admin, and move items previously in the
  "Support Grist" page into the "Support Grist" section of the Admin Panel.

- Replace "Support Grist" menu items with "Admin Panel" and show only to admins.

- Add "Support Grist" links to Github sponsorship to user-account menu.

- Add "Support Grist" button to top-bar, which
  - for admins, replaces the previous "Contribute" button and reopens the "Support Grist / opt-in to telemetry" nudge (unchanged)
  - for everyone else, links to Github sponsorship
  - in either case, user can dismiss it.

Test Plan: Shuffled some test cases between Support Grist and the new Admin Panel, and added some new cases.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Differential Revision: https://phab.getgrist.com/D4194
This commit is contained in:
Dmitry S
2024-03-23 13:11:06 -04:00
parent 0c05f4cdc4
commit e380fcfa90
32 changed files with 875 additions and 524 deletions

View File

@@ -48,6 +48,7 @@ import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
import {IBilling} from 'app/server/lib/IBilling';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {INotifier} from 'app/server/lib/INotifier';
import {InstallAdmin} from 'app/server/lib/InstallAdmin';
import log from 'app/server/lib/log';
import {getLoginSystem} from 'app/server/lib/logins';
import {IPermitStore} from 'app/server/lib/Permit';
@@ -55,7 +56,7 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isDefaultUser, isParameterOn, optIntegerParam,
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isParameterOn, optIntegerParam,
optStringParam, RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET,
trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
@@ -133,6 +134,7 @@ export class FlexServer implements GristServer {
private _servesPlugins?: boolean;
private _bundledWidgets?: ICustomWidget[];
private _billing: IBilling;
private _installAdmin: InstallAdmin;
private _instanceRoot: string;
private _docManager: DocManager;
private _docWorker: DocWorker;
@@ -390,6 +392,11 @@ export class FlexServer implements GristServer {
return this._notifier;
}
public getInstallAdmin(): InstallAdmin {
if (!this._installAdmin) { throw new Error('no InstallAdmin available'); }
return this._installAdmin;
}
public getAccessTokens() {
if (this._accessTokens) { return this._accessTokens; }
this.addDocWorkerMap();
@@ -725,6 +732,7 @@ export class FlexServer implements GristServer {
// If the installation appears to be new, give it an id and a creation date.
this._activations = new Activations(this._dbManager);
await this._activations.current();
this._installAdmin = await this.create.createInstallAdmin(this._dbManager);
}
public addDocWorkerMap() {
@@ -864,11 +872,6 @@ export class FlexServer implements GristServer {
this._telemetry = this.create.Telemetry(this._dbManager, this);
this._telemetry.addEndpoints(this.app);
this._telemetry.addPages(this.app, [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
]);
await this._telemetry.start();
// Start up a monitor for memory and cpu usage.
@@ -1787,15 +1790,23 @@ export class FlexServer implements GristServer {
public addInstallEndpoints() {
if (this._check('install')) { return; }
const isManager = expressWrap(
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); }
const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
next();
}
);
const adminPageMiddleware = [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
// In principle, it may be safe to show the Admin Panel to non-admins but let's protect it
// since it's intended for admins, and it's easier not to have to worry how it should behave
// for others.
requireInstallAdmin,
];
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
this.app.get('/api/install/prefs', expressWrap(async (_req, resp) => {
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
const activation = await this._activations.current();
return sendOkReply(null, resp, {
@@ -1803,7 +1814,7 @@ export class FlexServer implements GristServer {
});
}));
this.app.patch('/api/install/prefs', isManager, expressWrap(async (req, resp) => {
this.app.patch('/api/install/prefs', requireInstallAdmin, expressWrap(async (req, resp) => {
const props = {prefs: req.body};
const activation = await this._activations.current();
activation.checkProperties(props);

View File

@@ -16,6 +16,7 @@ import { Hosts } from 'app/server/lib/extractOrg';
import { ICreate } from 'app/server/lib/ICreate';
import { IDocStorageManager } from 'app/server/lib/IDocStorageManager';
import { INotifier } from 'app/server/lib/INotifier';
import { InstallAdmin } from 'app/server/lib/InstallAdmin';
import { IPermitStore } from 'app/server/lib/Permit';
import { ISendAppPageOptions } from 'app/server/lib/sendAppPage';
import { fromCallback } from 'app/server/lib/serverUtils';
@@ -47,6 +48,7 @@ export interface GristServer {
getDeploymentType(): GristDeploymentType;
getHosts(): Hosts;
getActivations(): Activations;
getInstallAdmin(): InstallAdmin;
getHomeDBManager(): HomeDBManager;
getStorageManager(): IDocStorageManager;
getTelemetry(): ITelemetry;
@@ -135,6 +137,7 @@ export function createDummyGristServer(): GristServer {
getDeploymentType() { return 'core'; },
getHosts() { throw new Error('no hosts'); },
getActivations() { throw new Error('no activations'); },
getInstallAdmin() { throw new Error('no install admin'); },
getHomeDBManager() { throw new Error('no db'); },
getStorageManager() { throw new Error('no storage manager'); },
getTelemetry() { return createDummyTelemetry(); },
@@ -155,7 +158,6 @@ export function createDummyGristServer(): GristServer {
export function createDummyTelemetry(): ITelemetry {
return {
addEndpoints() { /* do nothing */ },
addPages() { /* do nothing */ },
start() { return Promise.resolve(); },
logEvent() { /* do nothing */ },
logEventAsync() { return Promise.resolve(); },

View File

@@ -6,6 +6,7 @@ import {ExternalStorage} from 'app/server/lib/ExternalStorage';
import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
import {IBilling} from 'app/server/lib/IBilling';
import {INotifier} from 'app/server/lib/INotifier';
import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin';
import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox';
import {IShell} from 'app/server/lib/IShell';
import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox';
@@ -28,6 +29,9 @@ export interface ICreate {
NSandbox(options: ISandboxCreationOptions): ISandbox;
// Create the logic to determine which users are authorized to manage this Grist installation.
createInstallAdmin(dbManager: HomeDBManager): Promise<InstallAdmin>;
deploymentType(): GristDeploymentType;
sessionSecret(): string;
// Check configuration of the app early enough to show on startup.
@@ -80,6 +84,7 @@ export function makeSimpleCreator(opts: {
getExtraHeadHtml?: () => string,
getSqliteVariant?: () => SqliteVariant,
getSandboxVariants?: () => Record<string, SpawnFn>,
createInstallAdmin?: (dbManager: HomeDBManager) => Promise<InstallAdmin>,
}): ICreate {
const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts;
return {
@@ -157,5 +162,6 @@ export function makeSimpleCreator(opts: {
},
getSqliteVariant: opts.getSqliteVariant,
getSandboxVariants: opts.getSandboxVariants,
createInstallAdmin: opts.createInstallAdmin || (async () => new SimpleInstallAdmin()),
};
}

View File

@@ -0,0 +1,52 @@
import {ApiError} from 'app/common/ApiError';
import {appSettings} from 'app/server/lib/AppSettings';
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
import {User} from 'app/gen-server/entity/User';
import express from 'express';
/**
* Class implementing the logic to determine whether a user is authorized to manage the Grist
* installation.
*/
export abstract class InstallAdmin {
// Returns true if user is authorized to manage the Grist installation.
public abstract isAdminUser(user: User): Promise<boolean>;
// Returns true if req is authenticated (contains a user) and the user is authorized to manage
// 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;
}
// Returns middleware that fails unless the request includes an authenticated user and this user
// is authorized to manage the Grist installation.
public getMiddlewareRequireAdmin(): express.RequestHandler {
return this._requireAdmin.bind(this);
}
private async _requireAdmin(req: express.Request, resp: express.Response, next: express.NextFunction) {
try {
// getUser() will fail with 401 if user is not present.
if (!await this.isAdminUser(getUser(req))) {
throw new ApiError('Access denied', 403);
}
next();
} catch (err) {
next(err);
}
}
}
// Considers the user whose email matches GRIST_DEFAULT_EMAIL env var, if given, to be the
// installation admin. If not given, then there is no admin.
export class SimpleInstallAdmin extends InstallAdmin {
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
envVar: 'GRIST_DEFAULT_EMAIL',
});
public override async isAdminUser(user: User): Promise<boolean> {
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
}
}

View File

@@ -58,7 +58,6 @@ export interface ITelemetry {
): Promise<void>;
shouldLogEvent(name: TelemetryEvent): boolean;
addEndpoints(app: express.Express): void;
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined;
fetchTelemetryPrefs(): Promise<void>;
}
@@ -196,15 +195,6 @@ export class Telemetry implements ITelemetry {
}));
}
public addPages(app: express.Application, middleware: express.RequestHandler[]) {
if (this._deploymentType === 'core') {
app.get('/support', ...middleware, expressWrap(async (req, resp) => {
return this._gristServer.sendAppPage(req, resp,
{path: 'app.html', status: 200, config: {}});
}));
}
}
public getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined {
const prefs = this._telemetryPrefs;
if (!prefs) {

View File

@@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
@@ -377,9 +377,3 @@ export function addAbortHandler(req: Request, res: Writable, op: () => void) {
}
});
}
export function isDefaultUser(req: Request) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
const {loginEmail} = getUser(req);
return defaultEmail && defaultEmail === loginEmail;
}

View File

@@ -82,7 +82,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
((server?.getBundledWidgets().length || 0) > 0),
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined),
activation: (req as RequestWithLogin|undefined)?.activation,
enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),
supportedLngs: readLoadedLngs(req?.i18n),
namespaces: readLoadedNamespaces(req?.i18n),
@@ -306,11 +306,3 @@ function getDocFromConfig(config: GristLoadConfig): Document | null {
return config.getDoc[config.assignmentId] ?? null;
}
function getActivation(mreq: RequestWithLogin|undefined) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
return {
...mreq?.activation,
isManager: Boolean(defaultEmail && defaultEmail === mreq?.user?.loginEmail),
};
}