(core) flesh out "View As" feature

Summary:
The users shown by the "View As" button are now drawn from more sources:
 * There are users the document is shared with. This has been rationalized, the behavior was somewhat erratic. If the user is not an owner of the document, the only user of this kind that will be listed is themselves.
 * There are users mentioned in any user attribute table keyed by Email. If name and access columns are present, those are respected, otherwise name is taken from email and access is set to "editors".
 * There are example users provided if there are not many other users available.

Test Plan: added and extended tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3045
This commit is contained in:
Paul Fitzpatrick 2021-10-08 10:56:33 -04:00
parent 07558dceba
commit d635c97686
8 changed files with 301 additions and 55 deletions

View File

@ -7,12 +7,13 @@ import {cssMemberImage, cssMemberListItem, cssMemberPrimary,
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menuCssClass} from 'app/client/ui2018/menus'; import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI';
import {userOverrideParams} from 'app/common/gristUrls'; import {userOverrideParams} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI'; import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI';
import {getRealAccess, PermissionData, UserAccessData} from 'app/common/UserAPI'; import {getRealAccess, UserAccessData} from 'app/common/UserAPI';
import {Disposable, dom, Observable, styled} from 'grainjs'; import {Disposable, dom, Observable, styled} from 'grainjs';
import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel'; import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
@ -30,7 +31,7 @@ function buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOp
), ),
cssMemberText( cssMemberText(
cssMemberPrimary(user.name || dom('span', user.email), cssMemberPrimary(user.name || dom('span', user.email),
cssRole('(', roleNames[user.access!] || user.access, ')', testId('acl-user-access')), cssRole('(', roleNames[user.access!] || user.access || 'no access', ')', testId('acl-user-access')),
), ),
user.name ? cssMemberSecondary(user.email) : null user.name ? cssMemberSecondary(user.email) : null
), ),
@ -53,31 +54,46 @@ function isSpecialEmail(email: string) {
export class ACLUsersPopup extends Disposable { export class ACLUsersPopup extends Disposable {
public readonly isInitialized = Observable.create(this, false); public readonly isInitialized = Observable.create(this, false);
private _usersInDoc: UserAccessData[] = []; private _shareUsers: UserAccessData[] = []; // Users doc is shared with.
private _attributeTableUsers: UserAccessData[] = []; // Users mentioned in attribute tables.
private _exampleUsers: UserAccessData[] = []; // Example users.
private _currentUser: FullUser|null = null; private _currentUser: FullUser|null = null;
public init(pageModel: DocPageModel, permissionData: PermissionData|null) { public init(pageModel: DocPageModel, permissionData: PermissionDataWithExtraUsers|null) {
this._currentUser = pageModel.userOverride.get()?.user || pageModel.appModel.currentValidUser; this._currentUser = pageModel.userOverride.get()?.user || pageModel.appModel.currentValidUser;
if (permissionData) { if (permissionData) {
this._usersInDoc = permissionData.users.map(user => ({ this._shareUsers = permissionData.users.map(user => ({
...user, ...user,
access: getRealAccess(user, permissionData), access: getRealAccess(user, permissionData),
})) }))
.filter(user => user.access && !isSpecialEmail(user.email)); .filter(user => user.access && !isSpecialEmail(user.email));
this._attributeTableUsers = permissionData.attributeTableUsers;
this._exampleUsers = permissionData.exampleUsers;
this.isInitialized.set(true); this.isInitialized.set(true);
} }
} }
public attachPopup(elem: Element) { public attachPopup(elem: Element) {
setPopupToCreateDom(elem, (ctl) => cssMenuWrap(cssMenu( setPopupToCreateDom(elem, (ctl) => {
const buildRow = (user: UserAccessData) => buildUserRow(user, this._currentUser, ctl);
return cssMenuWrap(cssMenu(
dom.cls(menuCssClass), dom.cls(menuCssClass),
cssUsers.cls(''), cssUsers.cls(''),
dom.forEach(this._usersInDoc, user => buildUserRow(user, this._currentUser, ctl)), dom.forEach(this._shareUsers, buildRow),
// Add a divider between users-from-shares and users from attribute tables.
(this._attributeTableUsers.length > 0) ? menuDivider() : null,
dom.forEach(this._attributeTableUsers, buildRow),
// Include example users only if there are not many "real" users.
// It might be better to have an expandable section with these users, collapsed
// by default, but that's beyond my UI ken.
(this._shareUsers.length + this._attributeTableUsers.length < 5) ? [
(this._exampleUsers.length > 0) ? menuDivider() : null,
dom.forEach(this._exampleUsers, buildRow)
] : null,
(el) => { setTimeout(() => el.focus(), 0); }, (el) => { setTimeout(() => el.focus(), 0); },
dom.onKeyDown({Escape: () => ctl.close()}), dom.onKeyDown({Escape: () => ctl.close()}),
)), ));
{...defaultMenuOptions, placement: 'bottom-end'} }, {...defaultMenuOptions, placement: 'bottom-end'});
);
} }
} }

View File

@ -163,8 +163,8 @@ export class AccessRules extends Disposable {
for (const tableId of ['_grist_ACLResources', '_grist_ACLRules']) { for (const tableId of ['_grist_ACLResources', '_grist_ACLRules']) {
const tableData = this._gristDoc.docData.getTable(tableId)!; const tableData = this._gristDoc.docData.getTable(tableId)!;
this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this)); this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this));
this.autoDispose(this._gristDoc.docPageModel.currentDoc.addListener(this._updateDocAccessData, this));
} }
this.autoDispose(this._gristDoc.docPageModel.currentDoc.addListener(this._updateDocAccessData, this));
this.update().catch((e) => this._errorMessage.set(e.message)); this.update().catch((e) => this._errorMessage.set(e.message));
} }
@ -336,12 +336,8 @@ export class AccessRules extends Disposable {
), ),
), ),
bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())), bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())),
// Disabling "View as user" for forks for the moment. TODO Modify getDocAccess endpoint bigBasicButton('Users', cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem),
// to accept forks, through the kind of manipulation that getDoc does; then can enable. dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')),
!this._gristDoc.docPageModel.isFork.get() ?
bigBasicButton('Users', cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem),
dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden'),
) : null,
), ),
cssConditionError({style: 'margin-left: 16px'}, cssConditionError({style: 'margin-left: 16px'},
dom.maybe(this._publicEditAccess, () => dom('div', dom.maybe(this._publicEditAccess, () => dom('div',
@ -468,8 +464,7 @@ export class AccessRules extends Disposable {
const pageModel = this._gristDoc.docPageModel; const pageModel = this._gristDoc.docPageModel;
const doc = pageModel.currentDoc.get(); const doc = pageModel.currentDoc.get();
// Note that the getDocAccess endpoint does not succeed for forks currently. const permissionData = doc && await this._gristDoc.docComm.getUsersForViewAs();
const permissionData = doc && !doc.isFork ? await pageModel.appModel.api.getDocAccess(doc.id) : null;
if (this.isDisposed()) { return; } if (this.isDisposed()) { return; }
this._aclUsersPopup.init(pageModel, permissionData); this._aclUsersPopup.init(pageModel, permissionData);

View File

@ -56,6 +56,7 @@ export class DocComm extends Disposable implements ActiveDocAPI {
public checkAclFormula = this._wrapMethod("checkAclFormula"); public checkAclFormula = this._wrapMethod("checkAclFormula");
public getAclResources = this._wrapMethod("getAclResources"); public getAclResources = this._wrapMethod("getAclResources");
public waitForInitialization = this._wrapMethod("waitForInitialization"); public waitForInitialization = this._wrapMethod("waitForInitialization");
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
public changeUrlIdEmitter = this.autoDispose(new Emitter()); public changeUrlIdEmitter = this.autoDispose(new Emitter());

View File

@ -2,6 +2,7 @@ import {ActionGroup} from 'app/common/ActionGroup';
import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {FormulaProperties} from 'app/common/GranularAccessClause'; import {FormulaProperties} from 'app/common/GranularAccessClause';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {PermissionData, UserAccessData} from 'app/common/UserAPI';
import {ParseOptions} from 'app/plugin/FileParserAPI'; import {ParseOptions} from 'app/plugin/FileParserAPI';
import {IMessage} from 'grain-rpc'; import {IMessage} from 'grain-rpc';
@ -134,6 +135,16 @@ export interface ForkResult {
urlId: string; urlId: string;
} }
/**
* An extension of PermissionData to cover not just users with whom a document is shared,
* but also users mentioned in the document (in user attribute tables), and suggested
* example users. This is for use in the "View As" feature of the access rules page.
*/
export interface PermissionDataWithExtraUsers extends PermissionData {
attributeTableUsers: UserAccessData[];
exampleUsers: UserAccessData[];
}
export interface ActiveDocAPI { export interface ActiveDocAPI {
/** /**
* Closes a document, and unsubscribes from its userAction events. * Closes a document, and unsubscribes from its userAction events.
@ -277,4 +288,9 @@ export interface ActiveDocAPI {
* Wait for document to finish initializing. * Wait for document to finish initializing.
*/ */
waitForInitialization(): Promise<void>; waitForInitialization(): Promise<void>;
/**
* Get users that are worth proposing to "View As" for access control purposes.
*/
getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
} }

View File

@ -8,7 +8,7 @@ import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import {UserOrgPrefs} from 'app/common/Prefs'; import {UserOrgPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
// TODO: API should implement UserAPI // TODO: API should implement UserAPI
import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, getRealAccess,
ManagerDelta, NEW_DOCUMENT_CODE, OrganizationProperties, ManagerDelta, NEW_DOCUMENT_CODE, OrganizationProperties,
Organization as OrgInfo, PermissionData, PermissionDelta, SUPPORT_EMAIL, UserAccessData, Organization as OrgInfo, PermissionData, PermissionDelta, SUPPORT_EMAIL, UserAccessData,
WorkspaceProperties} from "app/common/UserAPI"; WorkspaceProperties} from "app/common/UserAPI";
@ -556,6 +556,22 @@ export class HomeDBManager extends EventEmitter {
return userByLogin; return userByLogin;
} }
/**
* Find a user by email. Don't create the user if it doesn't already exist.
*/
public async getExistingUserByLogin(
email: string,
manager?: EntityManager
): Promise<User|undefined> {
const normalizedEmail = normalizeEmail(email);
return (manager || this._connection).createQueryBuilder()
.select('user')
.from(User, 'user')
.leftJoinAndSelect('user.logins', 'logins')
.where('email = :email', {email: normalizedEmail})
.getOne();
}
/** /**
* Returns true if the given domain string is available, and false if it is not available. * Returns true if the given domain string is available, and false if it is not available.
* NOTE that the endpoint only checks if the domain string is taken in the database, it does * NOTE that the endpoint only checks if the domain string is taken in the database, it does
@ -982,22 +998,8 @@ export class HomeDBManager extends EventEmitter {
// Set trunkAccess field. // Set trunkAccess field.
doc.trunkAccess = doc.access; doc.trunkAccess = doc.access;
// Forks without a user id are editable by anyone with view access to the trunk. // Update access for fork.
if (forkUserId === undefined && roles.canView(doc.access)) { doc.access = 'owners'; } this._setForkAccess({userId, forkUserId, snapshotId}, doc);
if (forkUserId !== undefined) {
// A fork user id is known, so only that user should get to edit the fork.
if (userId === forkUserId) {
if (roles.canView(doc.access)) { doc.access = 'owners'; }
} else {
// reduce to viewer if not already viewer
doc.access = roles.getWeakestRole('viewers', doc.access);
}
}
// Finally, if we are viewing a snapshot, we can't edit it.
if (snapshotId) {
doc.access = roles.getWeakestRole('viewers', doc.access);
}
} }
return doc; return doc;
} }
@ -2027,8 +2029,26 @@ export class HomeDBManager extends EventEmitter {
// a more straightforward way of determining inheritance. The difficulty here is that all users // a more straightforward way of determining inheritance. The difficulty here is that all users
// in the org and their logins are needed for inclusion in the result, which would require an // in the org and their logins are needed for inclusion in the result, which would require an
// extra lookup step when traversing from the doc. // extra lookup step when traversing from the doc.
public async getDocAccess(scope: DocScope): Promise<QueryResult<PermissionData>> { //
const doc = await this._loadDocAccess(scope, Permissions.VIEW); // If the user is not an owner of the document, only that user (at most) will be mentioned
// in the result.
//
// Optionally, the results can be flattened, removing all information about inheritance and
// parents, and just giving the effective access level of each user (frankly, the default
// output of this method is quite confusing).
//
// Optionally, users without access to the document can be removed from the results
// (I believe they are included in order to one day facilitate auto-completion in the client?).
public async getDocAccess(scope: DocScope, options?: {
flatten?: boolean,
excludeUsersWithoutAccess?: boolean,
}): Promise<QueryResult<PermissionData>> {
// Doc permissions of forks are based on the "trunk" document, so make sure
// we look up permissions of trunk if we are on a fork (we'll fix the permissions
// up for the fork immediately afterwards).
const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(scope.urlId);
const doc = await this._loadDocAccess({...scope, urlId: trunkId}, Permissions.VIEW);
const docMap = getMemberUserRoles(doc, this.defaultCommonGroupNames); const docMap = getMemberUserRoles(doc, this.defaultCommonGroupNames);
// The wsMap gives the ws access inherited by each user. // The wsMap gives the ws access inherited by each user.
const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames); const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames);
@ -2036,7 +2056,7 @@ export class HomeDBManager extends EventEmitter {
const orgMap = getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames); const orgMap = getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames);
const wsMaxInheritedRole = this._getMaxInheritedRole(doc.workspace); const wsMaxInheritedRole = this._getMaxInheritedRole(doc.workspace);
// Iterate through the org since all users will be in the org. // Iterate through the org since all users will be in the org.
const users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => { let users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => {
// Merge the strongest roles from the resource and parent resources. Note that the parent // Merge the strongest roles from the resource and parent resources. Note that the parent
// resource access levels must be tempered by the maxInheritedRole values of their children. // resource access levels must be tempered by the maxInheritedRole values of their children.
const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole); const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole);
@ -2048,10 +2068,42 @@ export class HomeDBManager extends EventEmitter {
) )
}; };
}); });
let maxInheritedRole = this._getMaxInheritedRole(doc);
if (options?.excludeUsersWithoutAccess) {
users = users.filter(user => {
const access = getRealAccess(user, { maxInheritedRole, users });
return roles.canView(access);
});
}
if (forkId || snapshotId || options?.flatten) {
for (const user of users) {
const access = getRealAccess(user, { maxInheritedRole, users });
user.access = access;
user.parentAccess = undefined;
}
maxInheritedRole = null;
}
const thisUser = users.find(user => user.id === scope.userId);
if (!thisUser || getRealAccess(thisUser, { maxInheritedRole, users }) !== 'owners') {
// If not an owner, don't return information about other users.
users = thisUser ? [thisUser] : [];
}
// If we are on a fork, make any access changes needed. Assumes results
// have been flattened.
if (forkId || snapshotId) {
for (const user of users) {
this._setForkAccess({userId: user.id, forkUserId, snapshotId}, user);
}
}
return { return {
status: 200, status: 200,
data: { data: {
maxInheritedRole: this._getMaxInheritedRole(doc), maxInheritedRole,
users users
} }
}; };
@ -2732,6 +2784,33 @@ export class HomeDBManager extends EventEmitter {
return id; return id;
} }
/**
* Modify an access level when the document is a fork. Here are the rules, as they
* have evolved (the main constraint is that currently forks have no access info of
* their own in the db).
* - If fork is a snapshot, all users are at most viewers. Else:
* - If there is no ~USERID in fork id, then all viewers of trunk are owners of the fork.
* - If there is a ~USERID in fork id, that user is owner, all others are at most viewers.
*/
private _setForkAccess(ids: {userId: number, forkUserId?: number, snapshotId?: string},
res: {access: roles.Role|null}) {
// Forks without a user id are editable by anyone with view access to the trunk.
if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = 'owners'; }
if (ids.forkUserId !== undefined) {
// A fork user id is known, so only that user should get to edit the fork.
if (ids.userId === ids.forkUserId) {
if (roles.canView(res.access)) { res.access = 'owners'; }
} else {
// reduce to viewer if not already viewer
res.access = roles.getWeakestRole('viewers', res.access);
}
}
// Finally, if we are viewing a snapshot, we can't edit it.
if (ids.snapshotId) {
res.access = roles.getWeakestRole('viewers', res.access);
}
}
// This deals with the problem posed by receiving a PermissionDelta specifying a // This deals with the problem posed by receiving a PermissionDelta specifying a
// role for both alice@x and Alice@x. We do not distinguish between such emails. // role for both alice@x and Alice@x. We do not distinguish between such emails.
// If there are multiple indistinguishabe emails, we preserve just one of them, // If there are multiple indistinguishabe emails, we preserve just one of them,

View File

@ -23,6 +23,7 @@ import {
ForkResult, ForkResult,
ImportOptions, ImportOptions,
ImportResult, ImportResult,
PermissionDataWithExtraUsers,
QueryResult, QueryResult,
ServerQuery ServerQuery
} from 'app/common/ActiveDocAPI'; } from 'app/common/ActiveDocAPI';
@ -40,6 +41,7 @@ import {
import {DocData} from 'app/common/DocData'; import {DocData} from 'app/common/DocData';
import {DocSnapshots} from 'app/common/DocSnapshot'; import {DocSnapshots} from 'app/common/DocSnapshot';
import {DocumentSettings} from 'app/common/DocumentSettings'; import {DocumentSettings} from 'app/common/DocumentSettings';
import {normalizeEmail} from 'app/common/emails';
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause'; import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
import {byteString, countIf, safeJsonParse} from 'app/common/gutil'; import {byteString, countIf, safeJsonParse} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer'; import {InactivityTimer} from 'app/common/InactivityTimer';
@ -1105,6 +1107,65 @@ export class ActiveDoc extends EventEmitter {
return result; return result;
} }
/**
* Get users that are worth proposing to "View As" for access control purposes.
* User are drawn from the following sources:
* - Users document is shared with.
* - Users mentioned in user attribute tables keyed by email address.
* - Some predefined example users.
*
* The users the document is shared with are only available if the
* user is an owner of the document (or, in a fork, an owner of the
* trunk document). For viewers or editors, only the user calling
* the method will be included as users the document is shared with.
*
* Users mentioned in user attribute tables will be available to any user with
* the right to view access rules.
*
* Example users are always included.
*/
public async getUsersForViewAs(docSession: DocSession): Promise<PermissionDataWithExtraUsers> {
// Make sure we have rights to view access rules.
const db = this.getHomeDbManager();
if (!db || !await this._granularAccess.hasAccessRulesPermission(docSession)) {
throw new Error('Cannot list ACL users');
}
// Prepare a stub for the collected results.
const result: PermissionDataWithExtraUsers = {
users: [],
attributeTableUsers: [],
exampleUsers: [],
};
const isShared = new Set<string>();
// Collect users the document is shared with.
const userId = getDocSessionUserId(docSession);
if (!userId) { throw new Error('Cannot determine user'); }
const access = db.unwrapQueryResult(
await db.getDocAccess({userId, urlId: this.docName}, {
flatten: true, excludeUsersWithoutAccess: true,
}));
result.users = access.users;
result.users.forEach(user => isShared.add(normalizeEmail(user.email)));
// Collect users from user attribute tables. Omit duplicates with users the document is
// shared with.
const usersFromUserAttributes = await this._granularAccess.collectViewAsUsersFromUserAttributeTables();
for (const user of usersFromUserAttributes) {
if (!user.email) { continue; }
const email = normalizeEmail(user.email);
if (!isShared.has(email)) {
result.attributeTableUsers.push({email: user.email, name: user.name || '',
id: 0, access: user.access === undefined ? 'editors' : user.access});
}
}
// Add some example users.
result.exampleUsers = this._granularAccess.getExampleViewAsUsers();
return result;
}
public getGristDocAPI(): GristDocAPI { public getGristDocAPI(): GristDocAPI {
return this.docPluginManager.gristDocAPI; return this.docPluginManager.gristDocAPI;
} }

View File

@ -110,6 +110,7 @@ export class DocWorker {
checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'), checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'),
getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'), getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'),
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'), waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
}); });
} }

