(core) Implement new representation of ACL rules.

Summary:
- Added fields to _grist_ACLRules for the new Granular ACL representation
- Include a corresponding migration.

- Added ACLPermissions module with merging PermissionSets and converting to/from string.
- Implemented parsing of ACL formulas and compiling them into JS functions.
- Add automatic parsing of ACL formulas when ACLRules are added or updated.
- Convert GranularAccess to load and interpret new-style rules.
- Convert ACL UI to load and save new-style rules.

For now, no attempt to do anything better on the server or UI side, only to
reproduce previous behavior.

Test Plan: Added unittests for new files; fixed those for existing files.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2664
This commit is contained in:
Dmitry S 2020-11-17 16:49:32 -05:00
parent c042935c58
commit bc3a472324
12 changed files with 1131 additions and 482 deletions

View File

@ -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<string>();
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]);
}
}
}
});
}

View File

@ -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 '[+<bits>][-<bits>]' where <bits>
* 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<T = PermissionValue> {
read: T;
create: T;
update: T;
delete: T;
schemaEdit: T;
}
// Some shorter type aliases.
export type PartialPermissionSet = PermissionSet<PartialPermissionValue>;
export type MixedPermissionSet = PermissionSet<MixedPermissionValue>;
export type TablePermissionSet = PermissionSet<TablePermissionValue>;
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<keyof PermissionSet> =
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<PartialPermissionSet>): 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<T, U>(psets: Array<PermissionSet<T>>, combine: (bits: T[]) => U
): PermissionSet<U> {
const result: Partial<PermissionSet<U>> = {};
for (const prop of ALL_PERMISSION_PROPS) {
result[prop] = combine(psets.map(p => p[prop]));
}
return result as PermissionSet<U>;
}
/**
* 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'));
}

View File

@ -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<string, CellValue|InfoView>;
/**
* 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<ParsedAclFormula|CellValue>];
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<number, RowRecord[]>();
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};
}

View File

@ -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": {

View File

@ -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)));
}

View File

@ -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);

View File

@ -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<GranularAccessClause>();
// 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<string, RuleSet[]>();
// Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId).
private _tableColumnMap = new Map<string, RuleSet>();
// Map of tableId to the single default RuleSet for the table (colIds of '*')
private _tableRuleSets = new Map<string, RuleSet>();
// 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<string, UserAttributeRule>();
// 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<string, CharacteristicTable>();
// 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<OptDocSession, PermissionInfo>();
public constructor(private _docData: DocData, private _fetchQuery: (query: Query) => Promise<TableDataAction>) {
}
// 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:<any colId>", 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<string, UserAttributeRule>();
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<string, RuleSet[]>();
const tableColMap = new Map<string, RuleSet>();
const tableRuleSets = new Map<string, RuleSet>();
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 "<tableRef> <colId>").
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
const censoredColumnCodes: Set<string> = 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<string> {
const tables = new Set<string>();
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<string, number>();
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<OptDocSession, PermissionInfo>, 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<PermissionFunction>;
columnPermissions: Map<string, PermissionConstraint>;
}
/**
* 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<RuleSet, MixedPermissionSet>();
// 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<string, number>;
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;
}

View File

@ -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))

View File

@ -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'),
])

View File

@ -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 '[+<bits>][-<bits>]' where <bits> 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.

View File

@ -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"]]', ''],
}],
]})

View File

@ -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