(core) Add dropdown conditions

Summary:
Dropdown conditions let you specify a predicate formula that's used to filter
choices and references in their respective autocomplete dropdown menus.

Test Plan: Python and browser tests (WIP).

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4235
This commit is contained in:
George Gevoian
2024-04-26 16:34:16 -04:00
parent 34c85757f1
commit 3112433a58
86 changed files with 4221 additions and 1060 deletions

View File

@@ -1,109 +0,0 @@
/**
* Representation and compilation of ACL formulas.
*
* An example of an ACL formula is: "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
* These formulas are parsed in Python into a tree with nodes of the form [NODE_TYPE, ...args].
* See sandbox/grist/acl_formula.py for details.
*
* This modules includes typings for the nodes, and compileAclFormula() function that turns such a
* tree into an actual boolean function.
*/
import {CellValue} from 'app/common/DocActions';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {AclMatchFunc, AclMatchInput, ParsedAclFormula} from 'app/common/GranularAccessClause';
import {decodeObject} from "app/plugin/objtypes";
import constant = require('lodash/constant');
const GRIST_CONSTANTS: Record<string, string> = {
EDITOR: 'editors',
OWNER: 'owners',
VIEWER: 'viewers',
};
/**
* Compile a parsed ACL formula into an actual function that can evaluate a request.
*/
export function compileAclFormula(parsedAclFormula: ParsedAclFormula): AclMatchFunc {
const compiled = _compileNode(parsedAclFormula);
return (input) => Boolean(compiled(input));
}
/**
* Type for intermediate functions, which may return values other than booleans.
*/
type AclEvalFunc = (input: AclMatchInput) => any;
/**
* Compile a single node of the parsed formula tree.
*/
function _compileNode(parsedAclFormula: ParsedAclFormula): AclEvalFunc {
const rawArgs = parsedAclFormula.slice(1);
const args = rawArgs as ParsedAclFormula[];
switch (parsedAclFormula[0]) {
case 'And': { const parts = args.map(_compileNode); return (input) => parts.every(p => p(input)); }
case 'Or': { const parts = args.map(_compileNode); return (input) => parts.some(p => p(input)); }
case 'Add': return _compileAndCombine(args, ([a, b]) => a + b);
case 'Sub': return _compileAndCombine(args, ([a, b]) => a - b);
case 'Mult': return _compileAndCombine(args, ([a, b]) => a * b);
case 'Div': return _compileAndCombine(args, ([a, b]) => a / b);
case 'Mod': return _compileAndCombine(args, ([a, b]) => a % b);
case 'Not': return _compileAndCombine(args, ([a]) => !a);
case 'Eq': return _compileAndCombine(args, ([a, b]) => a === b);
case 'NotEq': return _compileAndCombine(args, ([a, b]) => a !== b);
case 'Lt': return _compileAndCombine(args, ([a, b]) => a < b);
case 'LtE': return _compileAndCombine(args, ([a, b]) => a <= b);
case 'Gt': return _compileAndCombine(args, ([a, b]) => a > b);
case 'GtE': return _compileAndCombine(args, ([a, b]) => a >= b);
case 'Is': return _compileAndCombine(args, ([a, b]) => a === b);
case 'IsNot': return _compileAndCombine(args, ([a, b]) => a !== b);
case 'In': return _compileAndCombine(args, ([a, b]) => Boolean(b?.includes(a)));
case 'NotIn': return _compileAndCombine(args, ([a, b]) => !b?.includes(a));
case 'List': return _compileAndCombine(args, (values) => values);
case 'Const': return constant(parsedAclFormula[1] as CellValue);
case 'Name': {
const name = rawArgs[0] as keyof AclMatchInput;
if (GRIST_CONSTANTS[name]) { return constant(GRIST_CONSTANTS[name]); }
if (!['user', 'rec', 'newRec'].includes(name)) {
throw new Error(`Unknown variable '${name}'`);
}
return (input) => input[name];
}
case 'Attr': {
const attrName = rawArgs[1] as string;
return _compileAndCombine([args[0]], ([value]) => getAttr(value, attrName, args[0]));
}
case 'Comment': return _compileNode(args[0]);
}
throw new Error(`Unknown node type '${parsedAclFormula[0]}'`);
}
function describeNode(node: ParsedAclFormula): string {
if (node[0] === 'Name') {
return node[1] as string;
} else if (node[0] === 'Attr') {
return describeNode(node[1] as ParsedAclFormula) + '.' + (node[2] as string);
} else {
return 'value';
}
}
function getAttr(value: any, attrName: string, valueNode: ParsedAclFormula): any {
if (value == null) {
if (valueNode[0] === 'Name' && (valueNode[1] === 'rec' || valueNode[1] === 'newRec')) {
// This code is recognized by GranularAccess to know when a rule is row-specific.
throw new ErrorWithCode('NEED_ROW_DATA', `Missing row data '${valueNode[1]}'`);
}
throw new Error(`No value for '${describeNode(valueNode)}'`);
}
return (typeof value.get === 'function' ? decodeObject(value.get(attrName)) : // InfoView
value[attrName]);
}
/**
* Helper for operators: compile a list of nodes, then when evaluating, evaluate them all and
* combine the array of results using the given combine() function.
*/
function _compileAndCombine(args: ParsedAclFormula[], combine: (values: any[]) => any): AclEvalFunc {
const compiled = args.map(_compileNode);
return (input: AclMatchInput) => combine(compiled.map(c => c(input)));
}

