mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user