mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Syncing db with data when actions are rejected
Summary: Writing results of the undo action to a database when the undo was caused by rejecting due to ACL checks. This ensures that DB and sanbox are in sync in case of non-deterministic formulas. Test Plan: Updated Reviewers: georgegevoian, dsagal Reviewed By: georgegevoian, dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3695
This commit is contained in:
parent
d47cac36f5
commit
601ba58a2e
@ -2709,7 +2709,7 @@ const dummyAccessCheck: IAccessCheck = {
|
||||
|
||||
/**
|
||||
* Helper class to calculate access for a set of cells in bulk. Used for initial
|
||||
* access check for a whole _grist_Cell table. Each cell can belong to a diffrent
|
||||
* access check for a whole _grist_Cell table. Each cell can belong to a different
|
||||
* table and row, so here we will avoid loading rows multiple times and checking
|
||||
* the table access multiple time.
|
||||
*/
|
||||
@ -3558,7 +3558,7 @@ export class CellData {
|
||||
fail();
|
||||
}
|
||||
// Now receive the action, and test if we can still see the cell (as the info might be moved
|
||||
// to a diffrent cell).
|
||||
// to a different cell).
|
||||
lastState.receiveAction(single);
|
||||
cell = cellData.getCell(id)!;
|
||||
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
|
||||
|
@ -4,17 +4,20 @@ import {
|
||||
Envelope,
|
||||
getEnvContent,
|
||||
LocalActionBundle,
|
||||
SandboxActionBundle,
|
||||
UserActionBundle
|
||||
} from 'app/common/ActionBundle';
|
||||
import {ApplyUAExtendedOptions} from 'app/common/ActiveDocAPI';
|
||||
import {CALCULATING_USER_ACTIONS, DocAction, getNumRows, UserAction} from 'app/common/DocActions';
|
||||
import {allToken} from 'app/common/sharing';
|
||||
import {GranularAccessForBundle} from 'app/server/lib/GranularAccess';
|
||||
import log from 'app/server/lib/log';
|
||||
import {LogMethods} from "app/server/lib/LogMethods";
|
||||
import {shortDesc} from 'app/server/lib/shortDesc';
|
||||
import assert from 'assert';
|
||||
import {Mutex} from 'async-mutex';
|
||||
import Deque from 'double-ended-queue';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {ActionHistory, asActionGroup, getActionUndoInfo} from './ActionHistory';
|
||||
import {ActiveDoc} from './ActiveDoc';
|
||||
import {makeExceptionalDocSession, OptDocSession} from './DocSession';
|
||||
@ -45,6 +48,22 @@ enum Branch { Local, Shared }
|
||||
// Don't log details of action bundles in production.
|
||||
const LOG_ACTION_BUNDLE = (process.env.NODE_ENV !== 'production');
|
||||
|
||||
interface ApplyResult {
|
||||
/**
|
||||
* Access denied exception if the user does not have permission to apply the action.
|
||||
*/
|
||||
failure?: Error,
|
||||
/**
|
||||
* Result of applying user actions. If there is a failure, it contains result of reverting
|
||||
* those actions that should be persisted (probably extra actions caused by nondeterministic
|
||||
* functions).
|
||||
*/
|
||||
result?: {
|
||||
accessControl: GranularAccessForBundle,
|
||||
bundle: SandboxActionBundle,
|
||||
}
|
||||
}
|
||||
|
||||
export class Sharing {
|
||||
protected _activeDoc: ActiveDoc;
|
||||
protected _actionHistory: ActionHistory;
|
||||
@ -212,9 +231,19 @@ export class Sharing {
|
||||
info.linkId = docSession.linkId;
|
||||
}
|
||||
|
||||
const {sandboxActionBundle, undo, accessControl} =
|
||||
const {result, failure} =
|
||||
await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions, options));
|
||||
|
||||
// ACL check failed, and we don't have anything to save. Just rethrow the error.
|
||||
if (failure && !result) {
|
||||
throw failure;
|
||||
}
|
||||
|
||||
assert(result, "result should be defined if failure is not");
|
||||
const sandboxActionBundle = result.bundle;
|
||||
const accessControl = result.accessControl;
|
||||
const undo = getEnvContent(result.bundle.undo);
|
||||
|
||||
try {
|
||||
|
||||
const isCalculate = (userActions.length === 1 && CALCULATING_USER_ACTIONS.has(userActions[0][0] as string));
|
||||
@ -222,7 +251,11 @@ export class Sharing {
|
||||
// - Calculate/UpdateCurrentTime because it's not considered as performed by a particular client.
|
||||
// - Adding attachment metadata when uploading attachments,
|
||||
// because then the attachment file may get hard-deleted and redo won't work properly.
|
||||
const internal = isCalculate || userActions.every(a => a[0] === "AddRecord" && a[1] === "_grist_Attachments");
|
||||
// - Action was rejected but it had some side effects (e.g. NOW() or UUID() formulas).
|
||||
const internal =
|
||||
isCalculate ||
|
||||
userActions.every(a => a[0] === "AddRecord" && a[1] === "_grist_Attachments") ||
|
||||
!!failure;
|
||||
|
||||
// A trivial action does not merit allocating an actionNum,
|
||||
// logging, and sharing. It's best not to log the
|
||||
@ -316,6 +349,11 @@ export class Sharing {
|
||||
actionGroup.actionSummary = actionSummary;
|
||||
await accessControl.appliedBundle();
|
||||
await accessControl.sendDocUpdateForBundle(actionGroup, this._activeDoc.getDocUsageSummary());
|
||||
// If the action was rejected, throw an exception, by this point data-engine should be in
|
||||
// sync with the database, and everyone should have the same view of the document.
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
if (docSession) {
|
||||
docSession.linkId = docSession.shouldBundleActions ? localActionBundle.actionNum : 0;
|
||||
}
|
||||
@ -391,35 +429,119 @@ export class Sharing {
|
||||
shortDesc(envAction[1])));
|
||||
}
|
||||
|
||||
private async _applyActionsToDataEngine(docSession: OptDocSession|null, userActions: UserAction[],
|
||||
options: ApplyUAExtendedOptions|null) {
|
||||
const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(docSession, userActions);
|
||||
const undo = getEnvContent(sandboxActionBundle.undo);
|
||||
const docActions = getEnvContent(sandboxActionBundle.stored).concat(
|
||||
getEnvContent(sandboxActionBundle.calc));
|
||||
const isDirect = getEnvContent(sandboxActionBundle.direct);
|
||||
|
||||
const accessControl = this._activeDoc.getGranularAccessForBundle(
|
||||
docSession || makeExceptionalDocSession('share'), docActions, undo, userActions, isDirect,
|
||||
options
|
||||
);
|
||||
private async _applyActionsToDataEngine(
|
||||
docSession: OptDocSession|null,
|
||||
userActions: UserAction[],
|
||||
options: ApplyUAExtendedOptions|null): Promise<ApplyResult> {
|
||||
const applyResult = await this._activeDoc.applyActionsToDataEngine(docSession, userActions);
|
||||
let accessControl = this._startGranularAccessForBundle(docSession, applyResult, userActions, options);
|
||||
try {
|
||||
// TODO: see if any of the code paths that have no docSession are relevant outside
|
||||
// of tests.
|
||||
await accessControl.canApplyBundle();
|
||||
} catch (e) {
|
||||
// should not commit. Don't write to db. Remove changes from sandbox.
|
||||
return { result : {bundle: applyResult, accessControl}};
|
||||
} catch (applyExc) {
|
||||
try {
|
||||
await this._activeDoc.applyActionsToDataEngine(docSession, [['ApplyUndoActions', undo]]);
|
||||
} finally {
|
||||
// We can't apply those actions, so we need to revert them.
|
||||
const undoResult = await this._activeDoc.applyActionsToDataEngine(docSession, [
|
||||
['ApplyUndoActions', getEnvContent(applyResult.undo)]
|
||||
]);
|
||||
|
||||
// We managed to reject and undo actions in the data-engine. Now we need to calculate if we have any extra
|
||||
// actions generated by the undo (it can happen for nondeterministic formulas). If we have them, we will need to
|
||||
// test if they pass ACL check and persist them in the database in order to keep the data engine in sync with
|
||||
// the database. If we have any extra actions, we will simulate that only those actions were applied and return
|
||||
// fake bundle together with the access failure. If we don't have any extra actions, we will just return the
|
||||
// failure.
|
||||
const extraBundle = this._createExtraBundle(undoResult, getEnvContent(applyResult.undo));
|
||||
|
||||
// If we have the same number of actions and they are equal, we can assume that the data-engine is in sync.
|
||||
if (!extraBundle) {
|
||||
// We stored what we send, we don't have any extra actions to save, we can just return the failure.
|
||||
await accessControl.finishedBundle();
|
||||
return { failure: applyExc };
|
||||
}
|
||||
|
||||
// We have some extra actions, so we need to prepare a fake bundle (only with the extra actions) and
|
||||
// return the failure, so the caller can persist the extra actions and report the failure.
|
||||
// Finish the access control for the origBundle.
|
||||
await accessControl.finishedBundle();
|
||||
// Start a new one. We assume that all actions are indirect, so this is basically a no-op, but we are doing it
|
||||
// nevertheless to make sure they pass access control.
|
||||
// NOTE: we assume that docActions can be used as userActions here. This is not always the case (as we might
|
||||
// have a special logic that targets UserActions directly), but in this scenario, the extra bundle should
|
||||
// contain only indirect data actions (mostly UpdateRecord) that are produced by comparing UserTables in the
|
||||
// data-engine.
|
||||
accessControl = this._startGranularAccessForBundle(docSession, extraBundle, extraBundle.stored, options);
|
||||
// Check if the extra bundle is allowed.
|
||||
await accessControl.canApplyBundle();
|
||||
// We are ok, we can store extra actions and report back the exception.
|
||||
return {result: {bundle: extraBundle, accessControl}, failure: applyExc};
|
||||
} catch(rollbackExc) {
|
||||
this._log.error(docSession, "Failed to apply undo of rejected action", rollbackExc.message);
|
||||
await accessControl.finishedBundle();
|
||||
await this._activeDoc.shutdown();
|
||||
throw rollbackExc;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return {sandboxActionBundle, undo, docActions, accessControl};
|
||||
}
|
||||
|
||||
private _startGranularAccessForBundle(
|
||||
docSession: OptDocSession|null,
|
||||
bundle: SandboxActionBundle,
|
||||
userActions: UserAction[],
|
||||
options: ApplyUAExtendedOptions|null
|
||||
) {
|
||||
const undo = getEnvContent(bundle.undo);
|
||||
const docActions = getEnvContent(bundle.stored).concat(getEnvContent(bundle.calc));
|
||||
const isDirect = getEnvContent(bundle.direct);
|
||||
return this._activeDoc.getGranularAccessForBundle(
|
||||
docSession || makeExceptionalDocSession('share'),
|
||||
docActions,
|
||||
undo,
|
||||
userActions,
|
||||
isDirect,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the extra bundle that effectively was applied to the data engine.
|
||||
* @param undoResult Result of applying undo actions to the data engine.
|
||||
* @param undoSource Actions that were sent to perform the undo.
|
||||
* @returns A bundle with extra actions that were applied to the data engine or null if there are no extra actions.
|
||||
*/
|
||||
private _createExtraBundle(undoResult: SandboxActionBundle, undoSource: DocAction[]): SandboxActionBundle|null {
|
||||
// First check that what we sent is what we stored, since those are undo actions, they should be identical. We
|
||||
// need to reverse the order of undo actions (they are reversed in data-engine by ApplyUndoActions)
|
||||
const sent = undoSource.slice().reverse();
|
||||
const storedHead = getEnvContent(undoResult.stored).slice(0, sent.length);
|
||||
// If we have less actions or they are not equal, we need need to fail immediately, this was not expected.
|
||||
if (undoResult.stored.length < undoSource.length) {
|
||||
throw new Error("There are less actions stored then expected");
|
||||
}
|
||||
if (!storedHead.every((action, i) => isEqual(action, sent[i]))) {
|
||||
throw new Error("Stored actions differ from sent actions");
|
||||
}
|
||||
// If we have the same number of actions and they are equal there is nothing to return.
|
||||
if (undoResult.stored.length === undoSource.length) {
|
||||
return null;
|
||||
}
|
||||
// Create a fake bundle simulating only those extra actions that were applied.
|
||||
return {
|
||||
envelopes: undoResult.envelopes, // Envelops are not supported, so we can use the first one (which is always #ALL)
|
||||
stored: undoResult.stored.slice(undoSource.length),
|
||||
// All actions are treated as direct, we want to perform ACL check on them.
|
||||
direct: undoResult.direct.slice(undoSource.length),
|
||||
calc: [], // Calc actions are also not used anymore.
|
||||
undo: [], // We won't allow to undo this one.
|
||||
retValues: undoResult.retValues.slice(undoSource.length),
|
||||
rowCount: undoResult.rowCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the index of the envelope containing the '#ALL' recipient, adding such an envelope to
|
||||
* the provided array if it wasn't already there.
|
||||
|
Loading…
Reference in New Issue
Block a user