View File

@@ -64,12 +64,16 @@ import {
} from 'app/common/DocUsage';
import {normalizeEmail} from 'app/common/emails';
import {Product} from 'app/common/Features';
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes';
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse, timeoutReached} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {Interval} from 'app/common/Interval';
import {
compilePredicateFormula,
getPredicateFormulaProperties,
PredicateFormulaProperties,
} from 'app/common/PredicateFormula';
import * as roles from 'app/common/roles';
import {schema, SCHEMA_VERSION} from 'app/common/schema';
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
@@ -84,7 +88,6 @@ import {Share} from 'app/gen-server/entity/Share';
import {RecordWithStringId} from 'app/plugin/DocApiTypes';
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance';
import {AssistanceContext} from 'app/common/AssistancePrompts';
import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer';
@@ -1289,7 +1292,11 @@ export class ActiveDoc extends EventEmitter {
}
public async autocomplete(
docSession: DocSession, txt: string, tableId: string, columnId: string, rowId: UIRowId
docSession: DocSession,
txt: string,
tableId: string,
columnId: string,
rowId: UIRowId | null
): Promise<ISuggestionWithValue[]> {
// Autocompletion can leak names of tables and columns.
if (!await this._granularAccess.canScanData(docSession)) { return []; }
@@ -1479,16 +1486,16 @@ export class ActiveDoc extends EventEmitter {
/**
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
*/
public async checkAclFormula(docSession: DocSession, text: string): Promise<FormulaProperties> {
public async checkAclFormula(docSession: DocSession, text: string): Promise<PredicateFormulaProperties> {
// Checks can leak names of tables and columns.
if (await this._granularAccess.hasNuancedAccess(docSession)) { return {}; }
await this.waitForInitialization();
try {
const parsedAclFormula = await this._pyCall('parse_acl_formula', text);
compileAclFormula(parsedAclFormula);
const parsedAclFormula = await this._pyCall('parse_predicate_formula', text);
compilePredicateFormula(parsedAclFormula);
// TODO We also need to check the validity of attributes, and of tables and columns
// mentioned in resources and userAttribute rules.
return getFormulaProperties(parsedAclFormula);
return getPredicateFormulaProperties(parsedAclFormula);
} catch (e) {
e.message = e.message?.replace('[Sandbox] ', '');
throw e;

View File

@@ -26,16 +26,15 @@ 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 { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
import { UserInfo } from 'app/common/GranularAccessClause';
import { InfoEditor, InfoView, UserInfo } 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 { MetaRowRecord, SingleCell } from 'app/common/TableData';
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 { GristObjCode } from 'app/plugin/GristData';
import { compileAclFormula } from 'app/server/lib/ACLFormula';
import { DocClients } from 'app/server/lib/DocClients';
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,
getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession';
@@ -344,7 +343,7 @@ export class GranularAccess implements GranularAccessForBundle {
* Represent fields from the session in an input object for ACL rules.
* Just one field currently, "user".
*/
public async inputs(docSession: OptDocSession): Promise<AclMatchInput> {
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
return {
user: await this._getUser(docSession),
docId: this._docId
@@ -401,7 +400,7 @@ export class GranularAccess implements GranularAccessForBundle {
}
const rec = new RecordView(rows, 0);
if (!hasExceptionalAccess) {
const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec: rec};
const input: PredicateFormulaInput = {...await this.inputs(docSession), rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { fail(); }
@@ -560,7 +559,7 @@ export class GranularAccess implements GranularAccessForBundle {
// Use the post-actions data to process the rules collection, and throw error if that fails.
const ruleCollection = new ACLRuleCollection();
await ruleCollection.update(tmpDocData, {log, compile: compileAclFormula});
await ruleCollection.update(tmpDocData, {log, compile: compilePredicateFormula});
if (ruleCollection.ruleError) {
throw new ApiError(ruleCollection.ruleError.message, 400);
}
@@ -1664,7 +1663,7 @@ export class GranularAccess implements GranularAccessForBundle {
const rec = new RecordView(rowsRec, undefined);
const newRec = new RecordView(rowsNewRec, undefined);
const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec};
const input: PredicateFormulaInput = {...await this.inputs(docSession), rec, newRec};
const [, tableId, , colValues] = action;
let filteredColValues: ColValues | BulkColValues | undefined | null = null;
@@ -1746,7 +1745,7 @@ export class GranularAccess implements GranularAccessForBundle {
colId?: string): Promise<number[]> {
const ruler = await this._getRuler(cursor);
const rec = new RecordView(data, undefined);
const input: AclMatchInput = {...await this.inputs(cursor.docSession), rec};
const input: PredicateFormulaInput = {...await this.inputs(cursor.docSession), rec};
const [, tableId, rowIds] = data;
const toRemove: number[] = [];
@@ -2561,7 +2560,7 @@ export class GranularAccess implements GranularAccessForBundle {
}
}
const rec = rows ? new RecordView(rows, 0) : undefined;
const input: AclMatchInput = {...inputs, rec, newRec: rec};
const input: PredicateFormulaInput = {...inputs, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return false; }
@@ -2635,7 +2634,11 @@ export class Ruler {
* Update granular access from DocData.
*/
public async update(docData: DocData) {
await this.ruleCollection.update(docData, {log, compile: compileAclFormula, enrichRulesForImplementation: true});
await this.ruleCollection.update(docData, {
log,
compile: compilePredicateFormula,
enrichRulesForImplementation: true,
});
// Also clear the per-docSession cache of rule evaluations.
this.clearCache();
@@ -2652,7 +2655,7 @@ export class Ruler {
export interface RulerOwner {
getUser(docSession: OptDocSession): Promise<UserInfo>;
inputs(docSession: OptDocSession): Promise<AclMatchInput>;
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
}
/**
@@ -2762,11 +2765,6 @@ class RecordEditor implements InfoEditor {
}
}
class EmptyRecordView implements InfoView {
public get(colId: string): CellValue { return null; }
public toJSON() { return {}; }
}
/**
* Cache information about user attributes.
*/
@@ -2840,7 +2838,7 @@ class CellAccessHelper {
private _tableAccess: Map<string, boolean> = new Map();
private _rowPermInfo: Map<string, Map<number, PermissionInfo>> = new Map();
private _rows: Map<string, TableDataAction> = new Map();
private _inputs!: AclMatchInput;
private _inputs!: PredicateFormulaInput;
constructor(
private _granular: GranularAccess,
@@ -2864,7 +2862,7 @@ class CellAccessHelper {
for(const [idx, rowId] of rows[2].entries()) {
if (rowIds.has(rowId) === false) { continue; }
const rec = new RecordView(rows, idx);
const input: AclMatchInput = {...this._inputs, rec, newRec: rec};
const input: PredicateFormulaInput = {...this._inputs, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
if (!this._rowPermInfo.has(tableId)) {
this._rowPermInfo.set(tableId, new Map());

View File

@@ -3,7 +3,8 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { AclMatchInput, RuleSet, UserInfo } from 'app/common/GranularAccessClause';
import { RuleSet, UserInfo } from 'app/common/GranularAccessClause';
import { PredicateFormulaInput } from 'app/common/PredicateFormula';
import { getSetMapValue } from 'app/common/gutil';
import log from 'app/server/lib/log';
import { mapValues } from 'lodash';
@@ -59,7 +60,7 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
// Construct a RuleInfo for a particular input, which is a combination of user and
// optionally a record.
constructor(protected _acls: ACLRuleCollection, protected _input: AclMatchInput) {}
constructor(protected _acls: ACLRuleCollection, protected _input: PredicateFormulaInput) {}
public getColumnAspect(tableId: string, colId: string): MixedT {
const ruleSet: RuleSet|undefined = this._acls.getColumnRuleSet(tableId, colId);
@@ -80,7 +81,7 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
}
public getUser(): UserInfo {
return this._input.user;
return this._input.user!;
}
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
@@ -205,7 +206,7 @@ export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermission
* included, the result may include permission values like 'allowSome', 'denySome', or 'mixed' (for
* rules with memo).
*/
function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermissionSet {
function evaluateRule(ruleSet: RuleSet, input: PredicateFormulaInput): PartialPermissionSet {
let pset: PartialPermissionSet = emptyPermissionSet();
for (const rule of ruleSet.body) {
try {
@@ -268,7 +269,7 @@ function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermission
* If a rule has a memo, and passes, add that memo for all permissions it denies.
* If a rule has a memo, and fails, add that memo for all permissions it allows.
*/
function extractMemos(ruleSet: RuleSet, input: AclMatchInput): MemoSet {
function extractMemos(ruleSet: RuleSet, input: PredicateFormulaInput): MemoSet {
const pset = emptyMemoSet();
for (const rule of ruleSet.body) {
try {