View File

@ -10,13 +10,14 @@ import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app
import { TableDataAction, UserAction } from 'app/common/DocActions'; import { TableDataAction, UserAction } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData'; import { DocData } from 'app/common/DocData';
import { UserOverride } from 'app/common/DocListAPI'; import { UserOverride } from 'app/common/DocListAPI';
import { normalizeEmail } from 'app/common/emails';
import { ErrorWithCode } from 'app/common/ErrorWithCode'; import { ErrorWithCode } from 'app/common/ErrorWithCode';
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause'; import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
import { UserInfo } from 'app/common/GranularAccessClause'; import { UserInfo } from 'app/common/GranularAccessClause';
import { isCensored } from 'app/common/gristTypes'; import { isCensored } from 'app/common/gristTypes';
import { getSetMapValue, isObject, pruneArray } from 'app/common/gutil'; import { getSetMapValue, isObject, pruneArray } from 'app/common/gutil';
import { canEdit, canView, Role } from 'app/common/roles'; import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { FullUser } from 'app/common/UserAPI'; import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { compileAclFormula } from 'app/server/lib/ACLFormula';
import { DocClients } from 'app/server/lib/DocClients'; import { DocClients } from 'app/server/lib/DocClients';
@ -718,6 +719,49 @@ export class GranularAccess implements GranularAccessForBundle {
this._prevUserAttributesMap?.delete(docSession); this._prevUserAttributesMap?.delete(docSession);
} }
// Get a set of example users for playing with access control.
// We use the example.com domain, which is reserved for uses like this.
public getExampleViewAsUsers(): UserAccessData[] {
return [
{id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners'},
{id: 0, email: 'editor1@example.com', name: 'Editor 1', access: 'editors'},
{id: 0, email: 'editor2@example.com', name: 'Editor 2', access: 'editors'},
{id: 0, email: 'viewer@example.com', name: 'Viewer', access: 'viewers'},
{id: 0, email: 'unknown@example.com', name: 'Unknown User', access: null},
];
}
// Compile a list of users mentioned in user attribute tables keyed by email.
// If there is a Name column or an Access column, in the table, we use them.
public async collectViewAsUsersFromUserAttributeTables(): Promise<Array<Partial<UserAccessData>>> {
const result: Array<Partial<UserAccessData>> = [];
for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {
if (clause.charId !== 'Email') { continue; }
try {
const users = await this._fetchQueryFromDB({
tableId: clause.tableId,
filters: {},
});
const user = new RecordView(users, undefined);
const count = users[2].length;
for (let i = 0; i < count; i++) {
user.index = i;
const email = user.get(clause.lookupColId);
const name = user.get('Name') || String(email).split('@')[0];
const access = user.has('Access') ? String(user.get('Access')) : 'editors';
result.push({
email: email ? String(email) : undefined,
name: name ? String(name) : undefined,
access: isValidRole(access) ? access : null, // 'null' -> null a bit circuitously
});
}
} catch (e) {
log.warn(`User attribute ${clause.name} failed`, e);
}
}
return result;
}
/** /**
* Get the role the session user has for this document. User may be overridden, * Get the role the session user has for this document. User may be overridden,
* in which case the role of the override is returned. * in which case the role of the override is returned.
@ -1243,18 +1287,8 @@ export class GranularAccess implements GranularAccessForBundle {
access = attrs.override.access; access = attrs.override.access;
fullUser = attrs.override.user; fullUser = attrs.override.user;
} else { } else {
// Look up user information in database. attrs.override = await this._getViewAsUser(linkParameters);
if (!this._homeDbManager) { throw new Error('database required'); } fullUser = attrs.override.user;
const dbUser = linkParameters.aclAsUserId ?
(await this._homeDbManager.getUser(integerParam(linkParameters.aclAsUserId))) :
(await this._homeDbManager.getUserByLogin(linkParameters.aclAsUser));
const docAuth = dbUser && await this._homeDbManager.getDocAuthCached({
urlId: this._docId,
userId: dbUser.id
});
access = docAuth?.access || null;
fullUser = dbUser && this._homeDbManager.makeFullUser(dbUser) || null;
attrs.override = { access, user: fullUser };
} }
} else { } else {
fullUser = getDocSessionUser(docSession); fullUser = getDocSessionUser(docSession);
@ -1305,6 +1339,45 @@ export class GranularAccess implements GranularAccessForBundle {
return user; return user;
} }
/**
* Get the "View As" user specified in link parameters.
* If aclAsUserId is set, we get the user with the specified id.
* If aclAsUser is set, we get the user with the specified email,
* from the database if possible, otherwise from user attribute
* tables or examples.
*/
private async _getViewAsUser(linkParameters: Record<string, string>): Promise<UserOverride> {
// Look up user information in database.
if (!this._homeDbManager) { throw new Error('database required'); }
const dbUser = linkParameters.aclAsUserId ?
(await this._homeDbManager.getUser(integerParam(linkParameters.aclAsUserId))) :
(await this._homeDbManager.getExistingUserByLogin(linkParameters.aclAsUser));
if (!dbUser && linkParameters.aclAsUser) {
// Look further for the user, in user attribute tables or examples.
const otherUsers = (await this.collectViewAsUsersFromUserAttributeTables())
.concat(this.getExampleViewAsUsers());
const email = normalizeEmail(linkParameters.aclAsUser);
const dummyUser = otherUsers.find(user => normalizeEmail(user?.email || '') === email);
if (dummyUser) {
return {
access: dummyUser.access || null,
user: {
id: -1,
email: dummyUser.email!,
name: dummyUser.name || dummyUser.email!,
}
};
}
}
const docAuth = dbUser && await this._homeDbManager.getDocAuthCached({
urlId: this._docId,
userId: dbUser.id
});
const access = docAuth?.access || null;
const user = dbUser && this._homeDbManager.makeFullUser(dbUser) || null;
return { access, user };
}
/** /**
* Remove a set of rows from a DocAction. If the DocAction ends up empty, null is returned. * Remove a set of rows from a DocAction. If the DocAction ends up empty, null is returned.
* If the DocAction needs modification, it is copied first - the original is never * If the DocAction needs modification, it is copied first - the original is never
@ -1801,6 +1874,10 @@ export class RecordView implements InfoView {
return this.data[3][colId]?.[this.index]; return this.data[3][colId]?.[this.index];
} }
public has(colId: string) {
return colId === 'id' || colId in this.data[3];
}
public toJSON() { public toJSON() {
if (this.index === undefined) { return {}; } if (this.index === undefined) { return {}; }
const results: {[key: string]: any} = {}; const results: {[key: string]: any} = {};