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; } interface ShareContext { shareRef: number; sections: MetaRowRecord<"_grist_Views_section">[]; columns: MetaRowRecord<"_grist_Tables_column">[]; } /** * 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 sectionIds = new Set(sections.map(section => section.id)); const fields = this.docData.getMetaTable('_grist_Views_section_field').getRecords().filter( field => { return sectionIds.has(field.parentId); } ); const columnIds = new Set(fields.map(field => field.colRef)); const columns = this.docData.getMetaTable('_grist_Tables_column').getRecords().filter( column => { return columnIds.has(column.id); } ); 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: tighten access control on creation since it may be broader // than users expect - hidden columns could be written. for (const table of tables) { this._shareTableForForm(table, { shareRef: share.id, sections, columns, }); } } /** * 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'>, shareContext: ShareContext) { const { shareRef } = shareContext; const resource = this._findOrAddResource({ tableId: table.tableId, colIds: '*', // At creation, allow all columns to be // initialized. }); 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, shareContext); } /** * Give read access to referenced columns. */ private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, shareContext: ShareContext) { const { shareRef } = shareContext; const tables = this.docData.getMetaTable('_grist_Tables'); const columns = this.docData.getMetaTable('_grist_Tables_column'); const tableColumns = shareContext.columns.filter(c => c.parentId === table.id && (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; }