gristlabs_grist-core/app/server/lib/PermissionInfo.ts
Paul Fitzpatrick 4ab096d179 (core) granular access control in the presence of schema changes
Summary:
 - Support schema changes in the presence of non-trivial ACL rules.
 - Fix update of `aclFormulaParsed` when updating formulas automatically after schema change.
 - Filter private metadata in broadcasts, not just fetches.  Censorship method is unchanged, just refactored.
 - Allow only owners to change ACL rules.
 - Force reloads if rules are changed.
 - Track rule changes within bundle, for clarity during schema changes - tableId and colId changes create a muddle otherwise.
 - Show or forbid pages dynamically depending on user's access to its sections. Logic unchanged, just no longer requires reload.
 - Fix calculation of pre-existing rows touched by a bundle, in the presence of schema changes.
 - Gray out acl page for non-owners.

Test Plan: added tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2734
2021-03-01 13:49:31 -05:00

251 lines
9.5 KiB
TypeScript

import { ALL_PERMISSION_PROPS, emptyPermissionSet,
makePartialPermissions, mergePartialPermissions, mergePermissions,
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { AclMatchInput, RuleSet } from 'app/common/GranularAccessClause';
import { getSetMapValue } from 'app/common/gutil';
import * as log from 'app/server/lib/log';
import { mapValues } from 'lodash';
/**
* A PermissionSet with context about how it was created. Allows us to produce more
* informative error messages.
*/
export interface PermissionSetWithContextOf<T = PermissionSet> {
perms: T;
ruleType: 'full'|'table'|'column'|'row';
getMemos: () => MemoSet;
}
export type MixedPermissionSetWithContext = PermissionSetWithContextOf<MixedPermissionSet>;
export type TablePermissionSetWithContext = PermissionSetWithContextOf<TablePermissionSet>;
export type PermissionSetWithContext = PermissionSetWithContextOf<PermissionSet<string>>;
// Accumulator for memos of relevant rules.
export type MemoSet = PermissionSet<string[]>;
// Merge MemoSets straightforwardly, by concatenation.
export function mergeMemoSets(psets: MemoSet[]): MemoSet {
const result: Partial<MemoSet> = {};
for (const prop of ALL_PERMISSION_PROPS) {
result[prop] = ([] as string[]).concat(...psets.map(p => p[prop]));
}
return result as MemoSet;
}
export function emptyMemoSet(): MemoSet {
return {
read: [],
create: [],
update: [],
delete: [],
schemaEdit: [],
};
}
/**
* Abstract base class for processing rules given a particular input.
* Main use of this class will be to calculate permissions, but will also
* be used to calculate metadata about permissions.
*/
abstract class RuleInfo<MixedT extends TableT, TableT> {
// Construct a RuleInfo for a particular input, which is a combination of user and
// optionally a record.
constructor(protected _acls: ACLRuleCollection, protected _input: AclMatchInput) {}
public getColumnAspect(tableId: string, colId: string): MixedT {
const ruleSet: RuleSet|undefined = this._acls.getColumnRuleSet(tableId, colId);
return ruleSet ? this._processColumnRule(ruleSet) : this._getTableDefaultAspect(tableId);
}
public getTableAspect(tableId: string): TableT {
const columnAccess = this._acls.getAllColumnRuleSets(tableId).map(rs => this._processColumnRule(rs));
columnAccess.push(this._getTableDefaultAspect(tableId));
return this._mergeTableAccess(columnAccess);
}
public getFullAspect(): MixedT {
const tableAccess = this._acls.getAllTableIds().map(tableId => this.getTableAspect(tableId));
tableAccess.push(this._getDocDefaultAspect());
return this._mergeFullAccess(tableAccess);
}
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
protected abstract _mergeTableAccess(access: MixedT[]): TableT;
protected abstract _mergeFullAccess(access: TableT[]): MixedT;
private _getTableDefaultAspect(tableId: string): MixedT {
const ruleSet: RuleSet|undefined = this._acls.getTableDefaultRuleSet(tableId);
return ruleSet ? this._processRule(ruleSet, () => this._getDocDefaultAspect()) :
this._getDocDefaultAspect();
}
private _getDocDefaultAspect(): MixedT {
return this._processRule(this._acls.getDocDefaultRuleSet());
}
private _processColumnRule(ruleSet: RuleSet): MixedT {
return this._processRule(ruleSet, () => this._getTableDefaultAspect(ruleSet.tableId));
}
}
/**
* Pool memos from rules, on the assumption that access has been denied and we are looking
* for possible explanations to offer the user.
*/
export class MemoInfo extends RuleInfo<MemoSet, MemoSet> {
protected _processRule(ruleSet: RuleSet, defaultAccess?: () => MemoSet): MemoSet {
const pset = extractMemos(ruleSet, this._input);
return defaultAccess ? mergeMemoSets([pset, defaultAccess()]) : pset;
}
protected _mergeTableAccess(access: MemoSet[]): MemoSet {
return mergeMemoSets(access);
}
protected _mergeFullAccess(access: MemoSet[]): MemoSet {
return mergeMemoSets(access);
}
}
export interface IPermissionInfo {
getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext;
getTableAccess(tableId: string): TablePermissionSetWithContext;
getFullAccess(): MixedPermissionSetWithContext;
getRuleCollection(): ACLRuleCollection;
}
/**
* 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.
*/
export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermissionSet> implements IPermissionInfo {
private _ruleResults = new Map<RuleSet, MixedPermissionSet>();
// 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`.
// Wrap permissions with information about how they were computed. This allows
// us to issue more informative error messages.
public getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext {
return {
perms: this.getColumnAspect(tableId, colId),
ruleType: 'column',
getMemos: () => new MemoInfo(this._acls, this._input).getColumnAspect(tableId, colId)
};
}
// 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.
// Wrap permission sets for better error messages.
public getTableAccess(tableId: string): TablePermissionSetWithContext {
return {
perms: this.getTableAspect(tableId),
ruleType: this._input?.rec ? 'row' : 'table',
getMemos: () => new MemoInfo(this._acls, this._input).getTableAspect(tableId)
};
}
// Combine permissions from all rules throughout.
// If 'mixed' is returned, then different tables, rows, or columns have different permissions.
// Wrap permission sets for better error messages.
public getFullAccess(): MixedPermissionSetWithContext {
return {
perms: this.getFullAspect(),
ruleType: 'full',
getMemos: () => new MemoInfo(this._acls, this._input).getFullAspect()
};
}
public getRuleCollection() {
return this._acls;
}
protected _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedPermissionSet): MixedPermissionSet {
return getSetMapValue(this._ruleResults, ruleSet, () => {
const pset = evaluateRule(ruleSet, this._input);
return toMixed(defaultAccess ? mergePartialPermissions(pset, defaultAccess()) : pset);
});
}
protected _mergeTableAccess(access: MixedPermissionSet[]): TablePermissionSet {
return mergePermissions(access, (bits) => (
bits.every(b => b === 'allow') ? 'allow' :
bits.every(b => b === 'deny') ? 'deny' :
bits.every(b => b === 'allow' || b === 'deny') ? 'mixedColumns' :
'mixed'
));
}
protected _mergeFullAccess(access: TablePermissionSet[]): MixedPermissionSet {
return mergePermissions(access, (bits) => (
bits.every(b => b === 'allow') ? 'allow' :
bits.every(b => b === 'deny') ? 'deny' :
'mixed'
));
}
}
/**
* 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'.
*/
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 {
// Unexpected error. Interpret rule pessimistically.
// Anything it would explicitly allow, no longer allow through this rule.
// Anything it would explicitly deny, go ahead and deny.
pset = mergePartialPermissions(pset, mapValues(rule.permissions, val => (val === 'allow' ? "" : val)));
log.warn("ACLRule for %s failed: %s", ruleSet.tableId, e.message);
}
}
}
return pset;
}
/**
* If a rule has a memo, and passes, add that memo for all permissions it denies.
* If a rule has a memo, and fails, add that memo for all permissions it allows.
*/
function extractMemos(ruleSet: RuleSet, input: AclMatchInput): MemoSet {
const pset = emptyMemoSet();
for (const rule of ruleSet.body) {
try {
const passing = rule.matchFunc!(input);
for (const prop of ALL_PERMISSION_PROPS) {
const p = rule.permissions[prop];
const memos: string[] = pset[prop];
if (rule.memo) {
if (passing && p === 'deny') {
memos.push(rule.memo);
} else if (!passing && p === 'allow') {
memos.push(rule.memo);
}
}
}
} catch (e) {
if (e.code !== 'NEED_ROW_DATA') {
// If a rule is failing unexpectedly, give some information via memos.
// TODO: Could give a more structured result.
for (const prop of ALL_PERMISSION_PROPS) {
pset[prop].push(`Rule [${rule.aclFormula}] for ${ruleSet.tableId} has an error: ${e.message}`);
}
}
}
}
return pset;
}