import {parsePermissions} from 'app/common/ACLPermissions'; import {AclRuleProblem} from 'app/common/ActiveDocAPI'; 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 = { 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', }], }, SeedRule: { tableId: SPECIAL_RULES_TABLE_ID, colIds: ['SeedRule'], body: [], } }; // 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(); // Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId). private _tableColumnMap = new Map(); // Rules for SPECIAL_RULES_TABLE_ID "columns". private _specialRuleSets = new Map(); // Map of tableId to the single default RuleSet for the table (colIds of '*') private _tableRuleSets = new Map(); // 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(); // 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:", 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 { 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(); for (const userAttr of userAttributes) { userAttributeMap.set(userAttr.name, userAttr); } // Build maps of ACL rules. const colRuleSets = new Map(); const tableColMap = new Map(); const tableRuleSets = new Map(); const tableIds = new Set(); let defaultRuleSet: RuleSet = DEFAULT_RULE_SET; // Collect special rules, combining them with corresponding defaults. const specialRuleSets = new Map(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) { // Log that we are seeing an invalid rule, but don't fail. // (Historically, older versions of the Grist app will attempt to // open newer documents). options.log.error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`); } else { 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 problems = this.findRuleProblems(docData); if (problems.length === 0) { return; } throw new Error(problems[0].comment); } /** * Enumerate rule problems caused by table and column IDs that are not valid. * Problems include: * - Rules for a table that does not exist * - Rules for columns that include a column that does not exist * - User attributes links to a column that does not exist */ public findRuleProblems(docData: DocData): AclRuleProblem[] { const problems: AclRuleProblem[] = []; 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) { problems.push({ tables: { tableIds: invalidTables, }, comment: `Invalid tables in rules: ${invalidTables.join(', ')}`, }); } // Collect valid columns, grouped by tableRef (rowId of table record). const validColumns = new Map>(); // 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 valid table, check that any explicitly mentioned columns are valid. for (const tableId of this.getAllTableIds()) { if (!validTableIds.has(tableId)) { continue; } 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) { problems.push({ columns: { tableId, colIds: invalidColIds, }, comment: `Invalid columns in rules for table ${tableId}: ${invalidColIds.join(', ')}`, }); } } } } // Check for valid tableId/lookupColId combinations in UserAttribute rules. const invalidUAColumns: string[] = []; const names: 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}`); names.push(rule.name); } } if (invalidUAColumns.length > 0) { problems.push({ userAttributes: { invalidUAColumns, names, }, comment: `Invalid columns in User Attribute rules: ${invalidUAColumns.join(', ')}`, }); } return problems; } 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>>(); 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: rule.memo, permissions: parsePermissions(String(rule.permissionsText)), permissionsText: String(rule.permissionsText), }); } } const ruleSet: RuleSet = {tableId, colIds, body}; ruleSets.push(ruleSet); } return {ruleSets, userAttributes}; }