mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -23,6 +23,7 @@ import {
|
||||
ForkResult,
|
||||
ImportOptions,
|
||||
ImportResult,
|
||||
PermissionDataWithExtraUsers,
|
||||
QueryResult,
|
||||
ServerQuery
|
||||
} from 'app/common/ActiveDocAPI';
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
import {DocData} from 'app/common/DocData';
|
||||
import {DocSnapshots} from 'app/common/DocSnapshot';
|
||||
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||
import {normalizeEmail} from 'app/common/emails';
|
||||
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
||||
import {byteString, countIf, safeJsonParse} from 'app/common/gutil';
|
||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||
@@ -1105,6 +1107,65 @@ export class ActiveDoc extends EventEmitter {
|
||||
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 {
|
||||
return this.docPluginManager.gristDocAPI;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export class DocWorker {
|
||||
checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'),
|
||||
getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'),
|
||||
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
|
||||
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app
|
||||
import { TableDataAction, UserAction } from 'app/common/DocActions';
|
||||
import { DocData } from 'app/common/DocData';
|
||||
import { UserOverride } from 'app/common/DocListAPI';
|
||||
import { normalizeEmail } from 'app/common/emails';
|
||||
import { ErrorWithCode } from 'app/common/ErrorWithCode';
|
||||
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
|
||||
import { UserInfo } from 'app/common/GranularAccessClause';
|
||||
import { isCensored } from 'app/common/gristTypes';
|
||||
import { getSetMapValue, isObject, pruneArray } from 'app/common/gutil';
|
||||
import { canEdit, canView, Role } from 'app/common/roles';
|
||||
import { FullUser } from 'app/common/UserAPI';
|
||||
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
||||
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
||||
import { compileAclFormula } from 'app/server/lib/ACLFormula';
|
||||
import { DocClients } from 'app/server/lib/DocClients';
|
||||
@@ -718,6 +719,49 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
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,
|
||||
* in which case the role of the override is returned.
|
||||
@@ -1243,18 +1287,8 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
access = attrs.override.access;
|
||||
fullUser = attrs.override.user;
|
||||
} else {
|
||||
// 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.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 };
|
||||
attrs.override = await this._getViewAsUser(linkParameters);
|
||||
fullUser = attrs.override.user;
|
||||
}
|
||||
} else {
|
||||
fullUser = getDocSessionUser(docSession);
|
||||
@@ -1305,6 +1339,45 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
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.
|
||||
* 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];
|
||||
}
|
||||
|
||||
public has(colId: string) {
|
||||
return colId === 'id' || colId in this.data[3];
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
if (this.index === undefined) { return {}; }
|
||||
const results: {[key: string]: any} = {};
|
||||
|
||||
Reference in New Issue
Block a user