(core) Checks that an ACL formula can be parsed, and prevent saving unparsable ACL rules.

Summary:
- Fix error-handling in bundleActions(), and wait for the full bundle to complete.
  (The omissions here were making it impossibly to react to errors from inside bundleActions())
- Catch problematic rules early enough to undo them, by trying out ruleCollection.update()
  on updated rules before the updates are applied.
- Added checkAclFormula() call to DocComm that checks parsing and compiling
  formula, and reports errors.
- In UI, prevent saving if any aclFormulas are invalid, or while waiting for the to get checked.

- Also fixed some lint errors

Test Plan: Added a test case of error reporting in ACL formulas.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2689
This commit is contained in:
Dmitry S
2020-12-14 23:19:38 -05:00
parent 3b3ae87ade
commit de35be6b0a
10 changed files with 241 additions and 99 deletions

View File

@@ -36,6 +36,7 @@ import {UploadResult} from 'app/common/uploads';
import {DocReplacementOptions, DocState} from 'app/common/UserAPI';
import {ParseOptions} from 'app/plugin/FileParserAPI';
import {GristDocAPI} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
import {Authorizer} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client';
@@ -898,6 +899,24 @@ export class ActiveDoc extends EventEmitter {
return makeForkIds({userId, isAnonymous, trunkDocId, trunkUrlId});
}
/**
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
*/
public async checkAclFormula(docSession: DocSession, text: string): Promise<void> {
// Checks can leak names of tables and columns.
if (!await this._granularAccess.canReadEverything(docSession)) { return; }
await this.waitForInitialization();
try {
const parsedAclFormula = await this._pyCall('parse_acl_formula', text);
compileAclFormula(parsedAclFormula);
// TODO We also need to check the validity of attributes, and of tables and columns
// mentioned in resources and userAttribute rules.
} catch (e) {
e.message = e.message?.replace('[Sandbox] ', '');
throw e;
}
}
public getGristDocAPI(): GristDocAPI {
return this.docPluginManager.gristDocAPI;
}

View File

@@ -110,6 +110,7 @@ export class DocWorker {
getActionSummaries: activeDocMethod.bind(null, 'viewers', 'getActionSummaries'),
reloadDoc: activeDocMethod.bind(null, 'editors', 'reloadDoc'),
fork: activeDocMethod.bind(null, 'viewers', 'fork'),
checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'),
});
}

View File

@@ -5,9 +5,11 @@ import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { ActionGroup } from 'app/common/ActionGroup';
import { createEmptyActionSummary } from 'app/common/ActionSummary';
import { Query } from 'app/common/ActiveDocAPI';
import { ApiError } from 'app/common/ApiError';
import { AsyncCreate } from 'app/common/AsyncCreate';
import { AddRecord, BulkAddRecord, BulkColValues, BulkRemoveRecord, BulkUpdateRecord, CellValue,
ColValues, DocAction, getTableId, isSchemaAction, RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
import { AddRecord, BulkAddRecord, BulkColValues, BulkRemoveRecord, BulkUpdateRecord } from 'app/common/DocActions';
import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions';
import { TableDataAction, UserAction } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import { ErrorWithCode } from 'app/common/ErrorWithCode';
@@ -109,7 +111,10 @@ export class GranularAccess {
// 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<TableDataAction>, private _recoveryMode: boolean) {
public constructor(
private _docData: DocData,
private _fetchQueryFromDB: (query: Query) => Promise<TableDataAction>,
private _recoveryMode: boolean) {
}
/**
@@ -145,10 +150,32 @@ export class GranularAccess {
*/
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)));
if (this._ruleCollection.haveRules()) {
this._prepareRowSnapshots(docActions, undo);
await Promise.all(
docActions.map((action, idx) => this._checkIncomingDocAction(docSession, action, idx)));
}
// If the actions change any rules, verify that we'll be able to handle the changed rules. If
// they are to cause an error, reject the action to avoid forcing user into recovery mode.
if (docActions.some(docAction => ['_grist_ACLRules', '_grist_Resources'].includes(getTableId(docAction)))) {
// Create a tmpDocData with just the tables we care about, then update docActions to it.
const tmpDocData: DocData = new DocData(
(tableId) => { throw new Error("Unexpected DocData fetch"); }, {
_grist_ACLResources: this._docData.getTable('_grist_ACLResources')!.getTableDataAction(),
_grist_ACLRules: this._docData.getTable('_grist_ACLRules')!.getTableDataAction(),
});
for (const da of docActions) {
tmpDocData.receiveAction(da);
}
// Use the post-actions data to process the rules collection, and throw error if that fails.
const ruleCollection = new ACLRuleCollection();
await ruleCollection.update(tmpDocData, {log, compile: compileAclFormula});
if (ruleCollection.ruleError && !this._recoveryMode) {
throw new ApiError(ruleCollection.ruleError.message, 400);
}
}
}
/**
@@ -164,7 +191,8 @@ export class GranularAccess {
this._applied = true;
// If there is a rule change, redo from scratch for now.
// TODO: this is placeholder code. Should deal with connected clients.
if (docActions.some(docAction => getTableId(docAction) === '_grist_ACLRules' || getTableId(docAction) === '_grist_Resources')) {
if (docActions.some(docAction => getTableId(docAction) === '_grist_ACLRules' ||
getTableId(docAction) === '_grist_Resources')) {
await this.update();
return;
}
@@ -656,7 +684,8 @@ export class GranularAccess {
}
// Compute which of the row ids supplied are for rows forbidden for this session.
private async _getForbiddenRows(docSession: OptDocSession, data: TableDataAction, ids: Set<number>): Promise<number[]> {
private async _getForbiddenRows(docSession: OptDocSession, data: TableDataAction, ids: Set<number>):
Promise<number[]> {
const rec = new RecordView(data, undefined);
const input: AclMatchInput = {user: await this._getUser(docSession), rec};
@@ -795,7 +824,10 @@ export class GranularAccess {
try {
// Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.
// TODO: add indexes to db.
rows = await this._fetchQueryFromDB({tableId: clause.tableId, filters: { [clause.lookupColId]: [get(user, clause.charId)] }});
rows = await this._fetchQueryFromDB({
tableId: clause.tableId,
filters: { [clause.lookupColId]: [get(user, clause.charId)] }
});
} catch (e) {
log.warn(`User attribute ${clause.name} failed`, e);
}

View File

@@ -19,7 +19,7 @@ import {WorkCoordinator} from './WorkCoordinator';
// processing hub actions or rebasing.
interface UserRequest {
action: UserActionBundle;
docSession: OptDocSession|null,
docSession: OptDocSession|null;
resolve(result: UserResult): void;
reject(err: Error): void;
}