diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 4e902f08..7f0e5e5e 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -5,6 +5,7 @@ */ import * as assert from 'assert'; +import {Mutex} from 'async-mutex'; import * as bluebird from 'bluebird'; import {EventEmitter} from 'events'; import {IMessage, MsgType} from 'grain-rpc'; @@ -101,6 +102,11 @@ export class ActiveDoc extends EventEmitter { protected _docManager: DocManager; protected _docName: string; protected _sharing: Sharing; + // This lock is used to avoid reading sandbox state while it is being modified but before + // the result has been confirmed to pass granular access rules (which may depend on the + // result). + protected _modificationLock: Mutex = new Mutex(); + private readonly _dataEngine: ISandbox; private _activeDocImport: ActiveDocImport; private _onDemandActions: OnDemandActions; @@ -282,11 +288,11 @@ export class ActiveDoc extends EventEmitter { public async createDoc(docSession: OptDocSession): Promise { this.logDebug(docSession, "createDoc"); await this.docStorage.createFile(); - await this._dataEngine.pyCall('load_empty'); + await this._rawPyCall('load_empty'); const timezone = docSession.browserSettings ? docSession.browserSettings.timezone : DEFAULT_TIMEZONE; // This init action is special. It creates schema tables, and is used to init the DB, but does // not go through other steps of a regular action (no ActionHistory or broadcasting). - const initBundle = await this._dataEngine.pyCall('apply_user_actions', [["InitNewDoc", timezone]]); + const initBundle = await this._rawPyCall('apply_user_actions', [["InitNewDoc", timezone]]); await this.docStorage.execTransaction(() => this.docStorage.applyStoredActions(getEnvContent(initBundle.stored))); @@ -390,7 +396,7 @@ export class ActiveDoc extends EventEmitter { * Finish initializing ActiveDoc, by initializing ActionHistory, Sharing, and docData. */ public async _initDoc(docSession: OptDocSession|null): Promise { - const metaTableData = await this._dataEngine.pyCall('fetch_meta_tables'); + const metaTableData = await this._rawPyCall('fetch_meta_tables'); this.docData = new DocData(tableId => this.fetchTable(makeExceptionalDocSession('system'), tableId), metaTableData); this._onDemandActions = new OnDemandActions(this.docStorage, this.docData); @@ -399,7 +405,7 @@ export class ActiveDoc extends EventEmitter { return this._fetchQueryFromDB(query, false); }); await this._granularAccess.update(); - this._sharing = new Sharing(this, this._actionHistory); + this._sharing = new Sharing(this, this._actionHistory, this._modificationLock); await this.openSharedDoc(docSession); } @@ -628,7 +634,7 @@ export class ActiveDoc extends EventEmitter { public async fetchTableSchema(docSession: DocSession): Promise { this.logInfo(docSession, "fetchTableSchema(%s)", docSession); await this.waitForInitialization(); - return this._dataEngine.pyCall('fetch_table_schema'); + return this._pyCall('fetch_table_schema'); } /** @@ -673,7 +679,7 @@ export class ActiveDoc extends EventEmitter { if (!this._granularAccess.canReadEverything(docSession)) { return []; } this.logInfo(docSession, "findColFromValues(%s, %s, %s)", docSession, values, n); await this.waitForInitialization(); - return this._dataEngine.pyCall('find_col_from_values', values, n, optTableId); + return this._pyCall('find_col_from_values', values, n, optTableId); } /** @@ -689,7 +695,7 @@ export class ActiveDoc extends EventEmitter { this.logInfo(docSession, "getFormulaError(%s, %s, %s, %s)", docSession, tableId, colId, rowId); await this.waitForInitialization(); - return this._dataEngine.pyCall('get_formula_error', tableId, colId, rowId); + return this._pyCall('get_formula_error', tableId, colId, rowId); } /** @@ -825,7 +831,7 @@ export class ActiveDoc extends EventEmitter { // Autocompletion can leak names of tables and columns. if (!this._granularAccess.canReadEverything(docSession)) { return []; } await this.waitForInitialization(); - return this._dataEngine.pyCall('autocomplete', txt, tableId); + return this._pyCall('autocomplete', txt, tableId); } public fetchURL(docSession: DocSession, url: string): Promise { @@ -893,6 +899,8 @@ export class ActiveDoc extends EventEmitter { /** * Applies normal actions to the data engine while processing onDemand actions separately. + * Should only be called by a Sharing object, with this._modificationLock held, since the + * actions may need to be rolled back if final access control checks fail. */ public async applyActionsToDataEngine(userActions: UserAction[]): Promise { const [normalActions, onDemandActions] = this._onDemandActions.splitByOnDemand(userActions); @@ -903,7 +911,7 @@ export class ActiveDoc extends EventEmitter { if (normalActions[0][0] !== 'Calculate') { await this.waitForInitialization(); } - sandboxActionBundle = await this._dataEngine.pyCall('apply_user_actions', normalActions); + sandboxActionBundle = await this._rawPyCall('apply_user_actions', normalActions); await this._reportDataEngineMemory(); } else { // Create default SandboxActionBundle to use if the data engine is not called. @@ -929,12 +937,12 @@ export class ActiveDoc extends EventEmitter { public async fetchSnapshot() { await this.waitForInitialization(); - return this._dataEngine.pyCall('fetch_snapshot'); + return this._pyCall('fetch_snapshot'); } // Needed for test/server/migrations.js tests public async testGetVersionFromDataEngine() { - return this._dataEngine.pyCall('get_version'); + return this._pyCall('get_version'); } // Needed for test/server/lib/HostedStorageManager.ts tests @@ -959,12 +967,33 @@ export class ActiveDoc extends EventEmitter { return this._docManager.makeAccessId(userId); } - public async beforeBroadcast(docActions: DocAction[], undo: DocAction[]) { - await this._granularAccess.beforeBroadcast(docActions, undo); + /** + * Called by Sharing manager when working on modifying the document. + * Called when DocActions have been produced from UserActions, but + * before those DocActions have been applied to the DB, to confirm + * that those DocActions are legal according to any granular access + * rules. + */ + public async canApplyDocActions(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[]) { + return this._granularAccess.canApplyDocActions(docSession, docActions, undo); } - public async afterBroadcast() { - await this._granularAccess.afterBroadcast(); + /** + * Called by Sharing manager when working on modifying the document. + * Called when DocActions have been produced from UserActions, and + * have been applied to the DB, but before the changes have been + * broadcast to clients. + */ + public async appliedActions(docActions: DocAction[], undo: DocAction[]) { + await this._granularAccess.appliedActions(docActions, undo); + } + + /** + * Called by Sharing manager when done working on modifying the document, + * regardless of whether the modification succeeded or failed. + */ + public async finishedActions() { + await this._granularAccess.finishedActions(); } /** @@ -986,7 +1015,7 @@ export class ActiveDoc extends EventEmitter { protected async _loadOpenDoc(docSession: OptDocSession): Promise { // Fetch the schema version of document and sandbox, and migrate if the sandbox is newer. const [schemaVersion, docInfoData] = await Promise.all([ - this._dataEngine.pyCall('get_version'), + this._rawPyCall('get_version'), this.docStorage.fetchTable('_grist_DocInfo'), ]); @@ -1020,7 +1049,7 @@ export class ActiveDoc extends EventEmitter { this.docStorage.fetchTable('_grist_Tables_column'), ]); - const tableNames: string[] = await this._dataEngine.pyCall('load_meta_tables', tablesData, columnsData); + const tableNames: string[] = await this._rawPyCall('load_meta_tables', tablesData, columnsData); // Figure out which tables are on-demand. const tablesParsed: BulkColValues = marshal.loads(tablesData!); @@ -1056,7 +1085,7 @@ export class ActiveDoc extends EventEmitter { protected async _applyUserActions(docSession: OptDocSession, actions: UserAction[], options: ApplyUAOptions = {}): Promise { - if (!this._granularAccess.canApplyUserActions(docSession, actions)) { + if (!this._granularAccess.canMaybeApplyUserActions(docSession, actions)) { throw new Error('cannot perform a requested action'); } @@ -1064,7 +1093,8 @@ export class ActiveDoc extends EventEmitter { this.logDebug(docSession, "_applyUserActions(%s, %s)", client, shortDesc(actions)); this._inactivityTimer.ping(); // The doc is in active use; ping it to stay open longer. - const user = client && client.session ? (await client.session.getEmail()) : ""; + const user = docSession.mode === 'system' ? 'grist' : + (client && client.session ? (await client.session.getEmail()) : ""); // Create the UserActionBundle. const action: UserActionBundle = { @@ -1081,7 +1111,7 @@ export class ActiveDoc extends EventEmitter { const result: ApplyUAResult = await new Promise( (resolve, reject) => - this._sharing!.addUserAction({action, client, resolve, reject})); + this._sharing!.addUserAction({action, docSession, resolve, reject})); this.logDebug(docSession, "_applyUserActions returning %s", shortDesc(result)); if (result.isModification) { @@ -1149,7 +1179,7 @@ export class ActiveDoc extends EventEmitter { let docActions: DocAction[]; try { // The last argument tells create_migrations() that only metadata is included. - docActions = await this._dataEngine.pyCall('create_migrations', tableData, true); + docActions = await this._rawPyCall('create_migrations', tableData, true); } catch (e) { if (!/need all tables/.test(e.message)) { throw e; @@ -1165,7 +1195,7 @@ export class ActiveDoc extends EventEmitter { tableData[tableName] = await this.docStorage.fetchTable(tableName); } } - docActions = await this._dataEngine.pyCall('create_migrations', tableData); + docActions = await this._rawPyCall('create_migrations', tableData); } const processedTables = Object.keys(tableData); @@ -1187,7 +1217,7 @@ export class ActiveDoc extends EventEmitter { // Pass the resulting array to `map`, which allows parallel processing of the tables. Database // and DataEngine may still do things serially, but it allows them to be busy simultaneously. await bluebird.map(tableNames, async (tableName: string) => - this._dataEngine.pyCall('load_table', tableName, await this._fetchTableIfPresent(tableName)), + this._pyCall('load_table', tableName, await this._fetchTableIfPresent(tableName)), // How many tables to query for and push to the data engine in parallel. { concurrency: 3 }); return this; @@ -1211,7 +1241,9 @@ export class ActiveDoc extends EventEmitter { private async _finishInitialization(docSession: OptDocSession, pendingTableNames: string[], startTime: number) { try { await this._loadTables(docSession, pendingTableNames); - await this._applyUserActions(docSession, [['Calculate']]); + // Calculations are not associated specifically with the user opening the document. + // TODO: be careful with which users can create formulas. + await this._applyUserActions(makeExceptionalDocSession('system'), [['Calculate']]); await this._reportDataEngineMemory(); this._fullyLoaded = true; const endTime = Date.now(); @@ -1246,7 +1278,7 @@ export class ActiveDoc extends EventEmitter { } private async _fetchQueryFromDataEngine(query: Query): Promise { - return this._dataEngine.pyCall('fetch_table', query.tableId, true, query.filters); + return this._pyCall('fetch_table', query.tableId, true, query.filters); } private async _reportDataEngineMemory() { @@ -1300,6 +1332,23 @@ export class ActiveDoc extends EventEmitter { // Mark as changed even if migration is not successful, out of caution. if (!this._migrating) { this._docManager.markAsChanged(this); } } + + /** + * Call a method in the sandbox, without checking the _modificationLock. Calls to + * the sandbox are naturally serialized. + */ + private _rawPyCall(funcName: string, ...varArgs: unknown[]): Promise { + return this._dataEngine.pyCall(funcName, ...varArgs); + } + + /** + * Call a method in the sandbox, while checking on the _modificationLock. If the + * lock is held, the call will wait until the lock is released, and then hold + * the lock itself while operating. + */ + private _pyCall(funcName: string, ...varArgs: unknown[]): Promise { + return this._modificationLock.runExclusive(() => this._rawPyCall(funcName, ...varArgs)); + } } // Helper to initialize a sandbox action bundle with no values. diff --git a/app/server/lib/DocSession.ts b/app/server/lib/DocSession.ts index 24b86309..0a9d69fc 100644 --- a/app/server/lib/DocSession.ts +++ b/app/server/lib/DocSession.ts @@ -15,7 +15,8 @@ export interface OptDocSession { linkId?: number; browserSettings?: BrowserSettings; req?: RequestWithLogin; - mode?: 'nascent'|'plugin'|'system'; // special permissions for creating, plugins, and system access + // special permissions for creating, plugins, system, and share access + mode?: 'nascent'|'plugin'|'system'|'share'; authorizer?: Authorizer; } @@ -30,7 +31,7 @@ export function makeOptDocSession(client: Client|null, browserSettings?: Browser * - plugin: user is treated as editor (because plugin access control is crude) * - system: user is treated as owner (because of some operation bypassing access control) */ -export function makeExceptionalDocSession(mode: 'nascent'|'plugin'|'system', +export function makeExceptionalDocSession(mode: 'nascent'|'plugin'|'system'|'share', options: {client?: Client, req?: RequestWithLogin, browserSettings?: BrowserSettings} = {}): OptDocSession { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 95b6f887..ea7b0a4e 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -1,4 +1,4 @@ -import { MixedPermissionSet, PartialPermissionSet, TablePermissionSet } from 'app/common/ACLPermissions'; +import { MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet } from 'app/common/ACLPermissions'; import { makePartialPermissions, mergePartialPermissions, mergePermissions } from 'app/common/ACLPermissions'; import { emptyPermissionSet, toMixed } from 'app/common/ACLPermissions'; import { ACLRuleCollection } from 'app/common/ACLRuleCollection'; @@ -6,8 +6,9 @@ import { ActionGroup } from 'app/common/ActionGroup'; import { createEmptyActionSummary } from 'app/common/ActionSummary'; import { Query } from 'app/common/ActiveDocAPI'; import { AsyncCreate } from 'app/common/AsyncCreate'; -import { BulkAddRecord, BulkColValues, BulkRemoveRecord, CellValue, ColValues, DocAction } from 'app/common/DocActions'; -import { getTableId, isSchemaAction, TableDataAction, UserAction } from 'app/common/DocActions'; +import { AddRecord, BulkAddRecord, BulkColValues, BulkRemoveRecord, BulkUpdateRecord, CellValue, + ColValues, DocAction, getTableId, isSchemaAction, RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions'; +import { TableDataAction, UserAction } from 'app/common/DocActions'; import { DocData } from 'app/common/DocData'; import { ErrorWithCode } from 'app/common/ErrorWithCode'; import { AclMatchInput, InfoView } from 'app/common/GranularAccessClause'; @@ -31,6 +32,12 @@ const ACTION_WITH_TABLE_ID = new Set(['AddRecord', 'BulkAddRecord', 'UpdateRecor 'ReplaceTableData', 'TableData', ]); +// Check if action has a tableId. +function isTableAction(a: UserAction): a is AddRecord | BulkAddRecord | UpdateRecord | BulkUpdateRecord | + RemoveRecord | BulkRemoveRecord | ReplaceTableData | TableDataAction { + return ACTION_WITH_TABLE_ID.has(String(a[0])); +} + // Actions that won't be allowed (yet) for a user with nuanced access to a document. // A few may be innocuous, but generally I've put them in this list if there are problems // tracking down what table the refer to, or they could allow creation/modification of a @@ -63,8 +70,26 @@ const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']); /** * * Manage granular access to a document. This allows nuances other than the coarse - * owners/editors/viewers distinctions. As a placeholder for a future representation, - * nuances are stored in the _grist_ACLResources table. + * owners/editors/viewers distinctions. Nuances are stored in the _grist_ACLResources + * and _grist_ACLRules tables. + * + * When the document is being modified, the object's GranularAccess is called at various + * steps of the process to check access rights. The GranularAccess object stores some + * state for an in-progress modification, to allow some caching of calculations across + * steps and clients. We expect modifications to be serialized, and the following + * pattern of calls for modifications: + * + * - canMaybeApplyUserActions(), called with UserActions for an initial access check. + * Since not all checks can be done without analyzing UserActions into DocActions, + * it is ok for this call to pass even if a more definitive test later will fail. + * - canApplyDocActions(), called when DocActions have been produced from UserActions, + * but before those DocActions have been applied to the DB. If fails, the modification + * will be abandoned. + * - appliedActions(), called when DocActions have been applied to the DB, but before + * those changes have been sent to clients. + * - filterActionGroup() and filterOutgoingDocActions() are called for each client. + * - finishedActions(), called when completely done with modification and any needed + * client notifications, whether successful or failed. * */ export class GranularAccess { @@ -83,7 +108,9 @@ export class GranularAccess { // When broadcasting a sequence of DocAction[]s, this contains the state of // affected rows for the relevant table before and after each DocAction. It // may contain some unaffected rows as well. - private _rowSnapshots: AsyncCreate>|null; + private _rowSnapshots: AsyncCreate>|null = null; + // Flag tracking whether a set of actions have been applied to the database or not. + private _applied: boolean = false; public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise) { } @@ -115,6 +142,19 @@ export class GranularAccess { return pset.read !== 'deny'; } + /** + * Called after UserAction[]s have been applied in the sandbox, and DocAction[]s have been + * computed, but before we have committed those DocAction[]s to the database. If this + * throws an exception, the sandbox changes will be reverted. + */ + public async canApplyDocActions(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[]) { + this._applied = false; + if (!this._ruleCollection.haveRules()) { return; } + this._prepareRowSnapshots(docActions, undo); + await Promise.all( + docActions.map((action, idx) => this._checkIncomingDocAction(docSession, action, idx))); + } + /** * This should be called after each action bundle has been applied to the database, * but before the actions are broadcast to clients. It will set us up to be able @@ -124,57 +164,16 @@ export class GranularAccess { * broadcasts can be parallelized, but should complete before moving on to further * document mutation). */ - public async beforeBroadcast(docActions: DocAction[], undo: DocAction[]) { - if (!this._ruleCollection.haveRules()) { return false; } - - // Prepare to compute row snapshots if it turns out we need them. - // If we never need them, they will never be computed. - this._rowSnapshots = new AsyncCreate(async () => { - // If we arrive here, the actions have been applied to the database. - // For row access work, we'll need to know the state of affected rows before and - // after the actions. One way to get that is to apply the undo actions to the - // affected part of the database. - // NOTE: the approach may need tweaking once row access control can be applied to - // incoming actions, not just outgoing ones -- in that case, it may be that some - // calculations are done earlier that could be reused here. - - // First figure out what rows in which tables are touched during the undo actions. - const rows = new Map(getRelatedRows(undo)); - // Populate a minimal in-memory version of the database with these rows. - const docData = new DocData( - (tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), - null, - ); - await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId))); - // Now apply the undo actions. - for (const docAction of undo) { docData.receiveAction(docAction); } - - // Now step forward, storing the before and after state for the table - // involved in each action. We'll use this to compute row access changes. - // For simple changes, the rows will be just the minimal set needed. - // This could definitely be optimized. E.g. for pure table updates, these - // states could be extracted while applying undo actions, with no need for - // a forward pass. And for a series of updates to the same table, there'll - // be duplicated before/after states that could be optimized. - const rowSnapshots = new Array<[TableDataAction, TableDataAction]>(); - for (const docAction of docActions) { - const tableId = getTableId(docAction); - const tableData = docData.getTable(tableId)!; - const before = cloneDeep(tableData.getTableDataAction()); - docData.receiveAction(docAction); - // If table is deleted, state afterwards doesn't matter. - const after = docData.getTable(tableId) ? cloneDeep(tableData.getTableDataAction()) : before; - rowSnapshots.push([before, after]); - } - return rowSnapshots; - }); + public async appliedActions(docActions: DocAction[], undo: DocAction[]) { + this._applied = true; } /** * This should be called once an action bundle has been broadcast to all clients. * It will clean up any temporary state cached for filtering those broadcasts. */ - public async afterBroadcast() { + public async finishedActions() { + this._applied = false; if (this._rowSnapshots) { this._rowSnapshots.clear(); } this._rowSnapshots = null; } @@ -184,7 +183,7 @@ export class GranularAccess { */ public async filterOutgoingDocActions(docSession: OptDocSession, docActions: DocAction[]): Promise { const actions = await Promise.all( - docActions.map((action, idx) => this.pruneOutgoingDocAction(docSession, action, idx))); + docActions.map((action, idx) => this._pruneOutgoingDocAction(docSession, action, idx))); return ([] as DocAction[]).concat(...actions); } @@ -211,16 +210,19 @@ export class GranularAccess { } /** - * Check if user can apply a list of actions. + * Check if user may be able to apply a list of actions. If it fails, the user cannot + * apply the actions. If it succeeds, the actions will need examination in more detail. + * TODO: not smart about intermediate states, if there is a table or column rename it will + * have trouble, and might forbid something that should be allowed. */ - public canApplyUserActions(docSession: OptDocSession, actions: UserAction[]): boolean { - return actions.every(action => this.canApplyUserAction(docSession, action)); + public canMaybeApplyUserActions(docSession: OptDocSession, actions: UserAction[]): boolean { + return actions.every(action => this.canMaybeApplyUserAction(docSession, action)); } /** * Check if user can apply a given action to the document. */ - public canApplyUserAction(docSession: OptDocSession, a: UserAction|DocAction): boolean { + public canMaybeApplyUserAction(docSession: OptDocSession, a: UserAction|DocAction): boolean { const name = a[0] as string; if (OK_ACTIONS.has(name)) { return true; } if (SPECIAL_ACTIONS.has(name)) { @@ -229,50 +231,26 @@ export class GranularAccess { if (SURPRISING_ACTIONS.has(name)) { return this.hasFullAccess(docSession); } - const isTableAction = ACTION_WITH_TABLE_ID.has(name); if (a[0] === 'ApplyUndoActions') { - return this.canApplyUserActions(docSession, a[1] as UserAction[]); + return this.canMaybeApplyUserActions(docSession, a[1] as UserAction[]); } else if (a[0] === 'ApplyDocActions') { - return this.canApplyUserActions(docSession, a[1] as UserAction[]); - } else if (isTableAction) { - const tableId = a[1] as string; + return this.canMaybeApplyUserActions(docSession, a[1] as UserAction[]); + } else if (isTableAction(a)) { + const tableId = getTableId(a); // If there are any access control nuances, deny _grist_* tables. // TODO: this is very crude, loosen this up appropriately. if (tableId.startsWith('_grist_')) { return !this.hasNuancedAccess(docSession); } const tableAccess = this.getTableAccess(docSession, tableId); - // For now, if there are any row restrictions, forbid editing. - // To allow editing, we'll need something that has access to full row, - // e.g. data engine (and then an equivalent for ondemand tables), or - // to fetch rows at this point. - // TODO We can now look properly at the create/update/delete/schemaEdit permissions in pset. - return tableAccess.read === 'allow'; + const accessFn = getAccessForActionType(a); + const access = accessFn(tableAccess); + // if access is mixed, leave this to be checked in detail later. + return access === 'allow' || access === 'mixed' || access === 'mixedColumns'; } return false; } - /** - * Cut out any rows/columns not accessible to the user. May throw a NEED_RELOAD - * exception if the information needed to achieve the desired pruning is not available. - * Returns null if the action is entirely pruned. The action passed in is never modified. - * The idx parameter is a record of which action in the bundle this action is, and can - * be used to access information in this._rowSnapshots if needed. - */ - public async pruneOutgoingDocAction(docSession: OptDocSession, a: DocAction, idx: number): Promise { - const tableId = getTableId(a); - const permInfo = this._getAccess(docSession); - const tableAccess = permInfo.getTableAccess(tableId); - if (tableAccess.read === 'deny') { return []; } - if (tableAccess.read === 'allow') { return [a]; } - if (tableAccess.read === 'mixedColumns') { - return [this._pruneColumns(a, permInfo, tableId)].filter(isObject); - } - // The remainder is the mixed condition. - const revisedDocActions = await this._pruneRows(docSession, a, idx); - return revisedDocActions.map(na => this._pruneColumns(na, permInfo, tableId)).filter(isObject); - } - /** * Check whether access is simple, or there are granular nuances that need to be * worked through. Currently if there are no owner-only tables, then everyone's @@ -433,7 +411,7 @@ export class GranularAccess { const permInfo = this._getAccess(docSession); const tableId = getTableId(data); if (permInfo.getTableAccess(tableId).read === 'mixed') { - this._filterRowsAndCells(docSession, data, data); + this._filterRowsAndCells(docSession, data, data, data, canRead); } // Filter columns, omitting any to which the user has no access, regardless of rows. @@ -442,21 +420,23 @@ export class GranularAccess { /** * Strip out any denied columns from an action. Returns null if nothing is left. + * accessFn may throw if denials are fatal. */ - private _pruneColumns(a: DocAction, permInfo: PermissionInfo, tableId: string): DocAction|null { + private _pruneColumns(a: DocAction, permInfo: PermissionInfo, tableId: string, + accessFn: AccessFn): DocAction|null { if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { return a; } else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || a[0] === 'UpdateRecord' || a[0] === 'BulkUpdateRecord' || a[0] === 'ReplaceTableData' || a[0] === 'TableData') { const na = cloneDeep(a); - this._filterColumns(na[3], (colId) => permInfo.getColumnAccess(tableId, colId).read !== 'deny'); + this._filterColumns(na[3], (colId) => accessFn(permInfo.getColumnAccess(tableId, colId)) !== 'deny'); if (Object.keys(na[3]).length === 0) { return null; } return na; } else if (a[0] === 'AddColumn' || a[0] === 'RemoveColumn' || a[0] === 'RenameColumn' || a[0] === 'ModifyColumn') { const na = cloneDeep(a); const colId: string = na[2]; - if (permInfo.getColumnAccess(tableId, colId).read === 'deny') { return null; } + if (accessFn(permInfo.getColumnAccess(tableId, colId)) === 'deny') { return null; } throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); } else { // Remaining cases of AddTable, RemoveTable, RenameTable should have @@ -553,54 +533,78 @@ export class GranularAccess { // Return the results, also applying any cell-level access control. for (const docAction of revisedDocActions) { - this._filterRowsAndCells(docSession, rowsAfter, docAction); + this._filterRowsAndCells(docSession, rowsAfter, rowsAfter, docAction, canRead); } return revisedDocActions; } + /** + * Like _pruneRows, but fails immediately if access to any row is forbidden. + * The accessFn supplied should throw an error on denial. + */ + private async _checkRows(docSession: OptDocSession, a: DocAction, idx: number, + accessFn: AccessFn): Promise { + // For the moment, only deal with Record-related actions. + // TODO: process table/column schema changes more carefully. + if (isSchemaAction(a)) { return; } + if (!this._rowSnapshots) { throw new Error('Logic error: actions not available'); } + const allRowSnapshots = await this._rowSnapshots.get(); + const [rowsBefore, rowsAfter] = allRowSnapshots[idx]; + this._filterRowsAndCells(docSession, rowsBefore, rowsAfter, a, accessFn); + } + /** * Modify action in place, scrubbing any rows and cells to which access is not granted. */ - private _filterRowsAndCells(docSession: OptDocSession, data: TableDataAction, docAction: DocAction) { + private _filterRowsAndCells(docSession: OptDocSession, rowsBefore: TableDataAction, rowsAfter: TableDataAction, + docAction: DocAction, accessFn: AccessFn) { if (docAction && isSchemaAction(docAction)) { // TODO should filter out metadata about an unavailable column, probably. return []; } - const rowCursor = new RecordView(data, 0); - const input: AclMatchInput = {user: this._getUser(docSession), rec: rowCursor}; + const rec = new RecordView(rowsBefore, undefined); + const newRec = new RecordView(rowsAfter, undefined); + const input: AclMatchInput = {user: this._getUser(docSession), rec, newRec}; const [, tableId, , colValues] = docAction; - if (colValues === undefined) { return []; } const rowIds = getRowIdsFromDocAction(docAction); const toRemove: number[] = []; let censorAt: (colId: string, idx: number) => void; - if (Array.isArray(docAction[2])) { + if (colValues === undefined) { + censorAt = () => 1; + } else if (Array.isArray(docAction[2])) { censorAt = (colId, idx) => (colValues as BulkColValues)[colId][idx] = 'CENSORED'; // TODO Pick a suitable value } else { censorAt = (colId) => (colValues as ColValues)[colId] = 'CENSORED'; // TODO Pick a suitable value } - let getDataIndex: (idx: number) => number = (idx) => idx; - if (docAction !== data) { - const indexes = new Map(data[2].map((rowId, idx) => [rowId, idx])); - getDataIndex = (idx) => indexes.get(rowIds[idx])!; + // These map an index of a row in docAction to its index in rowsBefore and in rowsAfter. + let getRecIndex: (idx: number) => number|undefined = (idx) => idx; + let getNewRecIndex: (idx: number) => number|undefined = (idx) => idx; + if (docAction !== rowsBefore) { + const recIndexes = new Map(rowsBefore[2].map((rowId, idx) => [rowId, idx])); + getRecIndex = (idx) => recIndexes.get(rowIds[idx]); + const newRecIndexes = new Map(rowsAfter[2].map((rowId, idx) => [rowId, idx])); + getNewRecIndex = (idx) => newRecIndexes.get(rowIds[idx]); } for (let idx = 0; idx < rowIds.length; idx++) { - rowCursor.index = getDataIndex(idx); + rec.index = getRecIndex(idx); + newRec.index = getNewRecIndex(idx); const rowPermInfo = new PermissionInfo(this._ruleCollection, input); // getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess. const rowAccess = rowPermInfo.getTableAccess(tableId); - if (rowAccess.read === 'deny') { + const access = accessFn(rowAccess); + if (access === 'deny') { toRemove.push(idx); - } else if (rowAccess.read !== 'allow') { + } else if (access !== 'allow' && colValues) { // Go over column rules. for (const colId of Object.keys(colValues)) { const colAccess = rowPermInfo.getColumnAccess(tableId, colId); - if (colAccess.read !== 'allow') { + if (accessFn(colAccess) === 'deny') { censorAt(colId, idx); } } @@ -608,30 +612,32 @@ export class GranularAccess { } if (toRemove.length > 0) { - if (data === docAction) { - this._removeRowsAt(toRemove, data[2], data[3]); + if (rowsBefore === docAction) { + this._removeRowsAt(toRemove, rowsBefore[2], rowsBefore[3]); } else { - // If there are still rows to remove, we must have a logic error. - throw new Error('Unexpected row removal'); + // Artificially introduced removals are ok, otherwise this is suspect. + if (docAction[0] !== 'RemoveRecord' && docAction[0] !== 'BulkRemoveRecord') { + throw new Error('Unexpected row removal'); + } } } } // Compute which of the row ids supplied are for rows forbidden for this session. private _getForbiddenRows(docSession: OptDocSession, data: TableDataAction, ids: Set): number[] { - const rowCursor = new RecordView(data, 0); - const input: AclMatchInput = {user: this._getUser(docSession), rec: rowCursor}; + const rec = new RecordView(data, undefined); + const input: AclMatchInput = {user: this._getUser(docSession), rec}; const [, tableId, rowIds] = data; const toRemove: number[] = []; for (let idx = 0; idx < rowIds.length; idx++) { - rowCursor.index = idx; + rec.index = idx; if (!ids.has(rowIds[idx])) { continue; } const rowPermInfo = new PermissionInfo(this._ruleCollection, input); // getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess. const rowAccess = rowPermInfo.getTableAccess(tableId); - if (rowAccess.read === 'deny') { + if (canRead(rowAccess) === 'deny') { toRemove.push(rowIds[idx]); } } @@ -807,6 +813,98 @@ export class GranularAccess { if (rowIds.size === 0) { return null; } return ['BulkRemoveRecord', getTableId(data), [...rowIds]]; } + + /** + * Prepare to compute intermediate states of rows, as + * this._rowSnapshots. The computation should happen only if + * needed, which depends on the rules and actions. The computation + * uses the state of the database, and so depends on whether the + * docActions have already been applied to the database or not, as + * determined by the this._applied flag, which should never be + * changed during any possible use of this._rowSnapshots. + */ + private _prepareRowSnapshots(docActions: DocAction[], undo: DocAction[]) { + // Prepare to compute row snapshots if it turns out we need them. + // If we never need them, they will never be computed. + this._rowSnapshots = new AsyncCreate(async () => { + // For row access work, we'll need to know the state of affected rows before and + // after the actions. + // First figure out what rows in which tables are touched during the actions. + const rows = new Map(getRelatedRows(this._applied ? [...undo].reverse() : docActions)); + // Populate a minimal in-memory version of the database with these rows. + const docData = new DocData( + (tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), + null, + ); + await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId))); + if (this._applied) { + // Apply the undo actions, since the docActions have already been applied to the db. + for (const docAction of [...undo].reverse()) { docData.receiveAction(docAction); } + } + + // Now step forward, storing the before and after state for the table + // involved in each action. We'll use this to compute row access changes. + // For simple changes, the rows will be just the minimal set needed. + // This could definitely be optimized. E.g. for pure table updates, these + // states could be extracted while applying undo actions, with no need for + // a forward pass. And for a series of updates to the same table, there'll + // be duplicated before/after states that could be optimized. + const rowSnapshots = new Array<[TableDataAction, TableDataAction]>(); + for (const docAction of docActions) { + const tableId = getTableId(docAction); + const tableData = docData.getTable(tableId)!; + const before = cloneDeep(tableData.getTableDataAction()); + docData.receiveAction(docAction); + // If table is deleted, state afterwards doesn't matter. + const after = docData.getTable(tableId) ? cloneDeep(tableData.getTableDataAction()) : before; + rowSnapshots.push([before, after]); + } + return rowSnapshots; + }); + } + + /** + * Cut out any rows/columns not accessible to the user. May throw a NEED_RELOAD + * exception if the information needed to achieve the desired pruning is not available. + * Returns null if the action is entirely pruned. The action passed in is never modified. + * The idx parameter is a record of which action in the bundle this action is, and can + * be used to access information in this._rowSnapshots if needed. + */ + private async _pruneOutgoingDocAction(docSession: OptDocSession, a: DocAction, idx: number): Promise { + const tableId = getTableId(a); + const permInfo = this._getAccess(docSession); + const tableAccess = permInfo.getTableAccess(tableId); + const access = tableAccess.read; + if (access === 'deny') { return []; } + if (access === 'allow') { return [a]; } + if (access === 'mixedColumns') { + return [this._pruneColumns(a, permInfo, tableId, canRead)].filter(isObject); + } + // The remainder is the mixed condition. + const revisedDocActions = await this._pruneRows(docSession, a, idx); + const result = revisedDocActions.map(na => this._pruneColumns(na, permInfo, tableId, + canRead)).filter(isObject); + return result; + } + + private async _checkIncomingDocAction(docSession: OptDocSession, a: DocAction, idx: number): Promise { + const accessFn = denyIsFatal(getAccessForActionType(a)); + const tableId = getTableId(a); + const permInfo = this._getAccess(docSession); + const tableAccess = permInfo.getTableAccess(tableId); + const access = accessFn(tableAccess); + if (access === 'allow') { return; } + if (access === 'mixedColumns') { + // Somewhat abusing prune method by calling it with an access function that + // throws on denial. + this._pruneColumns(a, permInfo, tableId, accessFn); + } + // The remainder is the mixed condition. + await this._checkRows(docSession, a, idx, accessFn); + // Somewhat abusing prune method by calling it with an access function that + // throws on denial. + this._pruneColumns(a, permInfo, tableId, accessFn); + } } /** @@ -907,12 +1005,16 @@ class PermissionInfo { } } -// A row-like view of TableDataAction, which is columnar in nature. +/** + * A row-like view of TableDataAction, which is columnar in nature. If index value + * is undefined, acts as an EmptyRecordRow. + */ export class RecordView implements InfoView { - public constructor(public data: TableDataAction, public index: number) { + public constructor(public data: TableDataAction, public index: number|undefined) { } public get(colId: string): CellValue { + if (this.index === undefined) { return null; } if (colId === 'id') { return this.data[2][this.index]; } @@ -920,6 +1022,7 @@ export class RecordView implements InfoView { } public toJSON() { + if (this.index === undefined) { return {}; } const results: {[key: string]: any} = {}; for (const key of Object.keys(this.data[3])) { results[key] = this.data[3][key][this.index]; @@ -943,3 +1046,35 @@ interface CharacteristicTable { rowNums: Map; data: TableDataAction; } + +// A function for extracting one of the create/read/update/delete/schemaEdit permissions +// from a permission set. +type AccessFn = (ps: PermissionSet) => string; + +// Get an AccessFn appropriate for the specific action. +// TODO: deal with ReplaceTableData, which both deletes and creates rows. +function getAccessForActionType(a: DocAction): AccessFn { + if (a[0] === 'UpdateRecord' || a[0] === 'BulkUpdateRecord') { + return (ps) => ps.update; + } else if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') { + return (ps) => ps.delete; + } else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord') { + return (ps) => ps.create; + } else { + return (ps) => ps.schemaEdit; + } +} + +// Tweak an AccessFn so that it throws an exception if access is denied. +function denyIsFatal(fn: AccessFn): AccessFn { + return (ps) => { + const result = fn(ps); + if (result === 'deny') { throw new Error('access denied'); } + return result; + }; +} + +// A simple access function that returns the "read" permission. +function canRead(ps: PermissionSet) { + return ps.read; +} diff --git a/app/server/lib/Sharing.ts b/app/server/lib/Sharing.ts index 9a162935..1cc1d22e 100644 --- a/app/server/lib/Sharing.ts +++ b/app/server/lib/Sharing.ts @@ -6,10 +6,11 @@ import {timeFormat} from 'app/common/timeFormat'; import * as log from 'app/server/lib/log'; import {shortDesc} from 'app/server/lib/shortDesc'; import * as assert from 'assert'; +import {Mutex} from 'async-mutex'; import * as Deque from 'double-ended-queue'; import {ActionHistory, asActionGroup} from './ActionHistory'; import {ActiveDoc} from './ActiveDoc'; -import {Client} from './Client'; +import {makeExceptionalDocSession, OptDocSession} from './DocSession'; import {WorkCoordinator} from './WorkCoordinator'; // Describes the request to apply a UserActionBundle. It includes a Client (so that broadcast @@ -18,7 +19,7 @@ import {WorkCoordinator} from './WorkCoordinator'; // processing hub actions or rebasing. interface UserRequest { action: UserActionBundle; - client: Client|null; + docSession: OptDocSession|null, resolve(result: UserResult): void; reject(err: Error): void; } @@ -41,7 +42,7 @@ export class Sharing { protected _pendingQueue: Deque = new Deque(); protected _workCoordinator: WorkCoordinator; - constructor(activeDoc: ActiveDoc, actionHistory: ActionHistory) { + constructor(activeDoc: ActiveDoc, actionHistory: ActionHistory, private _modificationLock: Mutex) { // TODO actionHistory is currently unused (we use activeDoc.actionLog). assert(actionHistory.isInitialized()); @@ -117,7 +118,7 @@ export class Sharing { assert(this._hubQueue.isEmpty() && !this._pendingQueue.isEmpty()); const userRequest: UserRequest = this._pendingQueue.shift()!; try { - const ret = await this.doApplyUserActionBundle(userRequest.action, userRequest.client); + const ret = await this.doApplyUserActionBundle(userRequest.action, userRequest.docSession); userRequest.resolve(ret); } catch (e) { log.warn("Unable to apply action...", e); @@ -200,13 +201,17 @@ export class Sharing { return this.doApplyUserActions(action.info[1], userActions, Branch.Shared, null); } - private doApplyUserActionBundle(action: UserActionBundle, client: Client|null): Promise { - return this.doApplyUserActions(action.info, action.userActions, Branch.Local, client); + private doApplyUserActionBundle(action: UserActionBundle, docSession: OptDocSession|null): Promise { + return this.doApplyUserActions(action.info, action.userActions, Branch.Local, docSession); } private async doApplyUserActions(info: ActionInfo, userActions: UserAction[], - branch: Branch, client: Client|null): Promise { - const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(userActions); + branch: Branch, docSession: OptDocSession|null): Promise { + const client = docSession && docSession.client; + + const {sandboxActionBundle, undo, docActions} = + await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions)); + // A trivial action does not merit allocating an actionNum, // logging, and sharing. Since we currently don't store // calculated values in the database, it is best not to log the @@ -217,35 +222,25 @@ export class Sharing { const isCalculate = (userActions.length === 1 && userActions[0][0] === 'Calculate'); const trivial = isCalculate && sandboxActionBundle.stored.length === 0; - // For Calculate "user" actions, don't attribute any changes to the user who - // happens to have opened the document. - const attribution = { - ...info, - ...isCalculate ? { user: 'grist' } : undefined, - }; const actionNum = trivial ? 0 : (branch === Branch.Shared ? this._actionHistory.getNextHubActionNum() : this._actionHistory.getNextLocalActionNum()); - const undo = getEnvContent(sandboxActionBundle.undo); const localActionBundle: LocalActionBundle = { actionNum, // The ActionInfo should go into the envelope that includes all recipients. - info: [findOrAddAllEnvelope(sandboxActionBundle.envelopes), attribution], + info: [findOrAddAllEnvelope(sandboxActionBundle.envelopes), info], envelopes: sandboxActionBundle.envelopes, stored: sandboxActionBundle.stored, calc: sandboxActionBundle.calc, - undo: getEnvContent(sandboxActionBundle.undo), + undo, userActions, actionHash: null, // Gets set below by _actionHistory.recordNext... parentActionHash: null, // Gets set below by _actionHistory.recordNext... }; this._logActionBundle(`doApplyUserActions (${Branch[branch]})`, localActionBundle); - const docActions = getEnvContent(localActionBundle.stored).concat( - getEnvContent(localActionBundle.calc)); - // TODO Note that the sandbox may produce actions which are not addressed to us (e.g. when we // have EDIT permission without VIEW). These are not sent to the browser or the database. But // today they are reflected in the sandbox. Should we (or the sandbox) immediately undo the @@ -291,14 +286,14 @@ export class Sharing { // date and other changes from external values may count as internal. internal: isCalculate, }); - await this._activeDoc.beforeBroadcast(docActions, undo); try { + await this._activeDoc.appliedActions(docActions, undo); await this._activeDoc.broadcastDocUpdate(client || null, 'docUserAction', { actionGroup, docActions, }); } finally { - await this._activeDoc.afterBroadcast(); + await this._activeDoc.finishedActions(); } return { actionNum: localActionBundle.actionNum, @@ -372,6 +367,26 @@ export class Sharing { (includeEnv[envAction[0]] ? "" : " alien"), shortDesc(envAction[1]))); } + + private async _applyActionsToDataEngine(docSession: OptDocSession|null, userActions: UserAction[]) { + const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(userActions); + const undo = getEnvContent(sandboxActionBundle.undo); + const docActions = getEnvContent(sandboxActionBundle.stored).concat( + getEnvContent(sandboxActionBundle.calc)); + + try { + // TODO: see if any of the code paths that have no docSession are relevant outside + // of tests. + await this._activeDoc.canApplyDocActions(docSession || makeExceptionalDocSession('share'), + docActions, undo); + } catch (e) { + // should not commit. Don't write to db. Remove changes from sandbox. + await this._activeDoc.applyActionsToDataEngine([['ApplyUndoActions', undo]]); + await this._activeDoc.finishedActions(); + throw e; + } + return {sandboxActionBundle, undo, docActions}; + } } /**