mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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