gristlabs_grist-core/app/common/ACLRuleCollection.ts

432 lines
16 KiB
TypeScript
Raw Permalink Normal View History

import {parsePermissions} from 'app/common/ACLPermissions';
import {ILogger} from 'app/common/BaseAPI';
import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue} from 'app/common/gutil';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy');
const defaultMatchFunc: AclMatchFunc = () => true;
export const SPECIAL_RULES_TABLE_ID = '*SPECIAL';
// 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.Access in [EDITOR, OWNER]",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "user.Access in [VIEWER]",
matchFunc: (input) => ['viewers'].includes(String(input.user.Access)),
permissions: parsePermissions('+R-CUDS'),
permissionsText: '+R',
}, {
aclFormula: "",
matchFunc: defaultMatchFunc,
permissions: parsePermissions('none'),
permissionsText: 'none',
}],
};
const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
AccessRules: {
tableId: SPECIAL_RULES_TABLE_ID,
colIds: ['AccessRules'],
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
aclFormula: "",
matchFunc: defaultMatchFunc,
permissions: parsePermissions('-R'),
permissionsText: '-R',
}],
},
FullCopies: {
tableId: SPECIAL_RULES_TABLE_ID,
colIds: ['FullCopies'],
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
aclFormula: "",
matchFunc: defaultMatchFunc,
permissions: parsePermissions('-R'),
permissionsText: '-R',
}],
}
};
// If the user-created rules become dysfunctional, we can swap in this emergency set.
// It grants full access to owners, and no access to anyone else.
const EMERGENCY_RULE_SET: RuleSet = {
tableId: '*',
colIds: '*',
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "",
matchFunc: defaultMatchFunc,
permissions: parsePermissions('none'),
permissionsText: 'none',
}],
};
export class ACLRuleCollection {
// Store error if one occurs while reading rules. Rules are replaced with emergency rules
// in this case.
public ruleError: Error|undefined;
// 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 '*')
// Includes also SPECIAL_RULES_TABLE_ID.
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>();
// Rules for SPECIAL_RULES_TABLE_ID "columns".
private _specialRuleSets = 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>();
// Whether there are ANY user-defined rules.
public haveRules(): boolean {
return this._haveRules;
}
// Return the RuleSet for "tableId:colId", or undefined if there isn't one for this column.
public getColumnRuleSet(tableId: string, colId: string): RuleSet|undefined {
if (tableId === SPECIAL_RULES_TABLE_ID) { return this._specialRuleSets.get(colId); }
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;
}
// Returns a Map of user attribute name to the corresponding UserAttributeRule.
public getUserAttributeRules(): Map<string, UserAttributeRule> {
return this._userAttributeRules;
}
/**
* Update granular access from DocData.
*/
public async update(docData: DocData, options: ReadAclOptions) {
const {ruleSets, userAttributes} = this._safeReadAclRules(docData, options);
// Build a map of user characteristics rules.
const userAttributeMap = new Map<string, UserAttributeRule>();
for (const userAttr of userAttributes) {
userAttributeMap.set(userAttr.name, userAttr);
}
// Build maps of ACL rules.
const colRuleSets = new Map<string, RuleSet[]>();
const tableColMap = new Map<string, RuleSet>();
const tableRuleSets = new Map<string, RuleSet>();
const tableIds = new Set<string>();
let defaultRuleSet: RuleSet = DEFAULT_RULE_SET;
// Collect special rules, combining them with corresponding defaults.
const specialRuleSets = new Map<string, RuleSet>(Object.entries(SPECIAL_RULE_SETS));
for (const ruleSet of ruleSets) {
if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) {
const specialType = String(ruleSet.colIds);
const specialDefault = specialRuleSets.get(specialType);
if (!specialDefault) {
throw new Error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`);
}
specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]});
}
}
// Insert the special rule sets into colRuleSets.
for (const ruleSet of specialRuleSets.values()) {
getSetMapValue(colRuleSets, SPECIAL_RULES_TABLE_ID, () => []).push(ruleSet);
}
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],
};
} else {
// tableId of '*' cannot list particular columns.
throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`);
}
} else if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) {
// Skip, since we handled these separately earlier.
} else if (ruleSet.colIds === '*') {
tableIds.add(ruleSet.tableId);
if (tableRuleSets.has(ruleSet.tableId)) {
throw new Error(`Invalid duplicate default rule for ${ruleSet.tableId}`);
}
tableRuleSets.set(ruleSet.tableId, ruleSet);
} else {
tableIds.add(ruleSet.tableId);
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 = [...tableIds];
this._userAttributeRules = userAttributeMap;
this._specialRuleSets = specialRuleSets;
}
/**
* Check that all references to table and column IDs in ACL rules are valid.
*/
public checkDocEntities(docData: DocData) {
const tablesTable = docData.getMetaTable('_grist_Tables');
const columnsTable = docData.getMetaTable('_grist_Tables_column');
// Collect valid tableIds and check rules against those.
const validTableIds = new Set(tablesTable.getColValues('tableId'));
const invalidTables = this.getAllTableIds().filter(t => !validTableIds.has(t));
if (invalidTables.length > 0) {
throw new Error(`Invalid tables in rules: ${invalidTables.join(', ')}`);
}
// Collect valid columns, grouped by tableRef (rowId of table record).
const validColumns = new Map<number, Set<string>>(); // Map from tableRef to set of colIds.
const colTableRefs = columnsTable.getColValues('parentId');
for (const [i, colId] of columnsTable.getColValues('colId').entries()) {
getSetMapValue(validColumns, colTableRefs[i], () => new Set()).add(colId);
}
// For each table, check that any explicitly mentioned columns are valid.
for (const tableId of this.getAllTableIds()) {
const tableRef = tablesTable.findRow('tableId', tableId);
const validTableCols = validColumns.get(tableRef);
for (const ruleSet of this.getAllColumnRuleSets(tableId)) {
if (Array.isArray(ruleSet.colIds)) {
const invalidColIds = ruleSet.colIds.filter(c => !validTableCols?.has(c));
if (invalidColIds.length > 0) {
throw new Error(`Invalid columns in rules for table ${tableId}: ${invalidColIds.join(', ')}`);
}
}
}
}
// Check for valid tableId/lookupColId combinations in UserAttribute rules.
const invalidUAColumns: string[] = [];
for (const rule of this.getUserAttributeRules().values()) {
const tableRef = tablesTable.findRow('tableId', rule.tableId);
const colRef = columnsTable.findMatchingRowId({parentId: tableRef, colId: rule.lookupColId});
if (!colRef) {
invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`);
}
}
if (invalidUAColumns.length > 0) {
throw new Error(`Invalid columns in User Attribute rules: ${invalidUAColumns.join(', ')}`);
}
}
private _safeReadAclRules(docData: DocData, options: ReadAclOptions): ReadAclResults {
try {
this.ruleError = undefined;
return readAclRules(docData, options);
} catch (e) {
this.ruleError = e; // Report the error indirectly.
return {ruleSets: [EMERGENCY_RULE_SET], userAttributes: []};
}
}
}
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
// If true, call addHelperCols to add helper columns of restricted columns to rule sets.
// Used in the server for extra filtering, but not in the client, because:
// 1. They would show in the UI
// 2. They would be saved back after editing, causing them to accumulate
includeHelperCols?: boolean;
}
export interface ReadAclResults {
ruleSets: RuleSet[];
userAttributes: UserAttributeRule[];
}
/**
* For each column in colIds, return the colIds of any hidden helper columns it has,
* i.e. display columns of references, and conditional formatting rule columns.
*/
function getHelperCols(docData: DocData, tableId: string, colIds: string[], log: ILogger): string[] {
const tablesTable = docData.getMetaTable('_grist_Tables');
const columnsTable = docData.getMetaTable('_grist_Tables_column');
const fieldsTable = docData.getMetaTable('_grist_Views_section_field');
const tableRef = tablesTable.findRow('tableId', tableId);
if (!tableRef) {
return [];
}
const result: string[] = [];
for (const colId of colIds) {
const [column] = columnsTable.filterRecords({parentId: tableRef, colId});
if (!column) {
continue;
}
function addColsFromRefs(colRefs: unknown) {
if (!Array.isArray(colRefs)) {
return;
}
for (const colRef of colRefs) {
if (typeof colRef !== 'number') {
continue;
}
const extraCol = columnsTable.getRecord(colRef);
if (!extraCol) {
continue;
}
if (extraCol.colId.startsWith("gristHelper_") && extraCol.parentId === tableRef) {
result.push(extraCol.colId);
} else {
log.error(`Invalid helper column ${extraCol.colId} of ${tableId}:${colId}`);
}
}
}
function addColsFromMetaRecord(rec: MetaRowRecord<'_grist_Tables_column' | '_grist_Views_section_field'>) {
addColsFromRefs([rec.displayCol]);
addColsFromRefs(decodeObject(rec.rules));
}
addColsFromMetaRecord(column);
for (const field of fieldsTable.filterRecords({colRef: column.id})) {
addColsFromMetaRecord(field);
}
}
return result;
}
/**
* 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.
*/
function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getMetaTable('_grist_ACLResources');
const rulesTable = docData.getMetaTable('_grist_ACLRules');
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
// Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) {
getSetMapValue(rulesByResource, ruleRecord.resource, () => []).push(ruleRecord);
}
for (const [resourceId, rules] of rulesByResource.entries()) {
const resourceRec = resourcesTable.getRecord(resourceId);
if (!resourceRec) {
throw new Error(`ACLRule ${rules[0].id} 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;
const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(',');
if (includeHelperCols && Array.isArray(colIds)) {
colIds.push(...getHelperCols(docData, tableId, colIds, log));
}
const body: RulePart[] = [];
for (const rule of rules) {
if (rule.userAttributes) {
if (tableId !== '*' || colIds !== '*') {
throw new Error(`ACLRule ${rule.id} invalid; user attributes must be on the default resource`);
}
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(`User attribute rule ${rule.id} is invalid`);
}
parsed.origRecord = rule;
userAttributes.push(parsed as UserAttributeRule);
} else if (body.length > 0 && !body[body.length - 1].aclFormula) {
throw new Error(`ACLRule ${rule.id} invalid because listed after default rule`);
} else if (rule.aclFormula && !rule.aclFormulaParsed) {
throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);
} else {
const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
body.push({
origRecord: rule,
aclFormula: String(rule.aclFormula),
matchFunc: rule.aclFormula ? compile?.(aclFormulaParsed) : defaultMatchFunc,
memo: aclFormulaParsed && aclFormulaParsed[0] === 'Comment' && aclFormulaParsed[2],
permissions: parsePermissions(String(rule.permissionsText)),
permissionsText: String(rule.permissionsText),
});
}
}
const ruleSet: RuleSet = {tableId, colIds, body};
ruleSets.push(ruleSet);
}
return {ruleSets, userAttributes};
}