mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) make the support account configurable, and allow listing public sites
Summary: This makes two small tweaks based on a user's questions about sharing sites publicly for a self-managed installation: * The support user `support@getgrist.com` is made configurable with `GRIST_SUPPORT_EMAIL`. This came up because only the support user can share material with the special "everyone" user. This restriction was added to avoid spam. * Regardless of public sharing settings, for our SaaS we had decided not to list public sites to anonymous users. That is somewhat a question of taste, so a `GRIST_LIST_PUBLIC_SITES` flag is added to override this choice. Public sharing isn't in a well polished state, and this diff doesn't advance that, in fact it adds a new wrinkle :-/ Test Plan: existing tests pass; manual testing Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3663
This commit is contained in:
		
							parent
							
								
									1c8a29ef9b
								
							
						
					
					
						commit
						ab3cdb62ac
					
				@ -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
 | 
			
		||||
 | 
			
		||||
@ -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() {
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
@ -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) {
 | 
			
		||||
 | 
			
		||||
@ -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';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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<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()) {
 | 
			
		||||
      return result;
 | 
			
		||||
    }
 | 
			
		||||
    const extendedScope = addPermit(scope, this._dbManager.getSupportUserId(), {org});
 | 
			
		||||
    return await op(extendedScope);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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<GristLoginSystem> {
 | 
			
		||||
          // 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();
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user