gristlabs_grist-core/app/server/lib/PermissionInfo.ts
Paul Fitzpatrick 6af811f7ab (core) give more detailed reasons for access denied when memos are present
Summary:
With this change, if a comment is added to an ACL formula, then that comment will be offered to the user if access is denied and that rule could potentially have granted access.

The code is factored so that when access is permitted, or when partially visible tables are being filtered, there is little overhead. Comments are gathered only when an explicit denial of access.

Test Plan: added tests, updated tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2730
2021-02-15 17:02:24 -05:00

240 lines
9.1 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';
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);
}
}
/**
* 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> {
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: '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()
};
}
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;
}