mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
c042935c58
commit
bc3a472324
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
163
app/common/ACLPermissions.ts
Normal file
163
app/common/ACLPermissions.ts
Normal 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'));
|
||||
}
|
@ -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};
|
||||
}
|
||||
|
@ -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": {
|
||||
|
104
app/server/lib/ACLFormula.ts
Normal file
104
app/server/lib/ACLFormula.ts
Normal 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)));
|
||||
}
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
91
sandbox/grist/acl_formula.py
Normal file
91
sandbox/grist/acl_formula.py
Normal 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))
|
@ -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'),
|
||||
])
|
||||
|
@ -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.
|
||||
|
159
sandbox/grist/test_acl_formula.py
Normal file
159
sandbox/grist/test_acl_formula.py
Normal 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"]]', ''],
|
||||
}],
|
||||
]})
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user