(core) Support user variable in dropdown conditions

Summary:
Dropdown conditions can now reference a `user` variable, similar to the
one available in Access Rules.

Test Plan: Browser test.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Differential Revision: https://phab.getgrist.com/D4255
This commit is contained in:
George Gevoian
2024-05-29 14:55:21 -07:00
parent 50077540e2
commit 72066bf0e4
27 changed files with 426 additions and 268 deletions

View File

@@ -443,6 +443,10 @@ export class ActiveDoc extends EventEmitter {
return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary());
}
public getUser(docSession: OptDocSession) {
return this._granularAccess.getUser(docSession);
}
public async getUserOverride(docSession: OptDocSession) {
return this._granularAccess.getUserOverride(docSession);
}

View File

@@ -395,9 +395,10 @@ export class DocManager extends EventEmitter {
}
}
const [metaTables, recentActions, userOverride] = await Promise.all([
const [metaTables, recentActions, user, userOverride] = await Promise.all([
activeDoc.fetchMetaTables(docSession),
activeDoc.getRecentMinimalActions(docSession),
activeDoc.getUser(docSession),
activeDoc.getUserOverride(docSession),
]);
@@ -414,6 +415,7 @@ export class DocManager extends EventEmitter {
doc: metaTables,
log: recentActions,
recoveryMode: activeDoc.recoveryMode,
user: user.toUserInfo(),
userOverride,
docUsage,
isTimingOn: activeDoc.isTimingOn,

View File

@@ -26,12 +26,14 @@ import { UserOverride } from 'app/common/DocListAPI';
import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage';
import { normalizeEmail } from 'app/common/emails';
import { ErrorWithCode } from 'app/common/ErrorWithCode';
import { InfoEditor, InfoView, UserInfo } from 'app/common/GranularAccessClause';
import { InfoEditor } from 'app/common/GranularAccessClause';
import * as gristTypes from 'app/common/gristTypes';
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
import { compilePredicateFormula, EmptyRecordView, PredicateFormulaInput } from 'app/common/PredicateFormula';
import { compilePredicateFormula, PredicateFormulaInput } from 'app/common/PredicateFormula';
import { MetaRowRecord, SingleCell } from 'app/common/TableData';
import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { User } from 'app/common/User';
import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData';
@@ -330,11 +332,106 @@ export class GranularAccess implements GranularAccessForBundle {
this._userAttributesMap = new WeakMap();
}
public getUser(docSession: OptDocSession): Promise<UserInfo> {
return this._getUser(docSession);
/**
* Construct the UserInfo needed for evaluating rules. This also enriches the user with values
* created by user-attribute rules.
*/
public async getUser(docSession: OptDocSession): Promise<User> {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
let access: Role | null;
let fullUser: FullUser | null;
const attrs = this._getUserAttributes(docSession);
access = getDocSessionAccess(docSession);
const linkId = getDocSessionShare(docSession);
let shareRef: number = 0;
if (linkId) {
const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({
linkId,
});
if (rowIds.length > 1) {
throw new Error('Share identifier is not unique');
}
if (rowIds.length === 1) {
shareRef = rowIds[0];
}
}
if (docSession.forkingAsOwner) {
// For granular access purposes, we become an owner.
// It is a bit of a bluff, done on the understanding that this session will
// never be used to edit the document, and that any edits will be done on a
// fork.
access = 'owners';
}
// If aclAsUserId/aclAsUser is set, then override user for acl purposes.
if (linkParameters.aclAsUserId || linkParameters.aclAsUser) {
if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); }
if (attrs.override) {
// Used cached properties.
access = attrs.override.access;
fullUser = attrs.override.user;
} else {
attrs.override = await this._getViewAsUser(linkParameters);
fullUser = attrs.override.user;
}
} else {
fullUser = getDocSessionUser(docSession);
}
const user = new User();
user.Access = access;
user.ShareRef = shareRef || null;
const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() ||
fullUser?.id === null;
user.UserID = (!isAnonymous && fullUser?.id) || null;
user.Email = fullUser?.email || null;
user.Name = fullUser?.name || null;
// If viewed from a websocket, collect any link parameters included.
// TODO: could also get this from rest api access, just via a different route.
user.LinkKey = linkParameters;
// Include origin info if accessed via the rest api.
// TODO: could also get this for websocket access, just via a different route.
user.Origin = docSession.req?.get('origin') || null;
user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`;
user.IsLoggedIn = !isAnonymous;
user.UserRef = fullUser?.ref || null; // Empty string should be treated as null.
if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) {
// It is important to signal that the doc is in an unexpected state,
// and prevent it opening.
throw this._ruler.ruleCollection.ruleError;
}
for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {
if (clause.name in user) {
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);
continue;
}
if (attrs.rows[clause.name]) {
user[clause.name] = attrs.rows[clause.name];
continue;
}
let rec = new EmptyRecordView();
let rows: TableDataAction|undefined;
try {
// Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.
// TODO: add indexes to db.
rows = await this._fetchQueryFromDB({
tableId: clause.tableId,
filters: { [clause.lookupColId]: [get(user, clause.charId)] }
});
} catch (e) {
log.warn(`User attribute ${clause.name} failed`, e);
}
if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); }
user[clause.name] = rec;
attrs.rows[clause.name] = rec;
}
return user;
}
public async getCachedUser(docSession: OptDocSession): Promise<UserInfo> {
public async getCachedUser(docSession: OptDocSession): Promise<User> {
const access = await this._getAccess(docSession);
return access.getUser();
}
@@ -345,7 +442,7 @@ export class GranularAccess implements GranularAccessForBundle {
*/
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
return {
user: await this._getUser(docSession),
user: await this.getUser(docSession),
docId: this._docId
};
}
@@ -479,7 +576,7 @@ export class GranularAccess implements GranularAccessForBundle {
public async canApplyBundle() {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession, isDirect} = this._activeBundle;
const currentUser = await this._getUser(docSession);
const currentUser = await this.getUser(docSession);
const userIsOwner = await this.isOwner(docSession);
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules');
@@ -1004,7 +1101,7 @@ export class GranularAccess implements GranularAccessForBundle {
}
public async getUserOverride(docSession: OptDocSession): Promise<UserOverride|undefined> {
await this._getUser(docSession);
await this.getUser(docSession);
return this._getUserAttributes(docSession).override;
}
@@ -1120,7 +1217,7 @@ export class GranularAccess implements GranularAccessForBundle {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
const baseAccess = getDocSessionAccess(docSession);
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
const info = await this._getUser(docSession);
const info = await this.getUser(docSession);
return info.Access;
}
return baseAccess;
@@ -1838,105 +1935,6 @@ export class GranularAccess implements GranularAccessForBundle {
}
}
/**
* Construct the UserInfo needed for evaluating rules. This also enriches the user with values
* created by user-attribute rules.
*/
private async _getUser(docSession: OptDocSession): Promise<UserInfo> {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
let access: Role | null;
let fullUser: FullUser | null;
const attrs = this._getUserAttributes(docSession);
access = getDocSessionAccess(docSession);
const linkId = getDocSessionShare(docSession);
let shareRef: number = 0;
if (linkId) {
const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({
linkId,
});
if (rowIds.length > 1) {
throw new Error('Share identifier is not unique');
}
if (rowIds.length === 1) {
shareRef = rowIds[0];
}
}
if (docSession.forkingAsOwner) {
// For granular access purposes, we become an owner.
// It is a bit of a bluff, done on the understanding that this session will
// never be used to edit the document, and that any edits will be done on a
// fork.
access = 'owners';
}
// If aclAsUserId/aclAsUser is set, then override user for acl purposes.
if (linkParameters.aclAsUserId || linkParameters.aclAsUser) {
if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); }
if (attrs.override) {
// Used cached properties.
access = attrs.override.access;
fullUser = attrs.override.user;
} else {
attrs.override = await this._getViewAsUser(linkParameters);
fullUser = attrs.override.user;
}
} else {
fullUser = getDocSessionUser(docSession);
}
const user = new User();
user.Access = access;
user.ShareRef = shareRef || null;
const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() ||
fullUser?.id === null;
user.UserID = (!isAnonymous && fullUser?.id) || null;
user.Email = fullUser?.email || null;
user.Name = fullUser?.name || null;
// If viewed from a websocket, collect any link parameters included.
// TODO: could also get this from rest api access, just via a different route.
user.LinkKey = linkParameters;
// Include origin info if accessed via the rest api.
// TODO: could also get this for websocket access, just via a different route.
user.Origin = docSession.req?.get('origin') || null;
user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`;
user.IsLoggedIn = !isAnonymous;
user.UserRef = fullUser?.ref || null; // Empty string should be treated as null.
if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) {
// It is important to signal that the doc is in an unexpected state,
// and prevent it opening.
throw this._ruler.ruleCollection.ruleError;
}
for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {
if (clause.name in user) {
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);
continue;
}
if (attrs.rows[clause.name]) {
user[clause.name] = attrs.rows[clause.name];
continue;
}
let rec = new EmptyRecordView();
let rows: TableDataAction|undefined;
try {
// Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.
// TODO: add indexes to db.
rows = await this._fetchQueryFromDB({
tableId: clause.tableId,
filters: { [clause.lookupColId]: [get(user, clause.charId)] }
});
} catch (e) {
log.warn(`User attribute ${clause.name} failed`, e);
}
if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); }
user[clause.name] = rec;
attrs.rows[clause.name] = rec;
}
return user;
}
/**
* Get the "View As" user specified in link parameters.
* If aclAsUserId is set, we get the user with the specified id.
@@ -2583,7 +2581,7 @@ export class GranularAccess implements GranularAccessForBundle {
/**
* Tests if the user can modify cell's data.
*/
private async _canApplyCellActions(currentUser: UserInfo, userIsOwner: boolean) {
private async _canApplyCellActions(currentUser: User, userIsOwner: boolean) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
@@ -2654,7 +2652,7 @@ export class Ruler {
}
export interface RulerOwner {
getUser(docSession: OptDocSession): Promise<UserInfo>;
getUser(docSession: OptDocSession): Promise<User>;
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
}
@@ -2688,36 +2686,6 @@ interface ActionCursor {
// access control state.
}
/**
* A row-like view of TableDataAction, which is columnar in nature. If index value
* is undefined, acts as an EmptyRecordRow.
*/
export class RecordView implements InfoView {
public constructor(public data: TableDataAction, public index: number|undefined) {
}
public get(colId: string): CellValue {
if (this.index === undefined) { return null; }
if (colId === 'id') {
return this.data[2][this.index];
}
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} = {};
for (const key of Object.keys(this.data[3])) {
results[key] = this.data[3][key]?.[this.index];
}
return results;
}
}
/**
* A read-write view of a DataAction, for use in censorship.
*/
@@ -3222,47 +3190,6 @@ export function filterColValues(action: DataAction,
return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)];
}
/**
* Information about a user, including any user attributes.
*
* Serializes into a more compact JSON form that excludes full
* row data, only keeping user info and table/row ids for any
* user attributes.
*
* See `user.py` for the sandbox equivalent that deserializes objects of this class.
*/
export class User implements UserInfo {
public Name: string | null = null;
public UserID: number | null = null;
public Access: Role | null = null;
public Origin: string | null = null;
public LinkKey: Record<string, string | undefined> = {};
public Email: string | null = null;
public SessionID: string | null = null;
public UserRef: string | null = null;
public ShareRef: number | null = null;
[attribute: string]: any;
constructor(_info: Record<string, unknown> = {}) {
Object.assign(this, _info);
}
public toJSON() {
const results: {[key: string]: any} = {};
for (const [key, value] of Object.entries(this)) {
if (value instanceof RecordView) {
// Only include the table id and first matching row id.
results[key] = [getTableId(value.data), value.get('id')];
} else if (value instanceof EmptyRecordView) {
results[key] = null;
} else {
results[key] = value;
}
}
return results;
}
}
export function validTableIdString(tableId: any): string {
if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); }
return tableId;

View File

@@ -3,8 +3,9 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { RuleSet, UserInfo } from 'app/common/GranularAccessClause';
import { RuleSet } from 'app/common/GranularAccessClause';
import { PredicateFormulaInput } from 'app/common/PredicateFormula';
import { User } from 'app/common/User';
import { getSetMapValue } from 'app/common/gutil';
import log from 'app/server/lib/log';
import { mapValues } from 'lodash';
@@ -80,8 +81,8 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
return this._mergeFullAccess(tableAccess);
}
public getUser(): UserInfo {
return this._input.user!;
public getUser(): User {
return this._input.user! as User;
}
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;