diff --git a/app/client/ui/AccessRules.ts b/app/client/ui/AccessRules.ts index c455cb92..e1f51b4c 100644 --- a/app/client/ui/AccessRules.ts +++ b/app/client/ui/AccessRules.ts @@ -8,8 +8,8 @@ import {primaryButton} from 'app/client/ui2018/buttons'; import {colors} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuItem, select} from 'app/client/ui2018/menus'; -import {decodeClause, GranularAccessDocClause, serializeClause} from 'app/common/GranularAccessClause'; -import {arrayRepeat, setDifference} from 'app/common/gutil'; +import {readAclRules} from 'app/common/GranularAccessClause'; +import {setDifference} from 'app/common/gutil'; import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs'; import isEqual = require('lodash/isEqual'); @@ -18,19 +18,22 @@ interface AclState { ownerOnlyStructure: boolean; } +const MATCH_NON_OWNER = 'user.Access != "owners"'; + function buildAclState(gristDoc: GristDoc): AclState { + const {ruleSets} = readAclRules(gristDoc.docData, {log: console}); + console.log("FOUND RULE SETS", ruleSets); + const ownerOnlyTableIds = new Set(); let ownerOnlyStructure = false; - const tableData = gristDoc.docModel.aclResources.tableData; - for (const res of tableData.getRecords()) { - const code = String(res.colIds); - const clause = decodeClause(code); - if (clause) { - if (clause.kind === 'doc') { + for (const ruleSet of ruleSets) { + if (ruleSet.tableId === '*' && ruleSet.colIds === '*') { + if (ruleSet.body.find(p => p.aclFormula === MATCH_NON_OWNER && p.permissionsText === '-S')) { ownerOnlyStructure = true; } - if (clause.kind === 'table' && clause.tableId) { - ownerOnlyTableIds.add(clause.tableId); + } else if (ruleSet.tableId !== '*' && ruleSet.colIds === '*') { + if (ruleSet.body.find(p => p.aclFormula === MATCH_NON_OWNER && p.permissionsText === 'none')) { + ownerOnlyTableIds.add(ruleSet.tableId); } } } @@ -63,43 +66,39 @@ export class AccessRules extends Disposable { // changed by other users), and apply changes, if any, relative to that. const latestState = buildAclState(this._gristDoc); const currentState = this._currentState.get(); - const tableData = this._gristDoc.docModel.aclResources.tableData; - await tableData.docData.bundleActions('Update Access Rules', async () => { + const docData = this._gristDoc.docData; + const resourcesTable = docData.getTable('_grist_ACLResources')!; + const rulesTable = docData.getTable('_grist_ACLRules')!; + await this._gristDoc.docData.bundleActions('Update Access Rules', async () => { // If ownerOnlyStructure flag changed, add or remove the relevant resource record. - if (currentState.ownerOnlyStructure !== latestState.ownerOnlyStructure) { - const clause: GranularAccessDocClause = { - kind: 'doc', - match: { kind: 'const', charId: 'Access', value: 'owners' }, - }; - const colIds = serializeClause(clause); - if (currentState.ownerOnlyStructure) { - await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds}]); - } else { - const rowId = tableData.findMatchingRowId({tableId: '', colIds}); - if (rowId) { - await this._gristDoc.docModel.aclResources.sendTableAction(['RemoveRecord', rowId]); - } - } + const defaultResource = resourcesTable.findMatchingRowId({tableId: '*', colIds: '*'}) || + await resourcesTable.sendTableAction(['AddRecord', null, {tableId: '*', colIds: '*'}]); + const ruleObj = {resource: defaultResource, aclFormula: MATCH_NON_OWNER, permissionsText: '-S'}; + const ruleRowId = rulesTable.findMatchingRowId(ruleObj); + if (currentState.ownerOnlyStructure && !ruleRowId) { + await rulesTable.sendTableAction(['AddRecord', null, ruleObj]); + } else if (!currentState.ownerOnlyStructure && ruleRowId) { + await rulesTable.sendTableAction(['RemoveRecord', ruleRowId]); } // Handle tables added to ownerOnlyTableIds. const tablesAdded = setDifference(currentState.ownerOnlyTableIds, latestState.ownerOnlyTableIds); - if (tablesAdded.size) { - await tableData.sendTableAction(['BulkAddRecord', arrayRepeat(tablesAdded.size, null), { - tableId: [...tablesAdded], - colIds: [...tablesAdded].map(tableId => serializeClause({ - kind: 'table', - tableId, - match: { kind: 'const', charId: 'Access', value: 'owners' }, - })), - }]); + for (const tableId of tablesAdded) { + const resource = resourcesTable.findMatchingRowId({tableId, colIds: '*'}) || + await resourcesTable.sendTableAction(['AddRecord', null, {tableId, colIds: '*'}]); + await rulesTable.sendTableAction( + ['AddRecord', null, {resource, aclFormula: MATCH_NON_OWNER, permissionsText: 'none'}]); } - // Handle table removed from ownerOnlyTableIds. const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds); - if (tablesRemoved.size) { - const rowIds = Array.from(tablesRemoved, t => tableData.findRow('tableId', t)).filter(r => r); - await tableData.sendTableAction(['BulkRemoveRecord', rowIds]); + for (const tableId of tablesRemoved) { + const resource = resourcesTable.findMatchingRowId({tableId, colIds: '*'}); + if (resource) { + const rowId = rulesTable.findMatchingRowId({resource, aclFormula: MATCH_NON_OWNER, permissionsText: 'none'}); + if (rowId) { + await rulesTable.sendTableAction(['RemoveRecord', rowId]); + } + } } }); } diff --git a/app/common/ACLPermissions.ts b/app/common/ACLPermissions.ts new file mode 100644 index 00000000..96e8f234 --- /dev/null +++ b/app/common/ACLPermissions.ts @@ -0,0 +1,163 @@ +/** + * Internal and DB representation of permission bits. These may be set to on, off, or omitted. + * + * In DB, permission sets are represented as strings of the form '[+][-]' where + * is a string of C,R,U,D,S characters, each appearing at most once; or the special values 'all' + * or 'none'. Note that empty string is also valid, and corresponds to the PermissionSet {}. + */ +// tslint:disable:no-namespace + +import fromPairs = require('lodash/fromPairs'); +import mapValues = require('lodash/mapValues'); + + +// A PermissionValue is the result of evaluating rules. It provides a definitive answer. +export type PermissionValue = "allow" | "deny"; + +// A MixedPermissionValue is the result of evaluating rules without a record. If some rules +// require a record, and some records may be allowed and some denied, the result is "mixed". +export type MixedPermissionValue = PermissionValue | "mixed"; + +// Similar to MixedPermissionValue, but if permission for a table depend on columns and NOT on +// rows, the result is "mixedColumns" rather than "mixed", which allows some optimizations. +export type TablePermissionValue = MixedPermissionValue | "mixedColumns"; + +// PartialPermissionValue is only used transiently while evaluating rules without a record. +export type PartialPermissionValue = PermissionValue | "allowSome" | "denySome" | "mixed" | ""; + +/** + * Internal representation of a set of permission bits. + */ +export interface PermissionSet { + read: T; + create: T; + update: T; + delete: T; + schemaEdit: T; +} + +// Some shorter type aliases. +export type PartialPermissionSet = PermissionSet; +export type MixedPermissionSet = PermissionSet; +export type TablePermissionSet = PermissionSet; + +const PERMISSION_BITS: {[letter: string]: keyof PermissionSet} = { + R: 'read', + C: 'create', + U: 'update', + D: 'delete', + S: 'schemaEdit', +}; + +const ALL_PERMISSION_BITS = "CRUDS"; + +export const ALL_PERMISSION_PROPS: Array = + Array.from(ALL_PERMISSION_BITS, ch => PERMISSION_BITS[ch]); + +const ALIASES: {[key: string]: string} = { + all: '+CRUDS', + none: '-CRUDS', +}; +const REVERSE_ALIASES = fromPairs(Object.entries(ALIASES).map(([alias, value]) => [value, alias])); + +// Comes in useful for initializing unset PermissionSets. +export function emptyPermissionSet(): PartialPermissionSet { + return {read: "", create: "", update: "", delete: "", schemaEdit: ""}; +} + +/** + * Convert a short string representation to internal. + */ +export function parsePermissions(permissionsText: string): PartialPermissionSet { + if (ALIASES.hasOwnProperty(permissionsText)) { + permissionsText = ALIASES[permissionsText]; + } + const pset: PartialPermissionSet = emptyPermissionSet(); + let value: PartialPermissionValue = ""; + for (const ch of permissionsText) { + if (ch === '+') { + value = "allow"; + } else if (ch === '-') { + value = "deny"; + } else if (!PERMISSION_BITS.hasOwnProperty(ch) || value === "") { + throw new Error(`Invalid permissions specification ${JSON.stringify(permissionsText)}`); + } else { + const prop = PERMISSION_BITS[ch]; + pset[prop] = value; + } + } + return pset; +} + +/** + * Convert an internal representation of permission bits to a short string. Note that there should + * be no values other then "allow" and "deny", since anything else will NOT be included. + */ +export function permissionSetToText(permissionSet: Partial): string { + let add = ""; + let remove = ""; + for (const ch of ALL_PERMISSION_BITS) { + const prop: keyof PermissionSet = PERMISSION_BITS[ch]; + const value = permissionSet[prop]; + if (value === "allow") { + add += ch; + } else if (value === "deny") { + remove += ch; + } + } + const perm = (add ? "+" + add : "") + (remove ? "-" + remove : ""); + return REVERSE_ALIASES[perm] || perm; +} + + +/** + * Replace allow/deny with allowSome/denySome to indicate dependence on rows. + */ +export function makePartialPermissions(pset: PartialPermissionSet): PartialPermissionSet { + return mapValues(pset, val => (val === "allow" ? "allowSome" : (val === "deny" ? "denySome" : val))); +} + +/** + * Combine PartialPermissions. Earlier rules win. Note that allowAll|denyAll|mixed are final + * results (further permissions can't change them), but allowSome|denySome may be changed by + * further rules into either allowAll|denyAll or mixed. + * + * Note that this logic satisfies associative property: (a + b) + c == a + (b + c). + */ +function combinePartialPermission(a: PartialPermissionValue, b: PartialPermissionValue): PartialPermissionValue { + if (!a) { return b; } + if (!b) { return a; } + // If the first is uncertain, the second may keep it unchanged, or make certain, or finalize as mixed. + if (a === 'allowSome') { return (b === 'allowSome' || b === 'allow') ? b : 'mixed'; } + if (a === 'denySome') { return (b === 'denySome' || b === 'deny') ? b : 'mixed'; } + // If the first is certain, it's not affected by the second. + return a; +} + +/** + * Combine PartialPermissionSets. + */ +export function mergePartialPermissions(a: PartialPermissionSet, b: PartialPermissionSet): PartialPermissionSet { + return mergePermissions([a, b], ([_a, _b]) => combinePartialPermission(_a, _b)); +} + +/** + * Merge a list of PermissionSets by combining individual bits. + */ +export function mergePermissions(psets: Array>, combine: (bits: T[]) => U +): PermissionSet { + const result: Partial> = {}; + for (const prop of ALL_PERMISSION_PROPS) { + result[prop] = combine(psets.map(p => p[prop])); + } + return result as PermissionSet; +} + +/** + * Convert a PartialPermissionSet to MixedPermissionSet by replacing any remaining uncertain bits + * with 'denyAll'. When rules are properly combined it should never be needed because the + * hard-coded fallback rules should finalize all bits. + */ +export function toMixed(pset: PartialPermissionSet): MixedPermissionSet { + return mergePermissions([pset], ([bit]) => (bit === 'allow' || bit === 'mixed' ? bit : 'deny')); +} diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index 3ea65048..cb184aa0 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -1,130 +1,139 @@ -import { safeJsonParse } from 'app/common/gutil'; -import { CellValue } from 'app/plugin/GristData'; +import { emptyPermissionSet, parsePermissions, PartialPermissionSet } from 'app/common/ACLPermissions'; +import { ILogger } from 'app/common/BaseAPI'; +import { CellValue, RowRecord } from 'app/common/DocActions'; +import { DocData } from 'app/common/DocData'; +import { getSetMapValue } from 'app/common/gutil'; +import sortBy = require('lodash/sortBy'); + +export interface RuleSet { + tableId: '*' | string; + colIds: '*' | string[]; + body: RulePart[]; + defaultPermissions: PartialPermissionSet; +} + +export interface RulePart { + aclFormula: string; + permissions: PartialPermissionSet; + permissionsText: string; // The text version of PermissionSet, as stored. + + // Compiled version of aclFormula. + matchFunc?: AclMatchFunc; +} + +// Light wrapper around characteristics or records. +export interface InfoView { + get(key: string): CellValue; + toJSON(): {[key: string]: any}; +} + +// Represents user info, which may include properties which are themselves RowRecords. +export type UserInfo = Record; /** - * All possible access clauses. In future the clauses will become more generalized. - * The consequences of clauses are currently combined in a naive and ad-hoc way, - * this will need systematizing. + * Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean. */ -export type GranularAccessClause = - GranularAccessDocClause | - GranularAccessTableClause | - GranularAccessRowClause | - GranularAccessColumnClause | - GranularAccessCharacteristicsClause; - -/** - * A clause that forbids anyone but owners from modifying the document structure. - */ -export interface GranularAccessDocClause { - kind: 'doc'; - match: MatchSpec; +export interface AclMatchInput { + user: UserInfo; + rec?: InfoView; + newRec?: InfoView; } /** - * A clause to control access to a specific table. + * The actual boolean function that can evaluate a request. The result of compiling ParsedAclFormula. */ -export interface GranularAccessTableClause { - kind: 'table'; - tableId: string; - match: MatchSpec; +export type AclMatchFunc = (input: AclMatchInput) => boolean; + +/** + * Representation of a parsed ACL formula. + */ +export type ParsedAclFormula = [string, ...Array]; + +export interface UserAttributeRule { + name: string; // Should be unique among UserAttributeRules. + tableId: string; // Table in which to look up an existing attribute. + lookupColId: string; // Column in tableId in which to do the lookup. + charId: string; // Attribute to look up, possibly a path. E.g. 'Email' or 'office.city'. +} + +export interface ReadAclOptions { + log: ILogger; // For logging warnings during rule processing. + compile?: (parsed: ParsedAclFormula) => AclMatchFunc; +} + +export interface ReadAclResults { + ruleSets: RuleSet[]; + userAttributes: UserAttributeRule[]; } /** - * A clause to control access to rows within a specific table. - * If "scope" is provided, this rule is simply ignored if the scope does not match - * the user. + * Parse all ACL rules in the document from DocData into a list of RuleSets and of + * UserAttributeRules. This is used by both client-side code and server-side. */ -export interface GranularAccessRowClause { - kind: 'row'; - tableId: string; - match: MatchSpec; - scope?: MatchSpec; -} +export function readAclRules(docData: DocData, {log, compile}: ReadAclOptions): ReadAclResults { + const resourcesTable = docData.getTable('_grist_ACLResources')!; + const rulesTable = docData.getTable('_grist_ACLRules')!; -/** - * A clause to control access to columns within a specific table. - */ -export interface GranularAccessColumnClause { - kind: 'column'; - tableId: string; - colIds: string[]; - match: MatchSpec; - onMatch?: AccessPermissionDelta; // permissions to apply if match succeeds - onFail?: AccessPermissionDelta; // permissions to apply if match fails -} + const ruleSets: RuleSet[] = []; + const userAttributes: UserAttributeRule[] = []; -/** - * A clause to make more information about the user/request available for access - * control decisions. - * - charId specifies a property of the user (e.g. Access/Email/UserID/Name, or a - * property added by another clause) to use as a key. - * - We look for a matching record in the specified table, comparing the specified - * column with the charId property. Outcome is currently unspecified if there are - * multiple matches. - * - Compare using lower case for now (because of Email). Could generalize in future. - * - All fields from a matching record are added to the variables available for MatchSpecs. - */ -export interface GranularAccessCharacteristicsClause { - kind: 'character'; - tableId: string; - charId: string; // characteristic to look up - lookupColId: string; // column in which to look it up -} - -/** - * A sketch of permissions, intended as a placeholder. - */ -export type AccessPermission = 'read' | 'update' | 'create' | 'delete'; -export type AccessPermissions = 'all' | AccessPermission[]; -export interface AccessPermissionDelta { - allow?: AccessPermissions; // permit the named operations - allowOnly?: AccessPermissions; // permit the named operations, and forbid others - forbid?: AccessPermissions; // forbid the named operations -} - -// Type for expressing matches. -export type MatchSpec = ConstMatchSpec | TruthyMatchSpec | PairMatchSpec | NotMatchSpec; - -// Invert a match. -export interface NotMatchSpec { - kind: 'not'; - match: MatchSpec; -} - -// Compare property of user with a constant. -export interface ConstMatchSpec { - kind: 'const'; - charId: string; - value: CellValue; -} - -// Check if a table column is truthy. -export interface TruthyMatchSpec { - kind: 'truthy'; - colId: string; -} - -// Check if a property of user matches a table column. -export interface PairMatchSpec { - kind: 'pair'; - charId: string; - colId: string; -} - -// Convert a clause to a string. Trivial, but fluid currently. -export function serializeClause(clause: GranularAccessClause) { - return '~acl ' + JSON.stringify(clause); -} - -export function decodeClause(code: string): GranularAccessClause|null { - // TODO: be strict about format. But it isn't super-clear what to do with - // a document if access control gets corrupted. Maybe go into an emergency - // mode where only owners have access, and they have unrestricted access? - // Also, format should be plain JSON once no longer stored in a random - // reused column. - if (code.startsWith('~acl ')) { - return safeJsonParse(code.slice(5), null); + // Group rules by resource first, ordering by rulePos. Each group will become a RuleSet. + const rulesByResource = new Map(); + for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) { + getSetMapValue(rulesByResource, ruleRecord.resource, () => []).push(ruleRecord); } - return null; + + for (const [resourceId, rules] of rulesByResource.entries()) { + const resourceRec = resourcesTable.getRecord(resourceId as number); + if (!resourceRec) { + log.error(`ACLRule ${rules[0].id} ignored; refers to an invalid ACLResource ${resourceId}`); + continue; + } + if (!resourceRec.tableId || !resourceRec.colIds) { + // This should only be the case for the old-style default rule/resource, which we + // intentionally ignore and skip. + continue; + } + const tableId = resourceRec.tableId as string; + const colIds = resourceRec.colIds === '*' ? '*' : (resourceRec.colIds as string).split(','); + + let defaultPermissions: PartialPermissionSet|undefined; + const body: RulePart[] = []; + for (const rule of rules) { + if (rule.userAttributes) { + if (tableId !== '*' || colIds !== '*') { + log.warn(`ACLRule ${rule.id} ignored; user attributes must be on the default resource`); + continue; + } + const parsed = JSON.parse(String(rule.userAttributes)); + // TODO: could perhaps use ts-interface-checker here. + if (!(parsed && typeof parsed === 'object' && + [parsed.name, parsed.tableId, parsed.lookupColId, parsed.charId] + .every(p => p && typeof p === 'string'))) { + throw new Error(`Invalid user attribute rule: ${parsed}`); + } + userAttributes.push(parsed as UserAttributeRule); + } else if (rule.aclFormula === '') { + defaultPermissions = parsePermissions(String(rule.permissionsText)); + } else if (defaultPermissions) { + log.warn(`ACLRule ${rule.id} ignored because listed after default rule`); + } else if (!rule.aclFormulaParsed) { + log.warn(`ACLRule ${rule.id} ignored because missing its parsed formula`); + } else { + body.push({ + aclFormula: String(rule.aclFormula), + matchFunc: compile?.(JSON.parse(String(rule.aclFormulaParsed))), + permissions: parsePermissions(String(rule.permissionsText)), + permissionsText: String(rule.permissionsText), + }); + } + } + if (!defaultPermissions) { + // Empty permissions allow falling through to the doc-default resource. + defaultPermissions = emptyPermissionSet(); + } + const ruleSet: RuleSet = {tableId, colIds, body, defaultPermissions}; + ruleSets.push(ruleSet); + } + return {ruleSets, userAttributes}; } diff --git a/app/common/schema.ts b/app/common/schema.ts index 60287a24..261597ed 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -1,4 +1,4 @@ -/*** THIS FILE IS AUTO-GENERATED BY sandbox/gen_js_schema.py ***/ +/*** THIS FILE IS AUTO-GENERATED BY core/sandbox/gen_js_schema.py ***/ // tslint:disable:object-literal-key-quotes export const schema = { @@ -146,6 +146,10 @@ export const schema = { principals : "Text", aclFormula : "Text", aclColumn : "Ref:_grist_Tables_column", + aclFormulaParsed : "Text", + permissionsText : "Text", + rulePos : "PositionNumber", + userAttributes : "Text", }, "_grist_ACLResources": { @@ -313,6 +317,10 @@ export interface SchemaTypes { principals: string; aclFormula: string; aclColumn: number; + aclFormulaParsed: string; + permissionsText: string; + rulePos: number; + userAttributes: string; }; "_grist_ACLResources": { diff --git a/app/server/lib/ACLFormula.ts b/app/server/lib/ACLFormula.ts new file mode 100644 index 00000000..9ef235f0 --- /dev/null +++ b/app/server/lib/ACLFormula.ts @@ -0,0 +1,104 @@ +/** + * 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 constant = require('lodash/constant'); + +/** + * 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]) => 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 (!['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])); + } + } + 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)}'`); + } + const result = (typeof value.get === 'function' ? value.get(attrName) : // InfoView + value[attrName]); + if (result === undefined) { + throw new Error(`No attribute '${describeNode(valueNode)}.${attrName}'`); + } + return result; +} + +/** + * 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))); +} diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 704d7b55..7ca80ef4 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -33,7 +33,6 @@ import * as marshal from 'app/common/marshal'; import {Peer} from 'app/common/sharing'; import {UploadResult} from 'app/common/uploads'; import {DocReplacementOptions, DocState} from 'app/common/UserAPI'; -import {Permissions} from 'app/gen-server/lib/Permissions'; import {ParseOptions} from 'app/plugin/FileParserAPI'; import {GristDocAPI} from 'app/plugin/GristAPI'; import {Authorizer} from 'app/server/lib/Authorizer'; @@ -582,7 +581,7 @@ export class ActiveDoc extends EventEmitter { // If user does not have rights to access what this query is asking for, fail. const tableAccess = this._granularAccess.getTableAccess(docSession, query.tableId); - if (!(tableAccess.permission & Permissions.VIEW)) { // tslint:disable-line:no-bitwise + if (tableAccess.read === 'deny') { throw new Error('not authorized to read table'); } @@ -592,7 +591,7 @@ export class ActiveDoc extends EventEmitter { // Also, if row-level access is being controlled, we wait for formula columns // to be populated. const wantFull = waitForFormulas || query.tableId.startsWith('_grist_') || - tableAccess.rowPermissionFunctions.length > 0; + tableAccess.read === 'mixed'; const onDemand = this._onDemandActions.isOnDemand(query.tableId); this.logInfo(docSession, "fetchQuery(%s, %s) %s", docSession, JSON.stringify(query), onDemand ? "(onDemand)" : "(regular)"); @@ -614,9 +613,8 @@ export class ActiveDoc extends EventEmitter { } // If row-level access is being controlled, filter the data appropriately. // Likewise if column-level access is being controlled. - if (tableAccess.rowPermissionFunctions.length > 0 || - tableAccess.columnPermissions.size > 0) { - this._granularAccess.filterData(data!, tableAccess); + if (tableAccess.read !== 'allow') { + this._granularAccess.filterData(docSession, data!); } this.logInfo(docSession, "fetchQuery -> %d rows, cols: %s", data![2].length, Object.keys(data![3]).join(", ")); @@ -766,7 +764,12 @@ export class ActiveDoc extends EventEmitter { localActionBundle.calc.forEach(da => docData.receiveAction(da[1])); const docActions = getEnvContent(localActionBundle.stored); // TODO: call this update less indiscriminately! - await this._granularAccess.update(); + // Update ACLs only when rules are touched. A suggest for later is for GranularAccess to + // listen to docData's changes that affect relevant table, and toggle a dirty flag. The + // update() can then be called whenever ACLs are needed and are dirty. + if (localActionBundle.stored.some(da => (da[1][1] === '_grist_ACLRules'))) { + await this._granularAccess.update(); + } if (docActions.some(docAction => this._onDemandActions.isSchemaAction(docAction))) { const indexes = this._onDemandActions.getDesiredIndexes(); await this.docStorage.updateIndexes(indexes); diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index cde42594..4c902dc6 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -1,18 +1,25 @@ +import { MixedPermissionSet, PartialPermissionSet, TablePermissionSet } from 'app/common/ACLPermissions'; +import { makePartialPermissions, mergePartialPermissions, mergePermissions } from 'app/common/ACLPermissions'; +import { emptyPermissionSet, parsePermissions, toMixed } from 'app/common/ACLPermissions'; import { ActionGroup } from 'app/common/ActionGroup'; import { createEmptyActionSummary } from 'app/common/ActionSummary'; import { Query } from 'app/common/ActiveDocAPI'; -import { BulkColValues, CellValue, ColValues, DocAction, TableDataAction, UserAction } from 'app/common/DocActions'; +import { BulkColValues, CellValue, ColValues, DocAction } from 'app/common/DocActions'; +import { TableDataAction, UserAction } from 'app/common/DocActions'; import { DocData } from 'app/common/DocData'; import { ErrorWithCode } from 'app/common/ErrorWithCode'; -import { AccessPermissions, decodeClause, GranularAccessCharacteristicsClause, - GranularAccessClause, GranularAccessColumnClause, MatchSpec } from 'app/common/GranularAccessClause'; +import { AclMatchInput, InfoView } from 'app/common/GranularAccessClause'; +import { readAclRules, RuleSet, UserAttributeRule, UserInfo } from 'app/common/GranularAccessClause'; +import { getSetMapValue } from 'app/common/gutil'; import { canView } from 'app/common/roles'; -import { TableData } from 'app/common/TableData'; -import { Permissions } from 'app/gen-server/lib/Permissions'; +import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { getDocSessionAccess, getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession'; import * as log from 'app/server/lib/log'; -import pullAt = require('lodash/pullAt'); import cloneDeep = require('lodash/cloneDeep'); +import get = require('lodash/get'); +import pullAt = require('lodash/pullAt'); + +// tslint:disable:no-bitwise // Actions that may be allowed for a user with nuanced access to a document, depending // on what table they refer to. @@ -50,6 +57,24 @@ const SURPRISING_ACTIONS = new Set([ // Actions we'll allow unconditionally for now. const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']); +// This is the hard-coded default RuleSet that's added to any user-created default rule. +const DEFAULT_RULE_SET: RuleSet = { + tableId: '*', + colIds: '*', + body: [{ + aclFormula: "user.Role in ['editors', 'owners']", + matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)), + permissions: parsePermissions('all'), + permissionsText: 'all', + }, { + aclFormula: "user.Role in ['viewers']", + matchFunc: (input) => ['viewers'].includes(String(input.user.Access)), + permissions: parsePermissions('+R'), + permissionsText: 'none', + }], + defaultPermissions: parsePermissions('none'), +}; + /** * * Manage granular access to a document. This allows nuances other than the coarse @@ -58,30 +83,122 @@ const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']); * */ export class GranularAccess { - private _resources: TableData; - private _clauses = new Array(); + // In the absence of rules, some checks are skipped. For now this is important to maintain all + // existing behavior. TODO should make sure checking access against default rules is equivalent + // and efficient. + private _haveRules = false; + + // Map of tableId to list of column RuleSets (those with colIds other than '*') + private _columnRuleSets = new Map(); + + // Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId). + private _tableColumnMap = new Map(); + + // Map of tableId to the single default RuleSet for the table (colIds of '*') + private _tableRuleSets = new Map(); + + // The default RuleSet (tableId '*', colIds '*') + private _defaultRuleSet: RuleSet = DEFAULT_RULE_SET; + + // List of all tableIds mentioned in rules. + private _tableIds: string[] = []; + + // Maps name to the corresponding UserAttributeRule. + private _userAttributeRules = new Map(); + // Cache any tables that we need to look-up for access control decisions. // This is an unoptimized implementation that is adequate if the tables // are not large and don't change all that often. private _characteristicTables = new Map(); + // Cache of PermissionInfo associated with the given docSession. It's a WeakMap, so should allow + // both to be garbage-collected once docSession is no longer in use. + private _permissionInfoMap = new WeakMap(); + + public constructor(private _docData: DocData, private _fetchQuery: (query: Query) => Promise) { } + // Return the RuleSet for "tableId:colId", or undefined if there isn't one for this column. + public getColumnRuleSet(tableId: string, colId: string): RuleSet|undefined { + return this._tableColumnMap.get(`${tableId}:${colId}`); + } + + // Return all RuleSets for "tableId:", not including "tableId:*". + public getAllColumnRuleSets(tableId: string): RuleSet[] { + return this._columnRuleSets.get(tableId) || []; + } + + // Return the RuleSet for "tableId:*". + public getTableDefaultRuleSet(tableId: string): RuleSet|undefined { + return this._tableRuleSets.get(tableId); + } + + // Return the RuleSet for "*:*". + public getDocDefaultRuleSet(): RuleSet { + return this._defaultRuleSet; + } + + // Return the list of all tableId mentions in ACL rules. + public getAllTableIds(): string[] { + return this._tableIds; + } + /** * Update granular access from DocData. */ public async update() { - this._resources = this._docData.getTable('_grist_ACLResources')!; - this._clauses.length = 0; - for (const res of this._resources.getRecords()) { - const clause = decodeClause(String(res.colIds)); - if (clause) { this._clauses.push(clause); } + const {ruleSets, userAttributes} = readAclRules(this._docData, {log, compile: compileAclFormula}); + + // Build a map of user characteristics rules. + const userAttributeMap = new Map(); + for (const userAttr of userAttributes) { + userAttributeMap.set(userAttr.name, userAttr); } - if (this._clauses.length > 0) { - // TODO: optimize this. - await this._updateCharacteristicTables(); + + // Build maps of ACL rules. + const colRuleSets = new Map(); + const tableColMap = new Map(); + const tableRuleSets = new Map(); + let defaultRuleSet: RuleSet = DEFAULT_RULE_SET; + + this._haveRules = (ruleSets.length > 0); + for (const ruleSet of ruleSets) { + if (ruleSet.tableId === '*') { + if (ruleSet.colIds === '*') { + defaultRuleSet = { + ...ruleSet, + body: [...ruleSet.body, ...DEFAULT_RULE_SET.body], + defaultPermissions: DEFAULT_RULE_SET.defaultPermissions, + }; + } else { + // tableId of '*' cannot list particular columns. + throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`); + } + } else if (ruleSet.colIds === '*') { + if (tableRuleSets.has(ruleSet.tableId)) { + throw new Error(`Invalid duplicate default rule for ${ruleSet.tableId}`); + } + tableRuleSets.set(ruleSet.tableId, ruleSet); + } else { + getSetMapValue(colRuleSets, ruleSet.tableId, () => []).push(ruleSet); + for (const colId of ruleSet.colIds) { + tableColMap.set(`${ruleSet.tableId}:${colId}`, ruleSet); + } + } } + + // Update GranularAccess state. + this._columnRuleSets = colRuleSets; + this._tableColumnMap = tableColMap; + this._tableRuleSets = tableRuleSets; + this._defaultRuleSet = defaultRuleSet; + this._tableIds = [...new Set([...colRuleSets.keys(), ...tableRuleSets.keys()])]; + this._userAttributeRules = userAttributeMap; + // Also clear the per-docSession cache of rule evaluations. + this._permissionInfoMap = new WeakMap(); + // TODO: optimize this. + await this._updateCharacteristicTables(); } /** @@ -92,10 +209,11 @@ export class GranularAccess { } /** - * Check whether user has access to table. + * Check whether user has any access to table. */ public hasTableAccess(docSession: OptDocSession, tableId: string) { - return Boolean(this.getTableAccess(docSession, tableId).permission & Permissions.VIEW); + const pset = this.getTableAccess(docSession, tableId); + return pset.read !== 'deny'; } /** @@ -103,13 +221,14 @@ export class GranularAccess { */ public filterOutgoingDocActions(docSession: OptDocSession, docActions: DocAction[]): DocAction[] { return docActions.map(action => this.pruneOutgoingDocAction(docSession, action)) - .filter(docActions => docActions !== null) as DocAction[]; + .filter(_docActions => _docActions !== null) as DocAction[]; } /** * Filter an ActionGroup to be sent to a client. */ public filterActionGroup(docSession: OptDocSession, actionGroup: ActionGroup): ActionGroup { + // TODO This seems a mistake -- should this check be negated? if (!this.allowActionGroup(docSession, actionGroup)) { return actionGroup; } // For now, if there's any nuance at all, suppress the summary and description. // TODO: create an empty action summary, to be sure not to leak anything important. @@ -163,8 +282,8 @@ export class GranularAccess { // To allow editing, we'll need something that has access to full row, // e.g. data engine (and then an equivalent for ondemand tables), or // to fetch rows at this point. - if (tableAccess.rowPermissionFunctions.length > 0) { return false; } - return Boolean(tableAccess.permission & Permissions.VIEW); + // TODO We can now look properly at the create/update/delete/schemaEdit permissions in pset. + return tableAccess.read === 'allow'; } return false; } @@ -176,34 +295,36 @@ export class GranularAccess { */ public pruneOutgoingDocAction(docSession: OptDocSession, a: DocAction): DocAction|null { const tableId = a[1] as string; - const tableAccess = this.getTableAccess(docSession, tableId); - if (!(tableAccess.permission & Permissions.VIEW)) { return null; } - if (tableAccess.rowPermissionFunctions.length > 0) { + const permInfo = this._getAccess(docSession); + const tableAccess = permInfo.getTableAccess(tableId); + if (tableAccess.read === 'deny') { return null; } + if (tableAccess.read === 'allow') { return a; } + + if (tableAccess.read === 'mixed') { // For now, trigger a reload, since we don't have the // information we need to filter rows. Reloads would be very // annoying if user is working on something, but at least data // won't be stale. TODO: improve! throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); } - if (tableAccess.columnPermissions.size > 0) { - if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { - return a; - } else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || a[0] == 'UpdateRecord' || - a[0] === 'BulkUpdateRecord' || a[0] === 'ReplaceTableData' || a[0] === 'TableData') { - const na = cloneDeep(a); - this.filterColumns(na[3], tableAccess); - if (Object.keys(na[3]).length === 0) { return null; } - return na; - } else if (a[0] === 'AddColumn' || a[0] === 'RemoveColumn' || a[0] === 'RenameColumn' || - a[0] === 'ModifyColumn') { - const na = cloneDeep(a); - const perms = tableAccess.columnPermissions.get(na[2]); - if (perms && (perms.forbidden & Permissions.VIEW)) { return null; } - throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); - } else { - // Remaining cases of AddTable, RemoveTable, RenameTable should have - // been handled at the table level. - } + + if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { + return a; + } else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || a[0] === 'UpdateRecord' || + a[0] === 'BulkUpdateRecord' || a[0] === 'ReplaceTableData' || a[0] === 'TableData') { + const na = cloneDeep(a); + this._filterColumns(na[3], (colId) => permInfo.getColumnAccess(tableId, colId).read !== 'deny'); + if (Object.keys(na[3]).length === 0) { return null; } + return na; + } else if (a[0] === 'AddColumn' || a[0] === 'RemoveColumn' || a[0] === 'RenameColumn' || + a[0] === 'ModifyColumn') { + const na = cloneDeep(a); + const colId: string = na[2]; + if (permInfo.getColumnAccess(tableId, colId).read === 'deny') { return null; } + throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); + } else { + // Remaining cases of AddTable, RemoveTable, RenameTable should have + // been handled at the table level. } // TODO: handle access to changes in metadata (trigger a reload at least, if // all else fails). @@ -216,7 +337,7 @@ export class GranularAccess { * access is simple and without nuance. */ public hasNuancedAccess(docSession: OptDocSession): boolean { - if (this._clauses.length === 0) { return false; } + if (!this._haveRules) { return false; } return !this.hasFullAccess(docSession); } @@ -224,13 +345,8 @@ export class GranularAccess { * Check whether user can read everything in document. */ public canReadEverything(docSession: OptDocSession): boolean { - for (const tableId of this.getTablesInClauses()) { - const tableData = this.getTableAccess(docSession, tableId); - if (!(tableData.permission & Permissions.VIEW) || tableData.rowPermissionFunctions.length > 0 || tableData.columnPermissions.size > 0) { - return false; - } - } - return true; + const permInfo = this._getAccess(docSession); + return permInfo.getFullAccess().read === 'allow'; } /** @@ -279,19 +395,24 @@ export class GranularAccess { // Collect a list of censored columns (by " "). const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`; const censoredColumnCodes: Set = new Set(); - for (const tableId of this.getTablesInClauses()) { - const tableAccess = this.getTableAccess(docSession, tableId); + const permInfo = this._getAccess(docSession); + for (const tableId of this.getAllTableIds()) { + const tableAccess = permInfo.getTableAccess(tableId); let tableRef: number|undefined = 0; - if (!(tableAccess.permission & Permissions.VIEW)) { + if (tableAccess.read === 'deny') { tableRef = this._docData.getTable('_grist_Tables')?.findRow('tableId', tableId); if (tableRef) { censoredTables.add(tableRef); } } - for (const [colId, perm] of tableAccess.columnPermissions) { - if (perm.forbidden & Permissions.VIEW) { - if (!tableRef) { - tableRef = this._docData.getTable('_grist_Tables')?.findRow('tableId', tableId); + for (const ruleSet of this.getAllColumnRuleSets(tableId)) { + if (Array.isArray(ruleSet.colIds)) { + for (const colId of ruleSet.colIds) { + if (permInfo.getColumnAccess(tableId, colId).read === 'deny') { + if (!tableRef) { + tableRef = this._docData.getTable('_grist_Tables')?.findRow('tableId', tableId); + } + if (tableRef) { censoredColumnCodes.add(columnCode(tableRef, colId)); } + } } - if (tableRef) { censoredColumnCodes.add(columnCode(tableRef, colId)); } } } } @@ -356,159 +477,71 @@ export class GranularAccess { * Distill the clauses for the given session and table, to figure out the * access level and any row-level access functions needed. */ - public getTableAccess(docSession: OptDocSession, tableId: string): TableAccess { - const access = getDocSessionAccess(docSession); - const characteristics: {[key: string]: CellValue} = {}; - const user = getDocSessionUser(docSession); - characteristics.Access = access; - characteristics.UserID = user?.id || null; - characteristics.Email = user?.email || null; - characteristics.Name = user?.name || null; - // Light wrapper around characteristics. - const ch: InfoView = { - get(key: string) { return characteristics[key]; }, - toJSON() { return characteristics; } - }; - const tableAccess: TableAccess = { permission: 0, rowPermissionFunctions: [], - columnPermissions: new Map() }; - let canChangeSchema: boolean = true; - let canView: boolean = true; - // Don't apply access control to system requests (important to load characteristic - // tables). - if (docSession.mode !== 'system') { - for (const clause of this._clauses) { - switch (clause.kind) { - case 'doc': - { - const match = getMatchFunc(clause.match); - if (!match({ ch })) { - canChangeSchema = false; - } - } - break; - case 'table': - if (clause.tableId === tableId) { - const match = getMatchFunc(clause.match); - if (!match({ ch })) { - canView = false; - } - } - break; - case 'row': - if (clause.tableId === tableId) { - const scope = clause.scope ? getMatchFunc(clause.scope) : () => true; - if (scope({ ch })) { - const match = getMatchFunc(clause.match); - tableAccess.rowPermissionFunctions.push((rec) => { - return match({ ch, rec }) ? Permissions.OWNER : 0; - }); - } - } - break; - case 'column': - if (clause.tableId === tableId) { - const isMatch = getMatchFunc(clause.match)({ ch }); - for (const colId of clause.colIds) { - if (PermissionConstraint.needUpdate(isMatch, clause)) { - let perms = tableAccess.columnPermissions.get(colId); - if (!perms) { - perms = new PermissionConstraint(); - tableAccess.columnPermissions.set(colId, perms); - } - perms.update(isMatch, clause); - } - } - } - break; - case 'character': - { - const key = this._getCharacteristicTableKey(clause); - const characteristicTable = this._characteristicTables.get(key); - if (characteristicTable) { - const character = this._normalizeValue(characteristics[clause.charId]); - const rowNum = characteristicTable.rowNums.get(character); - if (rowNum !== undefined) { - const rec = new RecordView(characteristicTable.data, rowNum); - for (const key of Object.keys(characteristicTable.data[3])) { - characteristics[key] = rec.get(key); - } - } - } - } - break; - default: - // Don't fail terminally if a clause is not understood, to preserve some - // document access. - // TODO: figure out a way to communicate problems to an appropriate user, so - // they know if a clause is not being honored. - log.error('problem clause: %s', clause); - break; - } - } - } - tableAccess.permission = canView ? Permissions.OWNER : 0; - if (!canChangeSchema) { - tableAccess.permission = tableAccess.permission & ~Permissions.SCHEMA_EDIT; - } - return tableAccess; - } - - /** - * Get the set of all tables mentioned in access clauses. - */ - public getTablesInClauses(): Set { - const tables = new Set(); - for (const clause of this._clauses) { - if ('tableId' in clause) { tables.add(clause.tableId); } - } - return tables; + public getTableAccess(docSession: OptDocSession, tableId: string): TablePermissionSet { + return this._getAccess(docSession).getTableAccess(tableId); } /** * Modify table data in place, removing any rows or columns to which access * is not granted. */ - public filterData(data: TableDataAction, tableAccess: TableAccess) { - this.filterRows(data, tableAccess); - this.filterColumns(data[3], tableAccess); + public filterData(docSession: OptDocSession, data: TableDataAction) { + const permInfo = this._getAccess(docSession); + const tableId = data[1] as string; + if (permInfo.getTableAccess(tableId).read === 'mixed') { + this.filterRows(docSession, data); + } + + // Filter columns, omitting any to which the user has no access, regardless of rows. + this._filterColumns(data[3], (colId) => permInfo.getColumnAccess(tableId, colId).read !== 'deny'); } /** - * Modify table data in place, removing any rows to which access + * Modify table data in place, removing any rows and scrubbing any cells to which access * is not granted. */ - public filterRows(data: TableDataAction, tableAccess: TableAccess) { + public filterRows(docSession: OptDocSession, data: TableDataAction) { + const rowCursor = new RecordView(data, 0); + const input: AclMatchInput = {user: this._getUser(docSession), rec: rowCursor}; + + const [, tableId, rowIds, colValues] = data; const toRemove: number[] = []; - const rec = new RecordView(data, 0); - for (let idx = 0; idx < data[2].length; idx++) { - rec.index = idx; - let permission = Permissions.OWNER; - for (const fn of tableAccess.rowPermissionFunctions) { - permission = permission & fn(rec); - } - if (!(permission & Permissions.VIEW)) { + for (let idx = 0; idx < rowIds.length; idx++) { + rowCursor.index = idx; + + const rowPermInfo = new PermissionInfo(this, input); + // getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess. + const rowAccess = rowPermInfo.getTableAccess(tableId); + if (rowAccess.read === 'deny') { toRemove.push(idx); + } else if (rowAccess.read !== 'allow') { + // Go over column rules. + for (const colId of Object.keys(colValues)) { + const colAccess = rowPermInfo.getColumnAccess(tableId, colId); + if (colAccess.read !== 'allow') { + colValues[colId][idx] = 'CENSORED'; // TODO Pick a suitable value + } + } } } + if (toRemove.length > 0) { - pullAt(data[2], toRemove); - const cols = data[3]; - for (const [, values] of Object.entries(cols)) { + pullAt(rowIds, toRemove); + for (const values of Object.values(colValues)) { pullAt(values, toRemove); } } } /** - * Modify table data in place, removing any columns to which access - * is not granted. + * Remove columns from a ColumnValues parameter of certain DocActions, using a predicate for + * which columns to keep. */ - public filterColumns(data: BulkColValues|ColValues, tableAccess: TableAccess) { - const colIds= [...tableAccess.columnPermissions.entries()].map(([colId, p]) => { - return (p.forbidden & Permissions.VIEW) ? colId : null; - }).filter(c => c !== null) as string[]; - for (const colId of colIds) { - delete data[colId]; + private _filterColumns(data: BulkColValues|ColValues, shouldInclude: (colId: string) => boolean) { + for (const colId of Object.keys(data)) { + if (!shouldInclude(colId)) { + delete data[colId]; + } } } @@ -529,7 +562,12 @@ export class GranularAccess { * When comparing user characteristics, we lowercase for the sake of email comparison. * This is a bit weak. */ - private _normalizeValue(value: CellValue): string { + private _normalizeValue(value: CellValue|InfoView): string { + // If we get a record, e.g. `user.office`, interpret it as `user.office.id` (rather than try + // to use stringification of the full record). + if (value && typeof value === 'object' && !Array.isArray(value)) { + value = value.get('id'); + } return JSON.stringify(value).toLowerCase(); } @@ -538,18 +576,18 @@ export class GranularAccess { */ private async _updateCharacteristicTables() { this._characteristicTables.clear(); - for (const clause of this._clauses) { - if (clause.kind === 'character') { - this._updateCharacteristicTable(clause); - } + for (const userChar of this._userAttributeRules.values()) { + await this._updateCharacteristicTable(userChar); } } /** * Load a table needed for look-up. */ - private async _updateCharacteristicTable(clause: GranularAccessCharacteristicsClause) { - const key = this._getCharacteristicTableKey(clause); + private async _updateCharacteristicTable(clause: UserAttributeRule) { + if (this._characteristicTables.get(clause.name)) { + throw new Error(`User attribute ${clause.name} ignored: duplicate name`); + } const data = await this._fetchQuery({tableId: clause.tableId, filters: {}}); const rowNums = new Map(); const matches = data[3][clause.lookupColId]; @@ -561,85 +599,154 @@ export class GranularAccess { colId: clause.lookupColId, rowNums, data + }; + this._characteristicTables.set(clause.name, result); + } + + /** + * Get PermissionInfo for the user represented by the given docSession. The returned object + * allows evaluating access level as far as possible without considering specific records. + * + * The result is cached in a WeakMap, and PermissionInfo does its own caching, so multiple calls + * to this._getAccess(docSession).someMethod() will reuse already-evaluated results. + */ + private _getAccess(docSession: OptDocSession): PermissionInfo { + // TODO The intent of caching is to avoid duplicating rule evaluations while processing a + // single request. Caching based on docSession is riskier since those persist across requests. + return getSetMapValue(this._permissionInfoMap as Map, docSession, + () => new PermissionInfo(this, {user: this._getUser(docSession)})); + } + + /** + * Construct the UserInfo needed for evaluating rules. This also enriches the user with values + * created by user-attribute rules. + */ + private _getUser(docSession: OptDocSession): UserInfo { + const access = getDocSessionAccess(docSession); + const fullUser = getDocSessionUser(docSession); + const user: UserInfo = {}; + user.Access = access; + user.UserID = fullUser?.id || null; + user.Email = fullUser?.email || null; + user.Name = fullUser?.name || null; + + for (const clause of this._userAttributeRules.values()) { + if (clause.name in user) { + log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`); + continue; + } + user[clause.name] = new EmptyRecordView(); + const characteristicTable = this._characteristicTables.get(clause.name); + if (characteristicTable) { + // User lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`. + const character = this._normalizeValue(get(user, clause.charId) as CellValue); + const rowNum = characteristicTable.rowNums.get(character); + if (rowNum !== undefined) { + user[clause.name] = new RecordView(characteristicTable.data, rowNum); + } + } } - this._characteristicTables.set(key, result); + return user; } - - private _getCharacteristicTableKey(clause: GranularAccessCharacteristicsClause): string { - return JSON.stringify({ tableId: clause.tableId, colId: clause.lookupColId }); - } -} - -// A function that computes permissions given a record. -export type PermissionFunction = (rec: RecordView) => number; - -// A summary of table-level access information. -export interface TableAccess { - permission: number; - rowPermissionFunctions: Array; - columnPermissions: Map; } /** - * This is a placeholder for accumulating permissions for a particular scope. + * Evaluate a RuleSet on a given input (user and optionally record). If a record is needed but not + * included, the result may include permission values like 'allowSome', 'denySome'. */ -export class PermissionConstraint { - private _allowed: number = 0; - private _forbidden: number = 0; - - // If a clause's condition matches the user, or fails to match the user, - // check if the clause could modify permissions via onMatch/onFail. - public static needUpdate(isMatch: boolean, clause: GranularAccessColumnClause) { - return (isMatch && clause.onMatch) || (!isMatch && clause.onFail); - } - - public constructor() { - this._allowed = this._forbidden = 0; - } - - public get allowed() { - return this._allowed; - } - - public get forbidden() { - return this._forbidden; - } - - public allow(p: number) { - this._allowed = this._allowed | p; - this._forbidden = this._forbidden & ~p; - } - - public allowOnly(p: number) { - this._allowed = p; - this._forbidden = ~p; - } - - public forbid(p: number) { - this._forbidden = this._forbidden | p; - this._allowed = this._allowed & ~p; - } - - // Update this PermissionConstraint based on whether the user matched/did not match - // a particular clause. - public update(isMatch: boolean, clause: GranularAccessColumnClause) { - const activeClause = (isMatch ? clause.onMatch : clause.onFail) || {}; - if (activeClause.allow) { - this.allow(getPermission(activeClause.allow)); - } - if (activeClause.allowOnly) { - this.allowOnly(getPermission(activeClause.allowOnly)); - } - if (activeClause.forbid) { - this.forbid(getPermission(activeClause.forbid)); +function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermissionSet { + let pset: PartialPermissionSet = emptyPermissionSet(); + for (const rule of ruleSet.body) { + try { + if (rule.matchFunc!(input)) { + pset = mergePartialPermissions(pset, rule.permissions); + } + } catch (e) { + if (e.code === 'NEED_ROW_DATA') { + pset = mergePartialPermissions(pset, makePartialPermissions(rule.permissions)); + } else { + // For other errors, assume the rule is invalid, and treat as a non-match. + // TODO An appropriate user should be alerted that a clause is not being honored. + log.warn("ACLRule for %s failed: %s", ruleSet.tableId, e.message); + } } } + pset = mergePartialPermissions(pset, ruleSet.defaultPermissions); + return pset; } -// Light wrapper around characteristics or records. -export interface InfoView { - get(key: string): CellValue; - toJSON(): {[key: string]: any}; +/** + * Helper for evaluating rules given a particular user and optionally a record. It evaluates rules + * for a column, table, or document, with caching to avoid evaluating the same rule multiple times. + */ +class PermissionInfo { + private _ruleResults = new Map(); + + // Construct a PermissionInfo for a particular input, which is a combination of user and + // optionally a record. + constructor(private _acls: GranularAccess, private _input: AclMatchInput) {} + + // Get permissions for "tableId:colId", defaulting to "tableId:*" and "*:*" as needed. + // If 'mixed' is returned, different rows may have different permissions. It should never return + // 'mixed' if the input includes `rec`. + public getColumnAccess(tableId: string, colId: string): MixedPermissionSet { + const ruleSet: RuleSet|undefined = this._acls.getColumnRuleSet(tableId, colId); + return ruleSet ? this._processColumnRule(ruleSet) : this._getTableDefaultAccess(tableId); + } + + // Combine permissions from all rules for the given table. + // If 'mixedColumns' is returned, different columns have different permissions, but they do NOT + // depend on rows. If 'mixed' is returned, some permissions depend on rows. + public getTableAccess(tableId: string): TablePermissionSet { + const columnAccess = this._acls.getAllColumnRuleSets(tableId).map(rs => this._processColumnRule(rs)); + columnAccess.push(this._getTableDefaultAccess(tableId)); + + return mergePermissions(columnAccess, (bits) => ( + bits.every(b => b === 'allow') ? 'allow' : + bits.every(b => b === 'deny') ? 'deny' : + bits.every(b => b === 'allow' || b === 'deny') ? 'mixedColumns' : + 'mixed' + )); + } + + // Combine permissions from all rules throughout. + // If 'mixed' is returned, then different tables, rows, or columns have different permissions. + public getFullAccess(): MixedPermissionSet { + const tableAccess = this._acls.getAllTableIds().map(tableId => this.getTableAccess(tableId)); + tableAccess.push(this._getDocDefaultAccess()); + + return mergePermissions(tableAccess, (bits) => ( + bits.every(b => b === 'allow') ? 'allow' : + bits.every(b => b === 'deny') ? 'deny' : + 'mixed' + )); + } + + // Get permissions for "tableId:*", defaulting to "*:*" as needed. + // If 'mixed' is returned, different rows may have different permissions. + private _getTableDefaultAccess(tableId: string): MixedPermissionSet { + const ruleSet: RuleSet|undefined = this._acls.getTableDefaultRuleSet(tableId); + return ruleSet ? this._processRule(ruleSet, () => this._getDocDefaultAccess()) : + this._getDocDefaultAccess(); + } + + // Get permissions for "*:*". + private _getDocDefaultAccess(): MixedPermissionSet { + return this._processRule(this._acls.getDocDefaultRuleSet()); + } + + // Evaluate and cache the given column rule, falling back to the corresponding table default. + private _processColumnRule(ruleSet: RuleSet): MixedPermissionSet { + return this._processRule(ruleSet, () => this._getTableDefaultAccess(ruleSet.tableId)); + } + + // Evaluate the given rule, with the default fallback, and cache the result. + private _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedPermissionSet): MixedPermissionSet { + return getSetMapValue(this._ruleResults, ruleSet, () => { + const pset = evaluateRule(ruleSet, this._input); + return toMixed(defaultAccess ? mergePartialPermissions(pset, defaultAccess()) : pset); + }); + } } // A row-like view of TableDataAction, which is columnar in nature. @@ -663,59 +770,18 @@ export class RecordView implements InfoView { } } -// A function for matching characteristic and/or record information. -export type MatchFunc = (state: { ch?: InfoView, rec?: InfoView }) => boolean; - -// Convert a match specification to a function. -export function getMatchFunc(spec: MatchSpec): MatchFunc { - switch (spec.kind) { - case 'not': - { - const core = getMatchFunc(spec.match); - return (state) => !core(state); - } - case 'const': - return (state) => state.ch?.get(spec.charId) === spec.value; - case 'truthy': - return (state) => Boolean(state.rec?.get(spec.colId)); - case 'pair': - return (state) => state.ch?.get(spec.charId) === state.rec?.get(spec.colId); - default: - throw new Error('match spec not understood'); - } +class EmptyRecordView implements InfoView { + public get(colId: string): CellValue { return null; } + public toJSON() { return {}; } } /** * A cache of a table needed for look-ups, including a map from keys to * row numbers. Keys are produced by _getCharacteristicTableKey(). */ -export interface CharacteristicTable { +interface CharacteristicTable { tableId: string; colId: string; rowNums: Map; data: TableDataAction; } - -export function getPermission(accessPermissions: AccessPermissions) { - if (accessPermissions === 'all') { return 255; } - let n: number = 0; - for (const p of accessPermissions) { - switch (p) { - case 'read': - n = n | Permissions.VIEW; - break; - case 'update': - n = n | Permissions.UPDATE; - break; - case 'create': - n = n | Permissions.ADD; - break; - case 'delete': - n = n | Permissions.REMOVE; - break; - default: - throw new Error(`unrecognized permission ${p}`); - } - } - return n; -} diff --git a/sandbox/grist/acl_formula.py b/sandbox/grist/acl_formula.py new file mode 100644 index 00000000..73245d49 --- /dev/null +++ b/sandbox/grist/acl_formula.py @@ -0,0 +1,91 @@ +import ast +import json + +def parse_acl_formula(acl_formula): + """ + Parse an ACL formula expression into a parse tree that we can interpret in JS, e.g. + "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']". + + The idea is to support enough to express ACL rules flexibly, but we don't need to support too + much, since rules should be reasonably simple. + + The returned tree has the form [NODE_TYPE, arguments...], with these NODE_TYPEs supported: + And|Or ...values + Add|Sub|Mult|Div|Mod left, right + Not operand + Eq|NotEq|Lt|LtE|Gt|GtE left, right + Is|IsNot|In|NotIn left, right + List ...elements + Const value (number, string, bool) + Name name (string) + Attr node, attr_name + """ + try: + tree = ast.parse(acl_formula, mode='eval') + return _TreeConverter().visit(tree) + except SyntaxError as err: + # In case of an error, include line and offset. + raise SyntaxError("%s on line %s col %s" % (err.args[0], err.lineno, err.offset)) + + +def parse_acl_formula_json(acl_formula): + """ + As parse_acl_formula(), but stringifies the result, and converts empty string to empty string. + """ + return json.dumps(parse_acl_formula(acl_formula)) if acl_formula else "" + + +named_constants = { + 'True': True, + 'False': False, + 'None': None, +} + +class _TreeConverter(ast.NodeVisitor): + # AST nodes are documented here: https://docs.python.org/2/library/ast.html#abstract-grammar + # pylint:disable=no-self-use + + def visit_Expression(self, node): + return self.visit(node.body) + + def visit_BoolOp(self, node): + return [node.op.__class__.__name__] + [self.visit(v) for v in node.values] + + def visit_BinOp(self, node): + if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod)): + return self.generic_visit(node) + return [node.op.__class__.__name__, self.visit(node.left), self.visit(node.right)] + + def visit_UnaryOp(self, node): + if not isinstance(node.op, (ast.Not)): + return self.generic_visit(node) + return [node.op.__class__.__name__, self.visit(node.operand)] + + def visit_Compare(self, node): + # We don't try to support chained comparisons like "1 < 2 < 3" (though it wouldn't be hard). + if len(node.ops) != 1 or len(node.comparators) != 1: + raise ValueError("Can't use chained comparisons") + return [node.ops[0].__class__.__name__, self.visit(node.left), self.visit(node.comparators[0])] + + def visit_Name(self, node): + if node.id in named_constants: + return ["Const", named_constants[node.id]] + return ["Name", node.id] + + def visit_Attribute(self, node): + return ["Attr", self.visit(node.value), node.attr] + + def visit_Num(self, node): + return ["Const", node.n] + + def visit_Str(self, node): + return ["Const", node.s] + + def visit_List(self, node): + return ["List"] + [self.visit(e) for e in node.elts] + + def visit_Tuple(self, node): + return self.visit_List(node) # We don't distinguish tuples and lists + + def generic_visit(self, node): + raise ValueError("Unsupported syntax at %s:%s" % (node.lineno, node.col_offset + 1)) diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 664e836f..5589bb0d 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -767,3 +767,12 @@ def migration20(tdset): 'indentation': [1 if v.id in table_views_map else 0 for v in views] }) ]) + +@migration(schema_version=21) +def migration21(tdset): + return tdset.apply_doc_actions([ + add_column('_grist_ACLRules', 'aclFormulaParsed', 'Text'), + add_column('_grist_ACLRules', 'permissionsText', 'Text'), + add_column('_grist_ACLRules', 'rulePos', 'PositionNumber'), + add_column('_grist_ACLRules', 'userAttributes', 'Text'), + ]) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 7edfc13b..ef9a58e3 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -12,7 +12,7 @@ import itertools from collections import OrderedDict, namedtuple import actions -SCHEMA_VERSION = 20 +SCHEMA_VERSION = 21 def make_column(col_id, col_type, formula='', isFormula=False): return { @@ -225,17 +225,40 @@ def schema_create_actions(): # All of the ACL rules. actions.AddTable('_grist_ACLRules', [ make_column('resource', 'Ref:_grist_ACLResources'), - make_column('permissions', 'Int'), # Bit-map of permission types. See acl.py. - make_column('principals', 'Text'), # JSON array of _grist_ACLPrincipals refs. + make_column('permissions', 'Int'), # DEPRECATED: permissionsText is used instead. + make_column('principals', 'Text'), # DEPRECATED - make_column('aclFormula', 'Text'), # Formula to apply to tableId, which should return - # additional principals for each row. - make_column('aclColumn', 'Ref:_grist_Tables_column') + # Text of match formula, in restricted Python syntax; "" for default rule. + make_column('aclFormula', 'Text'), + + make_column('aclColumn', 'Ref:_grist_Tables_column'), # DEPRECATED + + # JSON representation of the parse tree of matchFunc; "" for default rule. + make_column('aclFormulaParsed', 'Text'), + + # Permissions in the form '[+][-]' where is a string of + # C,R,U,D,S characters, each appearing at most once. Or the special values + # 'all' or 'none'. The empty string does not affect permissions. + make_column('permissionsText', 'Text'), + + # Rules for one resource are ordered by increasing rulePos. The default rule + # should be at the end (later rules would have no effect). + make_column('rulePos', 'PositionNumber'), + + # If non-empty, this rule adds extra user attributes. It should contain JSON + # of the form {name, tableId, lookupColId, charId}, and should be tied to the + # resource *:*. It acts by looking up user[charId] in the given tableId on the + # given lookupColId, and adds the full looked-up record as user[name], which + # becomes available to matchFunc. These rules are processed in order of rulePos, + # which should list them before regular rules. + make_column('userAttributes', 'Text'), ]), + # Note that the special resource with tableId of '' and colIds of '' should be ignored. It is + # present to satisfy older versions of Grist (before Nov 2020). actions.AddTable('_grist_ACLResources', [ - make_column('tableId', 'Text'), # Name of the table this rule applies to, or '' - make_column('colIds', 'Text'), # Comma-separated list of colIds, or '' + make_column('tableId', 'Text'), # Name of the table this rule applies to, or '*' + make_column('colIds', 'Text'), # Comma-separated list of colIds, or '*' ]), # DEPRECATED: All of the principals used by ACL rules, including users, groups, and instances. diff --git a/sandbox/grist/test_acl_formula.py b/sandbox/grist/test_acl_formula.py new file mode 100644 index 00000000..b077e01f --- /dev/null +++ b/sandbox/grist/test_acl_formula.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# pylint:disable=line-too-long + +import unittest +from acl_formula import parse_acl_formula +import test_engine + +class TestACLFormula(unittest.TestCase): + def test_basic(self): + # Test a few basic formulas and structures, hitting everything we expect to support + self.assertEqual(parse_acl_formula( + "user.Email == 'X@'"), + ["Eq", ["Attr", ["Name", "user"], "Email"], + ["Const", "X@"]]) + + self.assertEqual(parse_acl_formula( + "user.Role in ('editors', 'owners')"), + ["In", ["Attr", ["Name", "user"], "Role"], + ["List", ["Const", "editors"], ["Const", "owners"]]]) + + self.assertEqual(parse_acl_formula( + "user.Role not in ('editors', 'owners')"), + ["NotIn", ["Attr", ["Name", "user"], "Role"], + ["List", ["Const", "editors"], ["Const", "owners"]]]) + + self.assertEqual(parse_acl_formula( + "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']"), + ['And', + ['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']], + ['In', + ['Attr', ['Name', 'user'], 'email'], + ['List', ['Const', 'sally@'], ['Const', 'xie@']] + ]]) + + self.assertEqual(parse_acl_formula( + "user.IsAdmin or rec.assigned is None or (not newRec.HasDuplicates and rec.StatusIndex <= newRec.StatusIndex)"), + ['Or', + ['Attr', ['Name', 'user'], 'IsAdmin'], + ['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]], + ['And', + ['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']], + ['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']] + ] + ]) + + self.assertEqual(parse_acl_formula( + "r.A <= n.A + 1 or r.A >= n.A - 1 or r.B < n.B * 2.5 or r.B > n.B / 2.5 or r.C % 2 != 0"), + ['Or', + ['LtE', + ['Attr', ['Name', 'r'], 'A'], + ['Add', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]], + ['GtE', + ['Attr', ['Name', 'r'], 'A'], + ['Sub', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]], + ['Lt', + ['Attr', ['Name', 'r'], 'B'], + ['Mult', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]], + ['Gt', + ['Attr', ['Name', 'r'], 'B'], + ['Div', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]], + ['NotEq', + ['Mod', ['Attr', ['Name', 'r'], 'C'], ['Const', 2]], + ['Const', 0]] + ]) + + self.assertEqual(parse_acl_formula( + "rec.A is True or rec.A is not False"), + ['Or', + ['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]], + ['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]] + ]) + + self.assertEqual(parse_acl_formula( + "user.Office.City == 'Seattle' and user.Status.IsActive"), + ['And', + ['Eq', + ['Attr', ['Attr', ['Name', 'user'], 'Office'], 'City'], + ['Const', 'Seattle']], + ['Attr', ['Attr', ['Name', 'user'], 'Status'], 'IsActive'] + ]) + + def test_unsupported(self): + # Test a few constructs we expect to fail + # Not an expression + self.assertRaises(SyntaxError, parse_acl_formula, "return 1") + self.assertRaises(SyntaxError, parse_acl_formula, "def foo(): pass") + + # Unsupported node type + self.assertRaisesRegexp(ValueError, r'Unsupported syntax', parse_acl_formula, "max(rec)") + self.assertRaisesRegexp(ValueError, r'Unsupported syntax', parse_acl_formula, "user.id in {1, 2, 3}") + self.assertRaisesRegexp(ValueError, r'Unsupported syntax', parse_acl_formula, "1 if user.IsAnon else 2") + + # Unsupported operation + self.assertRaisesRegexp(ValueError, r'Unsupported syntax', parse_acl_formula, "1 | 2") + self.assertRaisesRegexp(ValueError, r'Unsupported syntax', parse_acl_formula, "1 << 2") + self.assertRaisesRegexp(ValueError, r'Unsupported syntax', parse_acl_formula, "~test") + + # Syntax error + self.assertRaises(SyntaxError, parse_acl_formula, "[(]") + self.assertRaises(SyntaxError, parse_acl_formula, "user.id in (1,2))") + self.assertRaisesRegexp(SyntaxError, r'invalid syntax on line 1 col 9', parse_acl_formula, "foo and !bar") + +class TestACLFormulaUserActions(test_engine.EngineTestCase): + def test_acl_actions(self): + # Adding or updating ACLRules automatically includes aclFormula compilation. + + # Single Add + out_actions = self.apply_user_action( + ['AddRecord', '_grist_ACLRules', None, {"resource": 1, "aclFormula": "user.UserID == 7"}], + ) + self.assertPartialOutActions(out_actions, { "stored": [ + ["AddRecord", "_grist_ACLRules", 1, {"resource": 1, "aclFormula": "user.UserID == 7", + "aclFormulaParsed": '["Eq", ["Attr", ["Name", "user"], "UserID"], ["Const", 7]]', + "rulePos": 1.0 + }], + ]}) + + # Single Update + out_actions = self.apply_user_action( + ['UpdateRecord', '_grist_ACLRules', 1, { + "aclFormula": "user.UserID == 8", + "aclFormulaParsed": "hello" + }], + ) + self.assertPartialOutActions(out_actions, { "stored": [ + ["UpdateRecord", "_grist_ACLRules", 1, { + "aclFormula": "user.UserID == 8", + "aclFormulaParsed": '["Eq", ["Attr", ["Name", "user"], "UserID"], ["Const", 8]]', + }], + ]}) + + # BulkAddRecord + out_actions = self.apply_user_action(['BulkAddRecord', '_grist_ACLRules', [None, None], { + "resource": [1, 1], + "aclFormula": ["user.IsGood", "user.IsBad"], + "aclFormulaParsed": ["[1]", '["ignored"]'], # Should get overwritten + }]) + self.assertPartialOutActions(out_actions, { "stored": [ + [ 'BulkAddRecord', '_grist_ACLRules', [2, 3], { + "resource": [1, 1], + "aclFormula": ["user.IsGood", "user.IsBad"], + "aclFormulaParsed": [ # Gets overwritten + '["Attr", ["Name", "user"], "IsGood"]', + '["Attr", ["Name", "user"], "IsBad"]', + ], + "rulePos": [2.0, 3.0], # Gets filled in. + }], + ]}) + + # BulkUpdateRecord + out_actions = self.apply_user_action(['BulkUpdateRecord', '_grist_ACLRules', [2, 3], { + "aclFormula": ["not user.IsGood", ""], + }]) + self.assertPartialOutActions(out_actions, { "stored": [ + [ 'BulkUpdateRecord', '_grist_ACLRules', [2, 3], { + "aclFormula": ["not user.IsGood", ""], + "aclFormulaParsed": ['["Not", ["Attr", ["Name", "user"], "IsGood"]]', ''], + }], + ]}) diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index ef14ae81..84fa108c 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -5,6 +5,7 @@ import json import sys import acl +from acl_formula import parse_acl_formula_json import actions import column import identifiers @@ -277,7 +278,8 @@ class UserActions(object): column_values = actions.decode_bulk_values(column_values) for col_id, values in column_values.iteritems(): self._ensure_column_accepts_data(table_id, col_id, values) - return self.doBulkAddOrReplace(table_id, row_ids, column_values, replace=False) + method = self._overrides.get(('BulkAddRecord', table_id), self.doBulkAddOrReplace) + return method(table_id, row_ids, column_values) @useraction def ReplaceTableData(self, table_id, row_ids, column_values): @@ -325,6 +327,13 @@ class UserActions(object): return filled_row_ids + @override_action('BulkAddRecord', '_grist_ACLRules') + def _addACLRules(self, table_id, row_ids, col_values): + # Automatically populate aclFormulaParsed value by parsing aclFormula. + if 'aclFormula' in col_values: + col_values['aclFormulaParsed'] = map(parse_acl_formula_json, col_values['aclFormula']) + return self.doBulkAddOrReplace(table_id, row_ids, col_values) + #---------------------------------------- # UpdateRecords & co. #---------------------------------------- @@ -376,7 +385,6 @@ class UserActions(object): method = self._overrides.get(('BulkUpdateRecord', table_id), self.doBulkUpdateRecord) method(table_id, row_ids, columns) - @override_action('BulkUpdateRecord', '_grist_Validations') def _updateValidationRecords(self, table_id, row_ids, col_values): for i, rec, values in self._bulk_action_iter(table_id, row_ids, col_values): @@ -550,6 +558,13 @@ class UserActions(object): self.doBulkUpdateRecord(table_id, row_ids, col_values) + @override_action('BulkUpdateRecord', '_grist_ACLRules') + def _updateACLRules(self, table_id, row_ids, col_values): + # Automatically populate aclFormulaParsed value by parsing aclFormula. + if 'aclFormula' in col_values: + col_values['aclFormulaParsed'] = map(parse_acl_formula_json, col_values['aclFormula']) + return self.doBulkUpdateRecord(table_id, row_ids, col_values) + def _prepare_formula_renames(self, renames): """ Helper that accepts a dict of {(table_id, col_id): new_name} (where col_id is None when table