diff --git a/app/common/ACLRuleCollection.ts b/app/common/ACLRuleCollection.ts index 434dc151..f2f004dd 100644 --- a/app/common/ACLRuleCollection.ts +++ b/app/common/ACLRuleCollection.ts @@ -1,14 +1,12 @@ import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions'; import {AVAILABLE_BITS_COLUMNS, AVAILABLE_BITS_TABLES, trimPermissions} from 'app/common/ACLPermissions'; -import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules'; +import {ACLRulesReader} from 'app/common/ACLRulesReader'; import {AclRuleProblem} from 'app/common/ActiveDocAPI'; import {DocData} from 'app/common/DocData'; import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; import {getSetMapValue, isNonNullish} from 'app/common/gutil'; -import {ShareOptions} from 'app/common/ShareOptions'; import {MetaRowRecord} from 'app/common/TableData'; import {decodeObject} from 'app/plugin/objtypes'; -import sortBy = require('lodash/sortBy'); export type ILogger = Pick; @@ -463,39 +461,16 @@ function getHelperCols(docData: DocData, tableId: string, colIds: string[], log: * UserAttributeRules. This is used by both client-side code and server-side. */ function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults { - // Wrap resources and rules tables so we can have "virtual" rules - // to implement special shares. - const resourcesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLResources')); - const rulesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLRules')); - const sharesTable = docData.getMetaTable('_grist_Shares'); - const ruleSets: RuleSet[] = []; const userAttributes: UserAttributeRule[] = []; - let hasShares: boolean = false; - const shares = sharesTable.getRecords(); - // ACLShareRules is used to edit resourcesTable and rulesTable in place. - const shareRules = new ACLShareRules(docData, resourcesTable, rulesTable); - // Add virtual rules to implement shares, if there are any. - // Add the virtual rules only when implementing/interpreting them, as - // opposed to accessing them for presentation or manipulation in the UI. - if (enrichRulesForImplementation && shares.length > 0) { - for (const share of shares) { - const options: ShareOptions = JSON.parse(share.options || '{}'); - shareRules.addRulesForShare(share.id, options); - } - shareRules.addDefaultRulesForShares(); - hasShares = true; - } + const aclRulesReader = new ACLRulesReader(docData, { + addShareRules: enrichRulesForImplementation, + }); // 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); + for (const [resourceId, rules] of aclRulesReader.entries()) { + const resourceRec = aclRulesReader.getResourceById(resourceId); if (!resourceRec) { throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`); } @@ -531,13 +506,7 @@ function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementat } else if (rule.aclFormula && !rule.aclFormulaParsed) { throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`); } else { - let aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); - // If we have "virtual" rules to implement shares, then regular - // rules need to be tweaked so that they don't apply when the - // share is active. - if (hasShares && rule.id >= 0) { - aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed}); - } + const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); let permissions = parsePermissions(String(rule.permissionsText)); if (tableId !== '*' && tableId !== SPECIAL_RULES_TABLE_ID) { const availableBits = (colIds === '*') ? AVAILABLE_BITS_TABLES : AVAILABLE_BITS_COLUMNS; diff --git a/app/common/ACLRulesReader.ts b/app/common/ACLRulesReader.ts new file mode 100644 index 00000000..ed4154ce --- /dev/null +++ b/app/common/ACLRulesReader.ts @@ -0,0 +1,454 @@ +import { DocData } from 'app/common/DocData'; +import { getSetMapValue } from 'app/common/gutil'; +import { SchemaTypes } from 'app/common/schema'; +import { ShareOptions } from 'app/common/ShareOptions'; +import { MetaRowRecord, MetaTableData } from 'app/common/TableData'; +import { isEqual, sortBy } from 'lodash'; + +/** + * For special shares, we need to refer to resources that may not + * be listed in the _grist_ACLResources table, and have rules that + * aren't backed by storage in _grist_ACLRules. So we implement + * a small helper to add an overlay of extra resources and rules. + * They are distinguishable from real, stored resources and rules + * by having negative IDs. + */ +export class TableWithOverlay { + private _extraRecords = new Array>(); + private _extraRecordsById = new Map>(); + private _excludedRecordIds = new Set(); + private _nextFreeVirtualId: number = -1; + + public constructor(private _originalTable: MetaTableData) {} + + // Add a record to the table, but only as an overlay - no + // persistent changes are made. Uses negative row IDs. + // Returns the ID assigned to the record. The passed in + // record is expected to have an ID of zero. + public addRecord(rec: MetaRowRecord): number { + if (rec.id !== 0) { throw new Error('Expected a zero ID'); } + const id = this._nextFreeVirtualId; + const recWithCorrectId: MetaRowRecord = {...rec, id}; + this._extraRecords.push({...rec, id}); + this._extraRecordsById.set(id, recWithCorrectId); + this._nextFreeVirtualId--; + return id; + } + + public excludeRecord(id: number) { + this._excludedRecordIds.add(id); + } + + // Support the few MetaTableData methods we actually use + // in ACLRulesReader. + + public getRecord(id: number) { + if (this._excludedRecordIds.has(id)) { return undefined; } + + if (id < 0) { + // Reroute negative IDs to our local stash of records. + return this._extraRecordsById.get(id); + } else { + // Everything else, we just pass along. + return this._originalTable.getRecord(id); + } + } + + public getRecords() { + return this._filterExcludedRecords([ + ...this._originalTable.getRecords(), + ...this._extraRecords, + ]); + } + + public filterRecords(properties: Partial>): Array> { + const originalRecords = this._originalTable.filterRecords(properties); + const extraRecords = this._extraRecords.filter((rec) => Object.keys(properties) + .every((p) => isEqual((rec as any)[p], (properties as any)[p]))); + return this._filterExcludedRecords([...originalRecords, ...extraRecords]); + } + + public findMatchingRowId(properties: Partial>): number { + const rowId = ( + this._originalTable.findMatchingRowId(properties) || + this._extraRecords.find((rec) => Object.keys(properties).every((p) => + isEqual((rec as any)[p], (properties as any)[p])) + )?.id + ); + return rowId && !this._excludedRecordIds.has(rowId) ? rowId : 0; + } + + private _filterExcludedRecords(records: MetaRowRecord[]) { + return records.filter(({id}) => !this._excludedRecordIds.has(id)); + } +} + +export interface ACLRulesReaderOptions { + /** + * Adds virtual rules for all shares in the document. + * + * If set to `true` and there are shares in the document, regular rules are + * modified so that they don't apply when a document is being accessed through + * a share, and new rules are added to grant access to the resources specified by + * the shares. + * + * This will also "split" any resources (and their rules) if they apply to multiple + * resources. Splitting produces copies of the original resource and rules + * rules, but with modifications in place so that each copy applies to a single + * resource. Normalizing the original rules in this way allows for a simpler mechanism + * to override the original rules/resources with share rules, for situations where a + * share needs to grant access to a resource that is protected by access rules (shares + * and access rules are mutually exclusive at this time). + * + * Note: a value of `true` will *not* cause any persistent modifications to be made to + * rules; all changes are "virtual" in the sense that they are applied on top of the + * persisted rules to enable shares. + * + * Defaults to `false`. + */ + addShareRules?: boolean; +} + +/** + * Helper class for reading ACL rules from DocData. + */ +export class ACLRulesReader { + private _resourcesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLResources')); + private _rulesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLRules')); + private _sharesTable = this.docData.getMetaTable('_grist_Shares'); + private _hasShares = this._options.addShareRules && this._sharesTable.numRecords() > 0; + /** Maps 'tableId:colId' to the comma-separated list of column IDs from the associated resource. */ + private _resourceColIdsByTableAndColId: Map = new Map(); + + public constructor(public docData: DocData, private _options: ACLRulesReaderOptions = {}) { + this._addOriginalRules(); + this._maybeAddShareRules(); + } + + public entries() { + const rulesByResourceId = new Map>>(); + for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) { + // If we have "virtual" rules to implement shares, then regular + // rules need to be tweaked so that they don't apply when the + // share is active. + if (this._hasShares && rule.id >= 0) { + disableRuleInShare(rule); + } + + getSetMapValue(rulesByResourceId, rule.resource, () => []).push(rule); + } + return rulesByResourceId.entries(); + } + + public getResourceById(id: number) { + return this._resourcesTable.getRecord(id); + } + + private _addOriginalRules() { + for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) { + const resource = this.getResourceById(rule.resource); + if (!resource) { + throw new Error(`ACLRule ${rule.id} refers to an invalid ACLResource ${rule.resource}`); + } + + if (resource.tableId !== '*' && resource.colIds !== '*') { + const colIds = resource.colIds.split(','); + if (colIds.length === 1) { continue; } + + for (const colId of colIds) { + this._resourceColIdsByTableAndColId.set(`${resource.tableId}:${colId}`, resource.colIds); + } + } + } + } + + private _maybeAddShareRules() { + if (!this._hasShares) { return; } + + for (const share of this._sharesTable.getRecords()) { + this._addRulesForShare(share); + } + this._addDefaultShareRules(); + } + + /** + * Add any rules needed for the specified share. + * + * The only kind of share we support for now is form endpoint + * sharing. + */ + private _addRulesForShare(share: MetaRowRecord<'_grist_Shares'>) { + // TODO: Unpublished shares could and should be blocked earlier, + // by home server + const {publish}: ShareOptions = JSON.parse(share.options || '{}'); + if (!publish) { + this._blockShare(share.id); + return; + } + + // Let's go looking for sections related to the share. + // It was decided that the relationship between sections and + // shares is via pages. Every section on a given page can belong + // to at most one share. + // Ignore sections which do not have `publish` set to `true` in + // `shareOptions`. + const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({ + shareRef: share.id, + }); + const parentViews = new Set(pages.map(page => page.viewRef)); + const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter( + section => { + if (!parentViews.has(section.parentId)) { return false; } + const options = JSON.parse(section.shareOptions || '{}'); + return Boolean(options.publish) && Boolean(options.form); + } + ); + + const tableRefs = new Set(sections.map(section => section.tableRef)); + const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter( + table => tableRefs.has(table.id) + ); + + // For tables associated with forms, allow creation of records, + // and reading of referenced columns. + // TODO: should probably be limiting to a set of columns associated + // with section - but for form widget that could potentially be very + // confusing since it may not be easy to see that certain columns + // haven't been made visible for it? For now, just working at table + // level. + for (const table of tables) { + this._shareTableForForm(table, share.id); + } + } + + /** + * When accessing a document via a share, by default no user tables are + * accessible. Everything added to the share gives additional + * access, and never reduces access, making it easy to grant + * access to multiple parts of the document. + * + * We do leave access unchanged for metadata tables, since they are + * censored via an alternative mechanism. + */ + private _addDefaultShareRules() { + // Block access to each table. + const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords() + .map(table => table.tableId) + .filter(tableId => !tableId.startsWith('_grist_')) + .sort(); + for (const tableId of tableIds) { + this._addShareRule(this._findOrAddResource({tableId, colIds: '*'}), '-CRUDS'); + } + + // Block schema access at the default level. + this._addShareRule(this._findOrAddResource({tableId: '*', colIds: '*'}), '-S'); + } + + /** + * Allow creating records in a table. + */ + private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>, + shareRef: number) { + const resource = this._findOrAddResource({ + tableId: table.tableId, + colIds: '*', + }); + let aclFormula = `user.ShareRef == ${shareRef}`; + let aclFormulaParsed = JSON.stringify([ + 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + [ 'Const', shareRef ] ]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+C', + })); + + // This is a hack to grant read schema access, needed for forms - + // Should not be needed once forms are actually available, but + // until them is very handy to allow using the web client to + // submit records. + aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`; + aclFormulaParsed = JSON.stringify( + [ 'And', + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ], + [ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+R', + })); + + this._shareTableReferencesForForm(table, shareRef); + } + + /** + * Give read access to referenced columns. + */ + private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, + shareRef: number) { + const tables = this.docData.getMetaTable('_grist_Tables'); + const columns = this.docData.getMetaTable('_grist_Tables_column'); + const tableColumns = columns.filterRecords({ + parentId: table.id, + }).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:')); + for (const column of tableColumns) { + const visibleColRef = column.visibleCol; + // This could be blank in tests, not sure about real life. + if (!visibleColRef) { continue; } + const visibleCol = columns.getRecord(visibleColRef); + if (!visibleCol) { continue; } + const referencedTable = tables.getRecord(visibleCol.parentId); + if (!referencedTable) { continue; } + + const tableId = referencedTable.tableId; + const colId = visibleCol.colId; + const resourceColIds = this._resourceColIdsByTableAndColId.get(`${tableId}:${colId}`) ?? colId; + const maybeResourceId = this._resourcesTable.findMatchingRowId({tableId, colIds: resourceColIds}); + if (maybeResourceId !== 0) { + this._maybeSplitResourceForShares(maybeResourceId); + } + const resource = this._findOrAddResource({tableId, colIds: colId}); + const aclFormula = `user.ShareRef == ${shareRef}`; + const aclFormulaParsed = JSON.stringify( + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+R', + })); + } + } + + /** + * Splits a resource into multiple resources that are suitable for being + * overridden by shares. Rules are copied to each resource, with modifications + * that disable them in shares. + * + * Ignores resources for single columns, and resources created for shares + * (i.e. those with a negative ID); the former can already be overridden + * by shares without any additional work, and the latter are guaranteed to + * only be for single columns. + * + * The motivation for this method is to normalize document access rules so + * that rule sets apply to at most a single column. Document shares may + * automatically grant limited access to parts of a document, such as columns + * that are referenced from a form field. But for this to happen, extra rules + * first need to be added to the original or new resource, which requires looking + * up the resource by column ID to see if it exists. This lookup only works if + * the rule set of the resource is for a single column; otherwise, the lookup + * will fail and cause a new resource to be created, which consequently causes + * 2 resources to exist that both contain the same column. Since this is an + * unsupported scenario with ambiguous evaluation semantics, we pre-emptively call + * this method to avoid such scenarios altogether. + */ + private _maybeSplitResourceForShares(resourceId: number) { + if (resourceId < 0) { return; } + + const resource = this.getResourceById(resourceId); + if (!resource) { + throw new Error(`Unable to find ACLResource with ID ${resourceId}`); + } + + const {tableId} = resource; + const colIds = resource.colIds.split(','); + if (colIds.length === 1) { return; } + + const rules = sortBy(this._rulesTable.filterRecords({resource: resourceId}), 'rulePos') + .map(r => disableRuleInShare(r)); + // Prepare a new resource for each column, with copies of the original resource's rules. + for (const colId of colIds) { + const newResourceId = this._resourcesTable.addRecord({id: 0, tableId, colIds: colId}); + for (const rule of rules) { + this._rulesTable.addRecord({...rule, id: 0, resource: newResourceId}); + } + } + // Exclude the original resource and rules. + this._resourcesTable.excludeRecord(resourceId); + for (const rule of rules) { + this._rulesTable.excludeRecord(rule.id); + } + } + + /** + * Find a resource we need, and return its rowId. The resource is + * added if it is not already present. + */ + private _findOrAddResource(properties: { + tableId: string, + colIds: string, + }): number { + const resource = this._resourcesTable.findMatchingRowId(properties); + if (resource !== 0) { return resource; } + return this._resourcesTable.addRecord({ + id: 0, + ...properties, + }); + } + + private _addShareRule(resourceRef: number, permissionsText: string) { + const aclFormula = 'user.ShareRef is not None'; + const aclFormulaParsed = JSON.stringify([ + 'NotEq', + ['Attr', ['Name', 'user'], 'ShareRef'], + ['Const', null], + ]); + this._rulesTable.addRecord(this._makeRule({ + resource: resourceRef, aclFormula, aclFormulaParsed, permissionsText, + })); + } + + private _blockShare(shareRef: number) { + const resource = this._findOrAddResource({ + tableId: '*', colIds: '*', + }); + const aclFormula = `user.ShareRef == ${shareRef}`; + const aclFormulaParsed = JSON.stringify( + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ]); + this._rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', + })); + } + + private _makeRule(options: { + resource: number, + aclFormula: string, + aclFormulaParsed: string, + permissionsText: string, + }): MetaRowRecord<'_grist_ACLRules'> { + const {resource, aclFormula, aclFormulaParsed, permissionsText} = options; + return { + id: 0, + resource, + aclFormula, + aclFormulaParsed, + memo: '', + permissionsText, + userAttributes: '', + rulePos: 0, + + // The following fields are unused and deprecated. + aclColumn: 0, + permissions: 0, + principals: '', + }; + } +} + +/** + * Updates the ACL formula of `rule` such that it's disabled if a document is being + * accessed via a share. + * + * Modifies `rule` in place. + */ +function disableRuleInShare(rule: MetaRowRecord<'_grist_ACLRules'>) { + const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); + const newAclFormulaParsed = [ + 'And', + [ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ], + aclFormulaParsed || [ 'Const', true ] + ]; + rule.aclFormula = 'user.ShareRef is None and (' + String(rule.aclFormula || 'True') + ')'; + rule.aclFormulaParsed = JSON.stringify(newAclFormulaParsed); + return rule; +} diff --git a/app/common/ACLShareRules.ts b/app/common/ACLShareRules.ts deleted file mode 100644 index 24e3c2ea..00000000 --- a/app/common/ACLShareRules.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { DocData } from 'app/common/DocData'; -import { SchemaTypes } from 'app/common/schema'; -import { ShareOptions } from 'app/common/ShareOptions'; -import { MetaRowRecord, MetaTableData } from 'app/common/TableData'; -import { isEqual } from 'lodash'; - -/** - * For special shares, we need to refer to resources that may not - * be listed in the _grist_ACLResources table, and have rules that - * aren't backed by storage in _grist_ACLRules. So we implement - * a small helper to add an overlay of extra resources and rules. - * They are distinguishable from real, stored resources and rules - * by having negative IDs. - */ -export class TableWithOverlay { - private _extraRecords = new Array>(); - private _extraRecordsById = new Map>(); - private _nextFreeVirtualId: number = -1; - - public constructor(private _originalTable: MetaTableData) {} - - // Add a record to the table, but only as an overlay - no - // persistent changes are made. Uses negative row IDs. - // Returns the ID assigned to the record. The passed in - // record is expected to have an ID of zero. - public addRecord(rec: MetaRowRecord): number { - if (rec.id !== 0) { throw new Error('Expected a zero ID'); } - const id = this._nextFreeVirtualId; - const recWithCorrectId: MetaRowRecord = {...rec, id}; - this._extraRecords.push({...rec, id}); - this._extraRecordsById.set(id, recWithCorrectId); - this._nextFreeVirtualId--; - return id; - } - - // Support the few MetaTableData methods we actually use - // in ACLRuleCollection and ACLShareRules. - - public getRecord(resourceId: number) { - // Reroute negative IDs to our local stash of records. - if (resourceId < 0) { - return this._extraRecordsById.get(resourceId); - } - // Everything else, we just pass along. - return this._originalTable.getRecord(resourceId); - } - - public getRecords() { - return [...this._originalTable.getRecords(), ...this._extraRecords]; - } - - public findMatchingRowId(properties: Partial>): number { - // Check stored records. - const rowId = this._originalTable.findMatchingRowId(properties); - if (rowId) { return rowId; } - // Check overlay. - return this._extraRecords.find((rec) => - Object.keys(properties).every((p) => isEqual( - (rec as any)[p], - (properties as any)[p])))?.id || 0; - } -} - -/** - * Helper for managing special share rules. - */ -export class ACLShareRules { - - public constructor( - public docData: DocData, - public resourcesTable: TableWithOverlay<'_grist_ACLResources'>, - public rulesTable: TableWithOverlay<'_grist_ACLRules'>, - ) {} - - /** - * Add any rules needed for the specified share. - * - * The only kind of share we support for now is form endpoint - * sharing. - */ - public addRulesForShare(shareRef: number, shareOptions: ShareOptions) { - // TODO: Unpublished shares could and should be blocked earlier, - // by home server - if (!shareOptions.publish) { - this._blockShare(shareRef); - return; - } - - // Let's go looking for sections related to the share. - // It was decided that the relationship between sections and - // shares is via pages. Every section on a given page can belong - // to at most one share. - // Ignore sections which do not have `publish` set to `true` in - // `shareOptions`. - const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({ - shareRef, - }); - const parentViews = new Set(pages.map(page => page.viewRef)); - const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter( - section => { - if (!parentViews.has(section.parentId)) { return false; } - const options = JSON.parse(section.shareOptions || '{}'); - return Boolean(options.publish) && Boolean(options.form); - } - ); - - const tableRefs = new Set(sections.map(section => section.tableRef)); - const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter( - table => tableRefs.has(table.id) - ); - - // For tables associated with forms, allow creation of records, - // and reading of referenced columns. - // TODO: should probably be limiting to a set of columns associated - // with section - but for form widget that could potentially be very - // confusing since it may not be easy to see that certain columns - // haven't been made visible for it? For now, just working at table - // level. - for (const table of tables) { - this._shareTableForForm(table, shareRef); - } - } - - /** - * When accessing a document via a share, by default no user tables are - * accessible. Everything added to the share gives additional - * access, and never reduces access, making it easy to grant - * access to multiple parts of the document. - * - * We do leave access unchanged for metadata tables, since they are - * censored via an alternative mechanism. - */ - public addDefaultRulesForShares() { - const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords() - .map(table => table.tableId) - .filter(tableId => !tableId.startsWith('_grist_')) - .sort(); - for (const tableId of tableIds) { - const resource = this._findOrAddResource({ - tableId, colIds: '*', - }); - const aclFormula = `user.ShareRef is not None`; - const aclFormulaParsed = JSON.stringify([ - 'NotEq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', null] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', - })); - } - } - - /** - * When accessing a document via a share, any regular granular access - * rules should not apply. This requires an extra conditional. - */ - public transformNonShareRules(state: { - rule: MetaRowRecord<'_grist_ACLRules'>, - aclFormulaParsed: object, - }) { - state.rule.aclFormula = 'user.ShareRef is None and (' + String(state.rule.aclFormula || 'True') + ')'; - state.aclFormulaParsed = [ - 'And', - [ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ], - state.aclFormulaParsed || [ 'Const', true ] - ]; - state.rule.aclFormulaParsed = JSON.stringify(state.aclFormulaParsed); - return state.aclFormulaParsed; - } - - /** - * Allow creating records in a table. - */ - private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>, - shareRef: number) { - const resource = this._findOrAddResource({ - tableId: table.tableId, - colIds: '*', - }); - let aclFormula = `user.ShareRef == ${shareRef}`; - let aclFormulaParsed = JSON.stringify([ - 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - [ 'Const', shareRef ] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '+C', - })); - - // This is a hack to grant read schema access, needed for forms - - // Should not be needed once forms are actually available, but - // until them is very handy to allow using the web client to - // submit records. - aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`; - aclFormulaParsed = JSON.stringify( - [ 'And', - [ 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', shareRef] ], - [ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '+R', - })); - - this._shareTableReferencesForForm(table, shareRef); - } - - /** - * Give read access to referenced columns. - */ - private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, - shareRef: number) { - const tables = this.docData.getMetaTable('_grist_Tables'); - const columns = this.docData.getMetaTable('_grist_Tables_column'); - const tableColumns = columns.filterRecords({ - parentId: table.id, - }).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:')); - for (const column of tableColumns) { - const visibleColRef = column.visibleCol; - // This could be blank in tests, not sure about real life. - if (!visibleColRef) { continue; } - const visibleCol = columns.getRecord(visibleColRef); - if (!visibleCol) { continue; } - const referencedTable = tables.getRecord(visibleCol.parentId); - if (!referencedTable) { continue; } - - const tableId = referencedTable.tableId; - const colId = visibleCol.colId; - const resource = this._findOrAddResource({ - tableId: tableId, - colIds: colId, - }); - const aclFormula = `user.ShareRef == ${shareRef}`; - const aclFormulaParsed = JSON.stringify( - [ 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', shareRef] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '+R', - })); - } - } - - /** - * Find a resource we need, and return its rowId. The resource is - * added if it is not already present. - */ - private _findOrAddResource(properties: { - tableId: string, - colIds: string, - }): number { - const resource = this.resourcesTable.findMatchingRowId(properties); - if (resource !== 0) { return resource; } - return this.resourcesTable.addRecord({ - id: 0, - ...properties, - }); - } - - private _blockShare(shareRef: number) { - const resource = this._findOrAddResource({ - tableId: '*', colIds: '*', - }); - const aclFormula = `user.ShareRef == ${shareRef}`; - const aclFormulaParsed = JSON.stringify( - [ 'Eq', - [ 'Attr', [ "Name", "user" ], "ShareRef" ], - ['Const', shareRef] ]); - this.rulesTable.addRecord(this._makeRule({ - resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', - })); - } - - private _makeRule(options: { - resource: number, - aclFormula: string, - aclFormulaParsed: string, - permissionsText: string, - }): MetaRowRecord<'_grist_ACLRules'> { - const {resource, aclFormula, aclFormulaParsed, permissionsText} = options; - return { - id: 0, - resource, - aclFormula, - aclFormulaParsed, - memo: '', - permissionsText, - userAttributes: '', - rulePos: 0, - - // The following fields are unused and deprecated. - aclColumn: 0, - permissions: 0, - principals: '', - }; - } -} diff --git a/test/server/lib/ACLRulesReader.ts b/test/server/lib/ACLRulesReader.ts new file mode 100644 index 00000000..7d91a359 --- /dev/null +++ b/test/server/lib/ACLRulesReader.ts @@ -0,0 +1,443 @@ +import {ACLRulesReader} from 'app/common/ACLRulesReader'; +import {DocData} from 'app/common/DocData'; +import {MetaRowRecord} from 'app/common/TableData'; +import {CellValue} from 'app/plugin/GristData'; +import {ActiveDoc} from 'app/server/lib/ActiveDoc'; +import {makeExceptionalDocSession} from 'app/server/lib/DocSession'; +import {assert} from 'chai'; +import * as sinon from 'sinon'; +import {createDocTools} from 'test/server/docTools'; + +describe('ACLRulesReader', function() { + this.timeout(10000); + + const docTools = createDocTools({persistAcrossCases: true}); + const fakeSession = makeExceptionalDocSession('system'); + + let activeDoc: ActiveDoc; + let docData: DocData; + + before(async function () { + activeDoc = await docTools.createDoc('ACLRulesReader'); + docData = activeDoc.docData!; + }); + + describe('without shares', function() { + it('entries', async function() { + // Check output of reading the resources and rules of an empty document. + for (const options of [undefined, {addShareRules: true}]) { + assertResourcesAndRules(new ACLRulesReader(docData, options), [ + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + } + + // Add some table and default rules and re-check output. + await activeDoc.applyUserActions(fakeSession, [ + ['AddTable', 'Private', [{id: 'A'}]], + ['AddTable', 'PartialPrivate', [{id: 'A'}]], + ['AddRecord', 'PartialPrivate', null, { A: 0 }], + ['AddRecord', 'PartialPrivate', null, { A: 1 }], + ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -3, {tableId: 'PartialPrivate', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, + aclFormula: 'user.Access == "owners"', + permissionsText: 'all', + memo: 'owner check', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: '', permissionsText: 'none', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-S', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -3, aclFormula: 'user.Access != "owners" and rec.A > 0', permissionsText: 'none', + }], + ['AddTable', 'Public', [{id: 'A'}]], + ]); + for (const options of [undefined, {addShareRules: true}]) { + assertResourcesAndRules(new ACLRulesReader(docData, options), [ + { + resource: {id: 2, tableId: 'Private', colIds: '*'}, + rules: [ + { + aclFormula: 'user.Access == "owners"', + permissionsText: 'all', + }, + { + aclFormula: '', + permissionsText: 'none', + }, + ], + }, + { + resource: {id: 3, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'user.Access != "owners"', + permissionsText: '-S', + }, + ], + }, + { + resource: {id: 4, tableId: 'PartialPrivate', colIds: '*'}, + rules: [ + { + aclFormula: 'user.Access != "owners" and rec.A > 0', + permissionsText: 'none', + }, + ], + }, + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + } + }); + + it('getResourceById', async function() { + for (const options of [undefined, {addShareRules: true}]) { + // Check output of valid resource ids. + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(1), + {id: 1, tableId: '', colIds: ''} + ); + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(2), + {id: 2, tableId: 'Private', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(3), + {id: 3, tableId: '*', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, options).getResourceById(4), + {id: 4, tableId: 'PartialPrivate', colIds: '*'} + ); + + // Check output of non-existent resource ids. + assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(5)); + assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(0)); + assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(-1)); + } + }); + }); + + describe('with shares', function() { + before(async function() { + sinon.stub(ActiveDoc.prototype as any, '_getHomeDbManagerOrFail').returns({ + syncShares: () => Promise.resolve(), + }); + activeDoc = await docTools.loadFixtureDoc('FilmsWithImages.grist'); + docData = activeDoc.docData!; + await activeDoc.applyUserActions(fakeSession, [ + ['AddRecord', '_grist_Shares', null, { + linkId: 'x', + options: '{"publish": true}' + }], + ]); + }); + + after(function() { + sinon.restore(); + }); + + it('entries', async function() { + // Check output of reading the resources and rules of an empty document. + assertResourcesAndRules(new ACLRulesReader(docData), [ + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + + // Check output of reading the resources and rules of an empty document, with share rules. + assertResourcesAndRules(new ACLRulesReader(docData, {addShareRules: true}), [ + { + resource: {id: -1, tableId: 'Films', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: -2, tableId: 'Friends', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: -3, tableId: 'Performances', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: -4, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-S', + }, + ], + }, + { + resource: {id: 1, tableId: '', colIds: ''}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: '', + }, + ], + }, + ]); + + // Add some default, table, and column rules. + await activeDoc.applyUserActions(fakeSession, [ + ['UpdateRecord', '_grist_Views_section', 7, + {shareOptions: '{"publish": true, "form": true}'}], + ['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}], + ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Poster,PosterDup'}], + ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -3, {tableId: '*', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: 'user.access != OWNER', permissionsText: '-R', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -2, aclFormula: 'True', permissionsText: 'all', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -3, aclFormula: 'True', permissionsText: 'all', + }], + ]); + + // Re-check output without share rules. + assertResourcesAndRules(new ACLRulesReader(docData), [ + { + resource: {id: 2, tableId: 'Films', colIds: 'Title,Poster,PosterDup'}, + rules: [ + { + aclFormula: 'user.access != OWNER', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: 3, tableId: 'Films', colIds: '*'}, + rules: [ + { + aclFormula: 'True', + permissionsText: 'all', + }, + ], + }, + { + resource: {id: 4, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'True', + permissionsText: 'all', + }, + ], + }, + DEFAULT_UNUSED_RESOURCE_AND_RULE, + ]); + + // Re-check output with share rules. + assertResourcesAndRules(new ACLRulesReader(docData, {addShareRules: true}), [ + { + resource: {id: -1, tableId: 'Friends', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef == 1', + permissionsText: '+C', + }, + { + aclFormula: 'user.ShareRef == 1 and rec.id == 0', + permissionsText: '+R', + }, + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + // Resource -2, -3, and -4, were split from resource 2. + { + resource: {id: -2, tableId: 'Films', colIds: 'Title'}, + rules: [ + { + aclFormula: 'user.ShareRef == 1', + permissionsText: '+R', + }, + { + aclFormula: 'user.ShareRef is None and (user.access != OWNER)', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: 3, tableId: 'Films', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: 'all', + }, + ], + }, + { + resource: {id: -5, tableId: 'Performances', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-CRUDS', + }, + ], + }, + { + resource: {id: 4, tableId: '*', colIds: '*'}, + rules: [ + { + aclFormula: 'user.ShareRef is not None', + permissionsText: '-S', + }, + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: 'all', + }, + ], + }, + // Resource -3 and -4 were split from resource 2. + { + resource: {id: -3, tableId: 'Films', colIds: 'Poster'}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (user.access != OWNER)', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: -4, tableId: 'Films', colIds: 'PosterDup'}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (user.access != OWNER)', + permissionsText: '-R', + }, + ], + }, + { + resource: {id: 1, tableId: '', colIds: ''}, + rules: [ + { + aclFormula: 'user.ShareRef is None and (True)', + permissionsText: '', + }, + ], + }, + ]); + }); + + it('getResourceById', async function() { + // Check output of valid resource ids. + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(1), + {id: 1, tableId: '', colIds: ''} + ); + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(2), + {id: 2, tableId: 'Films', colIds: 'Title,Poster,PosterDup'} + ); + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(3), + {id: 3, tableId: 'Films', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData).getResourceById(4), + {id: 4, tableId: '*', colIds: '*'} + ); + + // Check output of non-existent resource ids. + assert.isUndefined(new ACLRulesReader(docData).getResourceById(5)); + assert.isUndefined(new ACLRulesReader(docData).getResourceById(0)); + assert.isUndefined(new ACLRulesReader(docData).getResourceById(-1)); + + // Check output of valid resource ids (with share rules). + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(1), + {id: 1, tableId: '', colIds: ''} + ); + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(2)); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(3), + {id: 3, tableId: 'Films', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(4), + {id: 4, tableId: '*', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-1), + {id: -1, tableId: 'Friends', colIds: '*'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-2), + {id: -2, tableId: 'Films', colIds: 'Title'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-3), + {id: -3, tableId: 'Films', colIds: 'Poster'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-4), + {id: -4, tableId: 'Films', colIds: 'PosterDup'} + ); + assert.deepEqual( + new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-5), + {id: -5, tableId: 'Performances', colIds: '*'} + ); + + // Check output of non-existent resource ids (with share rules). + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(5)); + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(0)); + assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-6)); + }); + }); +}); + +interface ACLResourceAndRules { + resource: MetaRowRecord<'_grist_ACLResources'>|undefined; + rules: {aclFormula: CellValue, permissionsText: CellValue}[]; +} + +function assertResourcesAndRules( + aclRulesReader: ACLRulesReader, + expected: ACLResourceAndRules[] +) { + const actual: ACLResourceAndRules[] = [...aclRulesReader.entries()].map(([resourceId, rules]) => { + return { + resource: aclRulesReader.getResourceById(resourceId), + rules: rules.map(({aclFormula, permissionsText}) => ({aclFormula, permissionsText})), + }; + }); + assert.deepEqual(actual, expected); +} + +/** + * An unused resource and rule that's automatically included in every Grist document. + * + * See comment in `UserActions.InitNewDoc` (from `useractions.py`) for context. + */ +const DEFAULT_UNUSED_RESOURCE_AND_RULE: ACLResourceAndRules = { + resource: {id: 1, tableId: '', colIds: ''}, + rules: [{aclFormula: '', permissionsText: ''}], +};