diff --git a/README.md b/README.md index 48a1fd9f..0e7582de 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ GRIST_HOST | hostname to use when listening on a port. GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*. GRIST_IGNORE_SESSION | if set, Grist will not use a session for authentication. GRIST_INST_DIR | path to Grist instance configuration files, for Grist server. +GRIST_LIST_PUBLIC_SITES | if set to true, sites shared with the public will be listed for anonymous users. Defaults to false. GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited). GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited). @@ -231,6 +232,7 @@ GRIST_SESSION_SECRET | a key used to encode sessions GRIST_FORCE_LOGIN | when set to 'true' disables anonymous access GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) +GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. GRIST_THROTTLE_CPU | if set, CPU throttling is enabled GRIST_USER_ROOT | an extra path to look for plugins in. COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index e2de5668..3e668a95 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -18,7 +18,7 @@ import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs'; import {getThemeColors} from 'app/common/Themes'; import {getGristConfig} from 'app/common/urlUtils'; -import {getOrgName, Organization, OrgError, SUPPORT_EMAIL, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; +import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; @@ -276,7 +276,7 @@ export class AppModelImpl extends Disposable implements AppModel { } public isSupport() { - return this.currentValidUser?.email === SUPPORT_EMAIL; + return Boolean(this.currentValidUser?.isSupport); } public isBillingManager() { diff --git a/app/common/LoginSessionAPI.ts b/app/common/LoginSessionAPI.ts index 805f0c1e..d4d6a78a 100644 --- a/app/common/LoginSessionAPI.ts +++ b/app/common/LoginSessionAPI.ts @@ -14,6 +14,7 @@ export interface FullUser extends UserProfile { id: number; ref?: string|null; // Not filled for anonymous users. allowGoogleLogin?: boolean; // when present, specifies whether logging in via Google is possible. + isSupport?: boolean; // set if user is a special support user. } export interface LoginSessionAPI { diff --git a/app/common/ShareAnnotator.ts b/app/common/ShareAnnotator.ts index 81f271e0..720b344f 100644 --- a/app/common/ShareAnnotator.ts +++ b/app/common/ShareAnnotator.ts @@ -55,11 +55,11 @@ export class ShareAnnotator { } const top = features.maxSharesPerDoc; let at = 0; - const makeAnnotation = (user: {email: string, isMember?: boolean, access: string|null}) => { + const makeAnnotation = (user: {email: string, isMember?: boolean, isSupport?: boolean, access: string|null}) => { const annotation: ShareAnnotation = { isMember: user.isMember, }; - if (user.email === 'support@getgrist.com') { + if (user.isSupport) { return { isSupport: true }; } if (!annotation.isMember && user.access) { diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 5f27d4b9..40a8538a 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -23,9 +23,6 @@ export const ANONYMOUS_USER_EMAIL = 'anon@getgrist.com'; // Nominal email address of a user who, if you share with them, everyone gets access. export const EVERYONE_EMAIL = 'everyone@getgrist.com'; -// A special user allowed to add/remove the EVERYONE_EMAIL to/from a resource. -export const SUPPORT_EMAIL = 'support@getgrist.com'; - // A special 'docId' that means to create a new document. export const NEW_DOCUMENT_CODE = 'new'; diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index aaf951d3..715bcbd0 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -16,7 +16,7 @@ import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {Request} from 'express'; import {User} from './entity/User'; -import {HomeDBManager} from './lib/HomeDBManager'; +import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager'; // Special public organization that contains examples and templates. export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ? @@ -330,8 +330,9 @@ 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 scope = addPermit(getScope(req), this._dbManager.getSupportUserId(), {org}); - const query = await this._dbManager.getOrgAccess(scope, org); + const query = await this._withSupportUserAllowedToView( + org, req, (scope) => this._dbManager.getOrgAccess(scope, org) + ); return sendReply(req, res, query); })); @@ -454,9 +455,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); - // 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 org = domain ? (await this._withSupportUserAllowedToView( + domain, req, (scope) => 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)}, @@ -527,6 +528,30 @@ export class ApiServer { const allowGoogleLogin = user.options?.allowGoogleLogin ?? true; return {...fullUser, loginMethod, allowGoogleLogin}; } + + + /** + * Run a query, and, if it is denied and the user is the support + * user, rerun the query with permission to view the current + * org. This is a bit inefficient, but only affects the support + * user. We wait to add the special permission only if needed, since + * it will in fact override any other access the support user has + * been granted, which could reduce their apparent access if that is + * part of what is returned by the query. + */ + private async _withSupportUserAllowedToView( + org: string|number, req: express.Request, + op: (scope: Scope) => Promise> + ): Promise> { + const scope = getScope(req); + const userId = getUserId(req); + const result = await op(scope); + if (result.status === 200 || userId !== this._dbManager.getSupportUserId()) { + return result; + } + const extendedScope = addPermit(scope, this._dbManager.getSupportUserId(), {org}); + return await op(extendedScope); + } } /** diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 93f3649c..1eab7005 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -21,7 +21,6 @@ import { Organization as OrgInfo, PermissionData, PermissionDelta, - SUPPORT_EMAIL, UserAccessData, UserOptions, WorkspaceProperties @@ -50,6 +49,7 @@ import { now, readJson } from 'app/gen-server/sqlUtils'; +import {appSettings} from 'app/server/lib/AppSettings'; import {getOrCreateConnection} from 'app/server/lib/dbUtils'; import {makeId} from 'app/server/lib/idUtils'; import log from 'app/server/lib/log'; @@ -90,12 +90,25 @@ export type NotifierEvent = typeof NotifierEvents.type; // Nominal email address of a user who can view anything (for thumbnails). export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com'; +// A special user allowed to add/remove the EVERYONE_EMAIL to/from a resource. +export const SUPPORT_EMAIL = appSettings.section('access').flag('supportEmail').requireString({ + envVar: 'GRIST_SUPPORT_EMAIL', + defaultValue: 'support@getgrist.com', +}); + // A list of emails we don't expect to see logins for. const NON_LOGIN_EMAILS = [PREVIEWER_EMAIL, EVERYONE_EMAIL, ANONYMOUS_USER_EMAIL]; // Name of a special workspace with examples in it. export const EXAMPLE_WORKSPACE_NAME = 'Examples & Templates'; +// Flag controlling whether sites that are publicly accessible should be listed +// to the anonymous user. Defaults to not listing such sites. +const listPublicSites = appSettings.section('access').flag('listPublicSites').readBool({ + envVar: 'GRIST_LIST_PUBLIC_SITES', + defaultValue: false, +}); + // A TTL in milliseconds for caching the result of looking up access level for a doc, // which is a burden under heavy traffic. const DOC_AUTH_CACHE_TTL = 5000; @@ -474,6 +487,9 @@ export class HomeDBManager extends EventEmitter { if (this.getAnonymousUserId() === user.id) { result.anonymous = true; } + if (this.getSupportUserId() === user.id) { + result.isSupport = true; + } return result; } @@ -1100,7 +1116,7 @@ export class HomeDBManager extends EventEmitter { queryBuilder = this._withAccess(queryBuilder, users, 'orgs'); // Add a direct, efficient filter to remove irrelevant personal orgs from consideration. queryBuilder = this._filterByOrgGroups(queryBuilder, users, domain, options); - if (this._isAnonymousUser(users)) { + if (this._isAnonymousUser(users) && !listPublicSites) { // The anonymous user is a special case. It may have access to potentially // many orgs, but listing them all would be kind of a misfeature. but reporting // nothing would complicate the client. We compromise, and report at most @@ -2303,6 +2319,7 @@ export class HomeDBManager extends EventEmitter { roles.getStrongestRole(wsMap[u.id] || null, inheritFromOrg) ), isMember: orgAccess !== 'guests' && orgAccess !== null, + isSupport: u.id === this.getSupportUserId() ? true : undefined, }; }); let maxInheritedRole = this._getMaxInheritedRole(doc); diff --git a/app/server/lib/TestLogin.ts b/app/server/lib/TestLogin.ts index a5deb9ba..2cd0d5ac 100644 --- a/app/server/lib/TestLogin.ts +++ b/app/server/lib/TestLogin.ts @@ -1,3 +1,4 @@ +import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer'; import {Request} from 'express'; @@ -23,7 +24,7 @@ export async function getTestLoginSystem(): Promise { // Make sure support user has a test api key if needed. if (process.env.TEST_SUPPORT_API_KEY) { const dbManager = gristServer.getHomeDBManager(); - const user = await dbManager.getUserByLogin('support@getgrist.com'); + const user = await dbManager.getUserByLogin(SUPPORT_EMAIL); if (user) { user.apiKey = process.env.TEST_SUPPORT_API_KEY; await user.save();