mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) deal with write access for attachments
Summary: Attachments are a special case for granular access control. A user is now allowed to read a given attachment if they have read access to a cell containing its id. So when a user writes to a cell in an attachment column, it is important that they can only write the ids of cells to which they have access. This diff allows a user to add an attachment id in a cell if: * The user already has access to that a attachment via some existing cell, or * The user recently updated the attachment, or * The attachment change is from an undo/redo of a previous action attributed to that user Test Plan: Updated tests Reviewers: georgegevoian, dsagal Reviewed By: georgegevoian, dsagal Differential Revision: https://phab.getgrist.com/D3681
This commit is contained in:
parent
955fdf4ae7
commit
ea71312d0e
@ -203,6 +203,7 @@ export class AttachmentsEditor extends NewBaseEditor {
|
||||
...this._docComm.getUrlParams(),
|
||||
name: filename,
|
||||
...cell,
|
||||
maybeNew: 1, // The attachment may be uploaded by the user but not stored in the cell yet.
|
||||
attId,
|
||||
...(inline ? {inline: 1} : {})
|
||||
});
|
||||
|
@ -3,6 +3,7 @@
|
||||
* See also EncActionBundle for how these are packaged for encryption.
|
||||
*/
|
||||
|
||||
import {ApplyUAOptions} from 'app/common/ActiveDocAPI';
|
||||
import {DocAction, UserAction} from 'app/common/DocActions';
|
||||
import {RowCounts} from 'app/common/DocUsage';
|
||||
|
||||
@ -50,6 +51,7 @@ export function getEnvContent<Content>(items: Array<EnvContent<Content>>): Conte
|
||||
export interface UserActionBundle {
|
||||
info: ActionInfo;
|
||||
userActions: UserAction[];
|
||||
options?: ApplyUAOptions;
|
||||
}
|
||||
|
||||
// ActionBundle as received from the sandbox. It does not have some action metadata, but does have
|
||||
|
@ -12,10 +12,18 @@ export interface ApplyUAOptions {
|
||||
desc?: string; // Overrides the description of the action.
|
||||
otherId?: number; // For undo/redo; the actionNum of the original action to which it applies.
|
||||
linkId?: number; // For bundled actions, actionNum of the previous action in the bundle.
|
||||
bestEffort?: boolean; // If set, action may be applied in part if it cannot be applied completely.
|
||||
parseStrings?: boolean; // If true, parses string values in some actions based on the column
|
||||
}
|
||||
|
||||
export interface ApplyUAExtendedOptions extends ApplyUAOptions {
|
||||
bestEffort?: boolean; // If set, action may be applied in part if it cannot be applied completely.
|
||||
fromOwnHistory?: boolean; // If set, action is confirmed to be a redo/undo taken from history, from
|
||||
// an action marked as being by the current user.
|
||||
oldestSource?: number; // If set, gives the timestamp of the oldest source the undo/redo
|
||||
// action was built from, expressed as number of milliseconds
|
||||
// elapsed since January 1, 1970 00:00:00 UTC
|
||||
}
|
||||
|
||||
export interface ApplyUAResult {
|
||||
actionNum: number; // number of the action that got recorded.
|
||||
retValues: any[]; // array of return values, one for each of the passed-in user actions.
|
||||
|
@ -46,6 +46,7 @@ export interface UserInfo {
|
||||
LinkKey: Record<string, string | undefined>;
|
||||
UserID: number | null;
|
||||
UserRef: string | null;
|
||||
SessionID: string | null;
|
||||
[attributes: string]: unknown;
|
||||
toJSON(): {[key: string]: any};
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup';
|
||||
import {ActionSummary} from "app/common/ActionSummary";
|
||||
import {
|
||||
AclTableDescription,
|
||||
ApplyUAExtendedOptions,
|
||||
ApplyUAOptions,
|
||||
ApplyUAResult,
|
||||
DataSourceTransformed,
|
||||
@ -55,7 +56,6 @@ import {
|
||||
RowCounts,
|
||||
} from 'app/common/DocUsage';
|
||||
import {normalizeEmail} from 'app/common/emails';
|
||||
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
||||
import {parseUrlId} from 'app/common/gristUrls';
|
||||
@ -113,7 +113,7 @@ import {
|
||||
makeExceptionalDocSession,
|
||||
OptDocSession
|
||||
} from './DocSession';
|
||||
import {createAttachmentsIndex, DocStorage} from './DocStorage';
|
||||
import {createAttachmentsIndex, DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY} from './DocStorage';
|
||||
import {expandQuery} from './ExpandedQuery';
|
||||
import {GranularAccess, GranularAccessForBundle} from './GranularAccess';
|
||||
import {OnDemandActions} from './OnDemandActions';
|
||||
@ -121,6 +121,7 @@ import {getLogMetaFromDocSession, timeoutReached} from './serverUtils';
|
||||
import {findOrAddAllEnvelope, Sharing} from './Sharing';
|
||||
import cloneDeep = require('lodash/cloneDeep');
|
||||
import flatten = require('lodash/flatten');
|
||||
import pick = require('lodash/pick');
|
||||
import remove = require('lodash/remove');
|
||||
import sum = require('lodash/sum');
|
||||
import without = require('lodash/without');
|
||||
@ -141,9 +142,6 @@ const ACTIVEDOC_TIMEOUT = (process.env.NODE_ENV === 'production') ? 30 : 5;
|
||||
// We'll wait this long between re-measuring sandbox memory.
|
||||
const MEMORY_MEASUREMENT_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
// Cleanup expired attachments every hour (also happens when shutting down)
|
||||
const REMOVE_UNUSED_ATTACHMENTS_DELAY = {delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000};
|
||||
|
||||
// Apply the UpdateCurrentTime user action every hour
|
||||
const UPDATE_CURRENT_TIME_DELAY = {delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000};
|
||||
|
||||
@ -571,6 +569,12 @@ export class ActiveDoc extends EventEmitter {
|
||||
} finally {
|
||||
this._docManager.removeActiveDoc(this);
|
||||
}
|
||||
try {
|
||||
await this._granularAccess.close();
|
||||
} catch (err) {
|
||||
// This should not happen.
|
||||
this._log.error(docSession, "failed to shutdown granular access", err);
|
||||
}
|
||||
this._log.debug(docSession, "shutdown complete");
|
||||
}
|
||||
|
||||
@ -844,6 +848,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
this._updateAttachmentsSize().catch(e => {
|
||||
this._log.warn(docSession, 'failed to update attachments size', e);
|
||||
});
|
||||
await this._granularAccess.noteUploads(docSession, result.retValues);
|
||||
return result.retValues;
|
||||
} finally {
|
||||
await globalUploadSet.cleanup(uploadId);
|
||||
@ -868,47 +873,40 @@ export class ActiveDoc extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Given a _gristAttachments record, returns a promise for the attachment data.
|
||||
* Can optionally take a cell in which the attachment is expected to be
|
||||
* referenced, and/or a `maybeNew` flag which, when set, specifies that the
|
||||
* attachment may be a recent upload that is not yet referenced in the document.
|
||||
* @returns {Promise<Buffer>} Promise for the data of this attachment; rejected on error.
|
||||
*/
|
||||
public async getAttachmentData(docSession: OptDocSession, attRecord: MetaRowRecord<"_grist_Attachments">,
|
||||
cell?: SingleCell): Promise<Buffer> {
|
||||
options?: {
|
||||
cell?: SingleCell,
|
||||
maybeNew?: boolean,
|
||||
}): Promise<Buffer> {
|
||||
const attId = attRecord.id;
|
||||
const fileIdent = attRecord.fileIdent;
|
||||
const cell = options?.cell;
|
||||
const maybeNew = options?.maybeNew;
|
||||
if (
|
||||
await this._granularAccess.canReadEverything(docSession) ||
|
||||
await this.canDownload(docSession)
|
||||
) {
|
||||
// Do not need to sweat over access to attachments if user can
|
||||
// read everything or download everything.
|
||||
} else if (cell) {
|
||||
// Only provide the download if the user has access to the cell
|
||||
// they specified, and that cell is in an attachment column,
|
||||
// and the cell contains the specified attachment.
|
||||
await this._granularAccess.assertAttachmentAccess(docSession, cell, attId);
|
||||
} else {
|
||||
// Find cells that refer to the given attachment.
|
||||
const cells = await this.docStorage.findAttachmentReferences(attId);
|
||||
// Run through them to see if the user has access to any of them.
|
||||
// If so, we'll allow the download. We'd expect in a typical document
|
||||
// this this will be a small list of cells, typically 1 or less, but
|
||||
// of course extreme cases are possible.
|
||||
let goodCell: SingleCell|undefined;
|
||||
for (const possibleCell of cells) {
|
||||
try {
|
||||
await this._granularAccess.assertAttachmentAccess(docSession, possibleCell, attId);
|
||||
goodCell = possibleCell;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorWithCode && e.code === 'ACL_DENY') {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
if (maybeNew && await this._granularAccess.isAttachmentUploadedByUser(docSession, attId)) {
|
||||
// Fine, this is an attachment the user uploaded (recently).
|
||||
} else if (cell) {
|
||||
// Only provide the download if the user has access to the cell
|
||||
// they specified, and that cell is in an attachment column,
|
||||
// and the cell contains the specified attachment.
|
||||
await this._granularAccess.assertAttachmentAccess(docSession, cell, attId);
|
||||
} else {
|
||||
if (!await this._granularAccess.findAttachmentCellForUser(docSession, attId)) {
|
||||
// We found no reason to allow this user to access the attachment.
|
||||
throw new ApiError('Cannot access attachment', 403);
|
||||
}
|
||||
}
|
||||
if (!goodCell) {
|
||||
// We found no reason to allow this user to access the attachment.
|
||||
throw new ApiError('Cannot access attachment', 403);
|
||||
}
|
||||
}
|
||||
const data = await this.docStorage.getFileData(fileIdent);
|
||||
if (!data) { throw new ApiError("Invalid attachment identifier", 404); }
|
||||
@ -1165,24 +1163,9 @@ export class ActiveDoc extends EventEmitter {
|
||||
* actionGroup.
|
||||
*/
|
||||
public async applyUserActions(docSession: OptDocSession, actions: UserAction[],
|
||||
options?: ApplyUAOptions): Promise<ApplyUAResult> {
|
||||
assert(Array.isArray(actions), "`actions` parameter should be an array.");
|
||||
// Be careful not to sneak into user action queue before Calculate action, otherwise
|
||||
// there'll be a deadlock.
|
||||
await this.waitForInitialization();
|
||||
|
||||
if (
|
||||
this.dataLimitStatus === "deleteOnly" &&
|
||||
!actions.every(action => [
|
||||
'RemoveTable', 'RemoveColumn', 'RemoveRecord', 'BulkRemoveRecord',
|
||||
'RemoveViewSection', 'RemoveView', 'ApplyUndoActions', 'RespondToRequests',
|
||||
].includes(action[0] as string))
|
||||
) {
|
||||
throw new Error("Document is in delete-only mode");
|
||||
}
|
||||
|
||||
// Granular access control implemented in _applyUserActions.
|
||||
return await this._applyUserActions(docSession, actions, options);
|
||||
unsanitizedOptions?: ApplyUAOptions): Promise<ApplyUAResult> {
|
||||
const options = sanitizeApplyUAOptions(unsanitizedOptions);
|
||||
return this._applyUserActionsWithExtendedOptions(docSession, actions, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1200,12 +1183,25 @@ export class ActiveDoc extends EventEmitter {
|
||||
actionNums: number[],
|
||||
actionHashes: string[],
|
||||
undo: boolean,
|
||||
options?: ApplyUAOptions): Promise<ApplyUAResult> {
|
||||
unsanitizedOptions?: ApplyUAOptions): Promise<ApplyUAResult> {
|
||||
const options = sanitizeApplyUAOptions(unsanitizedOptions);
|
||||
const actionBundles = await this._actionHistory.getActions(actionNums);
|
||||
let fromOwnHistory: boolean = true;
|
||||
const user = getDocSessionUser(docSession);
|
||||
let oldestSource: number = Date.now();
|
||||
for (const [index, bundle] of actionBundles.entries()) {
|
||||
const actionNum = actionNums[index];
|
||||
const actionHash = actionHashes[index];
|
||||
if (!bundle) { throw new Error(`Could not find actionNum ${actionNum}`); }
|
||||
const info = bundle.info[1];
|
||||
const bundleEmail = info.user || '';
|
||||
const sessionEmail = user?.email || '';
|
||||
if (normalizeEmail(sessionEmail) !== normalizeEmail(bundleEmail)) {
|
||||
fromOwnHistory = false;
|
||||
}
|
||||
if (info.time && info.time < oldestSource) {
|
||||
oldestSource = info.time;
|
||||
}
|
||||
if (actionHash !== bundle.actionHash) {
|
||||
throw new Error(`Hash mismatch for actionNum ${actionNum}: ` +
|
||||
`expected ${actionHash} but got ${bundle.actionHash}`);
|
||||
@ -1221,7 +1217,10 @@ export class ActiveDoc extends EventEmitter {
|
||||
// It could be that error cases and timing etc leak some info prior to this
|
||||
// point.
|
||||
// Undos are best effort now by default.
|
||||
return this.applyUserActions(docSession, actions, {bestEffort: undo, ...(options||{})});
|
||||
return this._applyUserActionsWithExtendedOptions(
|
||||
docSession, actions, {bestEffort: undo,
|
||||
oldestSource,
|
||||
fromOwnHistory, ...(options||{})});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1685,8 +1684,9 @@ export class ActiveDoc extends EventEmitter {
|
||||
* granular access rules.
|
||||
*/
|
||||
public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],
|
||||
userActions: UserAction[], isDirect: boolean[]): GranularAccessForBundle {
|
||||
this._granularAccess.getGranularAccessForBundle(docSession, docActions, undo, userActions, isDirect);
|
||||
userActions: UserAction[], isDirect: boolean[],
|
||||
options: ApplyUAOptions|null): GranularAccessForBundle {
|
||||
this._granularAccess.getGranularAccessForBundle(docSession, docActions, undo, userActions, isDirect, options);
|
||||
return this._granularAccess;
|
||||
}
|
||||
|
||||
@ -1784,7 +1784,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
*/
|
||||
@ActiveDoc.keepDocOpen
|
||||
protected async _applyUserActions(docSession: OptDocSession, actions: UserAction[],
|
||||
options: ApplyUAOptions = {}): Promise<ApplyUAResult> {
|
||||
options: ApplyUAExtendedOptions = {}): Promise<ApplyUAResult> {
|
||||
|
||||
const client = docSession.client;
|
||||
this._log.debug(docSession, "_applyUserActions(%s, %s)%s", client, shortDesc(actions),
|
||||
@ -1796,13 +1796,14 @@ export class ActiveDoc extends EventEmitter {
|
||||
}
|
||||
|
||||
if (options?.bestEffort) {
|
||||
actions = await this._granularAccess.prefilterUserActions(docSession, actions);
|
||||
actions = await this._granularAccess.prefilterUserActions(docSession, actions, options);
|
||||
}
|
||||
await this._granularAccess.checkUserActions(docSession, actions);
|
||||
|
||||
// Create the UserActionBundle.
|
||||
const action: UserActionBundle = {
|
||||
info: this._makeInfo(docSession, options),
|
||||
options,
|
||||
userActions: actions,
|
||||
};
|
||||
|
||||
@ -1818,6 +1819,27 @@ export class ActiveDoc extends EventEmitter {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _applyUserActionsWithExtendedOptions(docSession: OptDocSession, actions: UserAction[],
|
||||
options?: ApplyUAExtendedOptions): Promise<ApplyUAResult> {
|
||||
assert(Array.isArray(actions), "`actions` parameter should be an array.");
|
||||
// Be careful not to sneak into user action queue before Calculate action, otherwise
|
||||
// there'll be a deadlock.
|
||||
await this.waitForInitialization();
|
||||
|
||||
if (
|
||||
this.dataLimitStatus === "deleteOnly" &&
|
||||
!actions.every(action => [
|
||||
'RemoveTable', 'RemoveColumn', 'RemoveRecord', 'BulkRemoveRecord',
|
||||
'RemoveViewSection', 'RemoveView', 'ApplyUndoActions', 'RespondToRequests',
|
||||
].includes(action[0] as string))
|
||||
) {
|
||||
throw new Error("Document is in delete-only mode");
|
||||
}
|
||||
|
||||
// Granular access control implemented in _applyUserActions.
|
||||
return await this._applyUserActions(docSession, actions, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new document file without using or initializing the data engine.
|
||||
*/
|
||||
@ -2306,3 +2328,7 @@ export function tableIdToRef(metaTables: { [p: string]: TableDataAction }, table
|
||||
}
|
||||
return tableRefs[tableRowIndex];
|
||||
}
|
||||
|
||||
export function sanitizeApplyUAOptions(options?: ApplyUAOptions): ApplyUAOptions {
|
||||
return pick(options||{}, ['desc', 'otherId', 'linkId', 'parseStrings']);
|
||||
}
|
||||
|
@ -290,7 +290,7 @@ export class DocWorkerApi {
|
||||
const ext = path.extname(fileIdent);
|
||||
const origName = attRecord.fileName as string;
|
||||
const fileName = ext ? path.basename(origName, path.extname(origName)) + ext : origName;
|
||||
const fileData = await activeDoc.getAttachmentData(docSessionFromRequest(req), attRecord, cell);
|
||||
const fileData = await activeDoc.getAttachmentData(docSessionFromRequest(req), attRecord, {cell});
|
||||
res.status(200)
|
||||
.type(ext)
|
||||
// Construct a content-disposition header of the form 'attachment; filename="NAME"'
|
||||
|
@ -47,6 +47,10 @@ const PENDING_VALUE = [GristObjCode.Pending];
|
||||
// that someone would delete a reference to an attachment and then undo that action this many days later.
|
||||
export const ATTACHMENTS_EXPIRY_DAYS = 7;
|
||||
|
||||
// Cleanup expired attachments every hour (also happens when shutting down).
|
||||
export const REMOVE_UNUSED_ATTACHMENTS_DELAY = {delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000};
|
||||
|
||||
|
||||
export class DocStorage implements ISQLiteDB, OnDemandStorage {
|
||||
|
||||
// ======================================================================
|
||||
|
@ -2,6 +2,7 @@
|
||||
* DocWorker collects the methods and endpoints that relate to a single Grist document.
|
||||
* In hosted environment, this comprises the functionality of the DocWorker instance type.
|
||||
*/
|
||||
import {isAffirmative} from 'app/common/gutil';
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
|
||||
import {assertAccess, getOrSetDocAuth, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||
@ -42,6 +43,7 @@ export class DocWorker {
|
||||
const tableId = stringParam(req.query.tableId, 'tableId');
|
||||
const rowId = integerParam(req.query.rowId, 'rowId');
|
||||
const cell = {colId, tableId, rowId};
|
||||
const maybeNew = isAffirmative(req.query.maybeNew);
|
||||
const attId = integerParam(req.query.attId, 'attId');
|
||||
const attRecord = activeDoc.getAttachmentMetadata(attId);
|
||||
const ext = path.extname(attRecord.fileIdent);
|
||||
@ -54,7 +56,7 @@ export class DocWorker {
|
||||
// Construct a content-disposition header of the form 'inline|attachment; filename="NAME"'
|
||||
const contentDispType = inline ? "inline" : "attachment";
|
||||
const contentDispHeader = contentDisposition(stringParam(req.query.name, 'name'), {type: contentDispType});
|
||||
const data = await activeDoc.getAttachmentData(docSession, attRecord, cell);
|
||||
const data = await activeDoc.getAttachmentData(docSession, attRecord, {cell, maybeNew});
|
||||
res.status(200)
|
||||
.type(ext)
|
||||
.set('Content-Disposition', contentDispHeader)
|
||||
|
@ -2,8 +2,9 @@ import { ALL_PERMISSION_PROPS } from 'app/common/ACLPermissions';
|
||||
import { ACLRuleCollection, SPECIAL_RULES_TABLE_ID } from 'app/common/ACLRuleCollection';
|
||||
import { ActionGroup } from 'app/common/ActionGroup';
|
||||
import { createEmptyActionSummary } from 'app/common/ActionSummary';
|
||||
import { ServerQuery } from 'app/common/ActiveDocAPI';
|
||||
import { ApplyUAExtendedOptions, ServerQuery } from 'app/common/ActiveDocAPI';
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { MapWithTTL } from 'app/common/AsyncCreate';
|
||||
import {
|
||||
AddRecord,
|
||||
BulkAddRecord,
|
||||
@ -27,7 +28,7 @@ import { ErrorWithCode } from 'app/common/ErrorWithCode';
|
||||
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
|
||||
import { UserInfo } from 'app/common/GranularAccessClause';
|
||||
import * as gristTypes from 'app/common/gristTypes';
|
||||
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
|
||||
import { getSetMapValue, isNonNullish, isNumber, pruneArray } from 'app/common/gutil';
|
||||
import { MetaRowRecord, SingleCell } from 'app/common/TableData';
|
||||
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
||||
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
||||
@ -37,12 +38,13 @@ import { compileAclFormula } from 'app/server/lib/ACLFormula';
|
||||
import { DocClients } from 'app/server/lib/DocClients';
|
||||
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionUser,
|
||||
OptDocSession } from 'app/server/lib/DocSession';
|
||||
import { DocStorage } from 'app/server/lib/DocStorage';
|
||||
import { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from 'app/server/lib/DocStorage';
|
||||
import log from 'app/server/lib/log';
|
||||
import { IPermissionInfo, PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo';
|
||||
import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo';
|
||||
import { integerParam } from 'app/server/lib/requestUtils';
|
||||
import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
|
||||
import { getColIdsFromDocAction, getColValuesFromDocAction, getRelatedRows,
|
||||
getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
|
||||
import cloneDeep = require('lodash/cloneDeep');
|
||||
import fromPairs = require('lodash/fromPairs');
|
||||
import memoize = require('lodash/memoize');
|
||||
@ -176,6 +178,19 @@ const OTHER_RECOGNIZED_ACTIONS = new Set([
|
||||
'RemoveViewSection',
|
||||
]);
|
||||
|
||||
// When an attachment is uploaded, it isn't immediately added to a cell in
|
||||
// the document. We grant the uploader a special period where they can freely
|
||||
// add or re-add the attachment to the document without access control fuss.
|
||||
// We keep that period within the time range where an unused attachment
|
||||
// would get deleted.
|
||||
const UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD =
|
||||
(REMOVE_UNUSED_ATTACHMENTS_DELAY.delayMs - REMOVE_UNUSED_ATTACHMENTS_DELAY.varianceMs) / 2;
|
||||
|
||||
// When a user undoes their own action or actions, checks of attachment ownership
|
||||
// are handled specially. This special handling will not apply for undoes of actions
|
||||
// older than this limit.
|
||||
const HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface DocUpdateMessage {
|
||||
actionGroup: ActionGroup;
|
||||
docActions: DocAction[];
|
||||
@ -229,6 +244,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
// garbage-collection once docSession is no longer in use.
|
||||
private _userAttributesMap = new WeakMap<OptDocSession, UserAttributes>();
|
||||
private _prevUserAttributesMap: WeakMap<OptDocSession, UserAttributes>|undefined;
|
||||
private _attachmentUploads = new MapWithTTL<number, string>(UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD);
|
||||
|
||||
// When broadcasting a sequence of DocAction[]s, this contains the state of
|
||||
// affected rows for the relevant table before and after each DocAction. It
|
||||
@ -251,6 +267,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
// Flag for whether doc actions mention a rule change, even if passive due to
|
||||
// schema changes.
|
||||
hasAnyRuleChange: boolean,
|
||||
options: ApplyUAExtendedOptions|null,
|
||||
}|null;
|
||||
|
||||
public constructor(
|
||||
@ -263,8 +280,13 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
private _docId: string) {
|
||||
}
|
||||
|
||||
public async close() {
|
||||
this._attachmentUploads.clear();
|
||||
}
|
||||
|
||||
public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],
|
||||
userActions: UserAction[], isDirect: boolean[]): void {
|
||||
userActions: UserAction[], isDirect: boolean[],
|
||||
options: ApplyUAExtendedOptions|null): void {
|
||||
if (this._activeBundle) { throw new Error('Cannot start a bundle while one is already in progress'); }
|
||||
// This should never happen - attempts to write to a pre-fork session should be
|
||||
// caught by an Authorizer. But let's be paranoid, since we may be pretending to
|
||||
@ -273,7 +295,8 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
if (docSession.forkingAsOwner) { throw new Error('Should never modify a prefork'); }
|
||||
this._activeBundle = {
|
||||
docSession, docActions, undo, userActions, isDirect,
|
||||
applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false
|
||||
applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false,
|
||||
options,
|
||||
};
|
||||
this._activeBundle.hasDeliberateRuleChange =
|
||||
scanActionsRecursively(userActions, (a) => isAclTable(String(a[1])));
|
||||
@ -300,6 +323,16 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
return access.getUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Represent fields from the session in an input object for ACL rules.
|
||||
* Just one field currently, "user".
|
||||
*/
|
||||
public async inputs(docSession: OptDocSession): Promise<AclMatchInput> {
|
||||
return {
|
||||
user: await this._getUser(docSession),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether user has any access to table.
|
||||
*/
|
||||
@ -348,7 +381,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
return fail();
|
||||
}
|
||||
const rec = new RecordView(rows, 0);
|
||||
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec};
|
||||
const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec: rec};
|
||||
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
|
||||
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
|
||||
if (rowAccess === 'deny') { fail(); }
|
||||
@ -382,6 +415,42 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the specified attachment is known to have been uploaded
|
||||
* by the user (identified by SessionID) recently.
|
||||
*/
|
||||
public async isAttachmentUploadedByUser(docSession: OptDocSession, attId: number): Promise<boolean> {
|
||||
const user = await this.getUser(docSession);
|
||||
const id = user.SessionID || '';
|
||||
return (this._attachmentUploads.get(attId) === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a cell in an attachment column that contains the specified attachment,
|
||||
* and which is accessible by the user associated with the session.
|
||||
*/
|
||||
public async findAttachmentCellForUser(docSession: OptDocSession, attId: number): Promise<SingleCell|undefined> {
|
||||
// Find cells that refer to the given attachment.
|
||||
const cells = await this._docStorage.findAttachmentReferences(attId);
|
||||
// Run through them to see if the user has access to any of them.
|
||||
// We'd expect in a typical document that this will be a small
|
||||
// list of cells, typically 1 or less, but of course extreme cases
|
||||
// are possible.
|
||||
for (const possibleCell of cells) {
|
||||
try {
|
||||
await this.assertAttachmentAccess(docSession, possibleCell, attId);
|
||||
return possibleCell;
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorWithCode && e.code === 'ACL_DENY') {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Nothing found.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@ -612,7 +681,8 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
* Any filtering done here is NOT a security measure, and the output should
|
||||
* not be granted any level of automatic trust.
|
||||
*/
|
||||
public async prefilterUserActions(docSession: OptDocSession, actions: UserAction[]): Promise<UserAction[]> {
|
||||
public async prefilterUserActions(docSession: OptDocSession, actions: UserAction[],
|
||||
options: ApplyUAExtendedOptions|null): Promise<UserAction[]> {
|
||||
// Currently we only attempt prefiltering for an ApplyUndoActions.
|
||||
if (actions.length !== 1) { return actions; }
|
||||
const userAction = actions[0];
|
||||
@ -646,7 +716,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
// any case (though we could rearrange to limit how undo actions are
|
||||
// requested).
|
||||
this.getGranularAccessForBundle(docSession, docActions, [], docActions,
|
||||
docActions.map(() => true));
|
||||
docActions.map(() => true), options);
|
||||
for (const [actionIdx, action] of docActions.entries()) {
|
||||
// A single action might contain forbidden material at cell, row, column,
|
||||
// or table level. Retaining permitted material may require refactoring the
|
||||
@ -880,6 +950,36 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
(_docSession) => this._filterDocUpdate(_docSession, message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when uploads occur. We record the fact that the specified attachment
|
||||
* ids originated in uploads by the current user, for a certain length of time.
|
||||
* During that time, attempts by the user to use these attachment ids in an
|
||||
* attachment column will be accepted. The user is identified by SessionID,
|
||||
* which is a user id for logged in users, and a session-unique id for
|
||||
* anonymous users accessing Grist from a browser.
|
||||
*
|
||||
* A remaining weakness of this protection could be if attachment ids were
|
||||
* reused, and reused quickly. Attachments can be deleted after
|
||||
* REMOVE_UNUSED_ATTACHMENTS_DELAY and on document shutdown. We keep
|
||||
* UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD less than REMOVE_UNUSED_ATTACHMENTS_DELAY,
|
||||
* and wipe our records on document shutdown.
|
||||
*/
|
||||
public async noteUploads(docSession: OptDocSession, attIds: number[]) {
|
||||
const user = await this.getUser(docSession);
|
||||
const id = user.SessionID;
|
||||
if (!id) {
|
||||
log.rawError('noteUploads needs a SessionID', {
|
||||
docId: this._docId,
|
||||
attIds,
|
||||
userId: user.UserID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const attId of attIds) {
|
||||
this._attachmentUploads.set(attId, id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove cached access information for a given session.
|
||||
public flushAccess(docSession: OptDocSession) {
|
||||
this._ruler.flushAccess(docSession);
|
||||
@ -1465,7 +1565,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
|
||||
const rec = new RecordView(rowsRec, undefined);
|
||||
const newRec = new RecordView(rowsNewRec, undefined);
|
||||
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec};
|
||||
const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec};
|
||||
|
||||
const [, tableId, , colValues] = action;
|
||||
let filteredColValues: ColValues | BulkColValues | undefined | null = null;
|
||||
@ -1547,7 +1647,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
colId?: string): Promise<number[]> {
|
||||
const ruler = await this._getRuler(cursor);
|
||||
const rec = new RecordView(data, undefined);
|
||||
const input: AclMatchInput = {user: await this._getUser(cursor.docSession), rec};
|
||||
const input: AclMatchInput = {...await this.inputs(cursor.docSession), rec};
|
||||
|
||||
const [, tableId, rowIds] = data;
|
||||
const toRemove: number[] = [];
|
||||
@ -1893,7 +1993,9 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
const needMeta = docActions.some(a => isSchemaAction(a) || getTableId(a).startsWith('_grist_'));
|
||||
if (!needMeta) {
|
||||
// Sometimes, the intermediate states are trivial.
|
||||
return docActions.map(action => ({action}));
|
||||
// TODO: look into whether it would be worth caching attachment columns.
|
||||
const attachmentColumns = this._getAttachmentColumns(this._docData);
|
||||
return docActions.map(action => ({action, attachmentColumns}));
|
||||
}
|
||||
const metaDocData = new DocData(
|
||||
async (tableId) => {
|
||||
@ -1944,11 +2046,33 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
replaceRuler = false;
|
||||
}
|
||||
step.ruler = ruler;
|
||||
step.attachmentColumns = this._getAttachmentColumns(metaDocData);
|
||||
steps.push(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate attachment columns, represented as a map from tableId to
|
||||
* a set of colIds.
|
||||
*/
|
||||
private _getAttachmentColumns(metaDocData: DocData): Map<string, Set<string>> {
|
||||
const tablesTable = metaDocData.getMetaTable('_grist_Tables');
|
||||
const columnsTable = metaDocData.getMetaTable('_grist_Tables_column');
|
||||
const attachmentColumns: Map<string, Set<string>> = new Map();
|
||||
for (const col of columnsTable.filterRecords({type: 'Attachments'})) {
|
||||
const table = tablesTable.getRecord(col.parentId);
|
||||
const tableId = table?.tableId;
|
||||
if (!tableId) { throw new Error('table not found'); /* should not happen */ }
|
||||
const colId = col.colId;
|
||||
if (!attachmentColumns.has(tableId)) {
|
||||
attachmentColumns.set(tableId, new Set());
|
||||
}
|
||||
attachmentColumns.get(tableId)!.add(colId);
|
||||
}
|
||||
return attachmentColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any permitted parts of an action. A completely forbidden
|
||||
* action results in an empty list. Forbidden columns and rows will
|
||||
@ -2095,6 +2219,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
}
|
||||
|
||||
private async _checkIncomingDocAction(cursor: ActionCursor): Promise<void> {
|
||||
await this._checkIncomingAttachmentChanges(cursor);
|
||||
const {action, docSession} = cursor;
|
||||
const accessCheck = await this._getAccessForActionType(docSession, action, 'fatal');
|
||||
const tableId = getTableId(action);
|
||||
@ -2111,6 +2236,69 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
this._pruneColumns(action, permInfo, tableId, accessCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a look at the DocAction and see if it might allow the user to
|
||||
* introduce attachment ids into a cell. If so, make sure the user
|
||||
* has the right to access any attachments mentioned.
|
||||
*/
|
||||
private async _checkIncomingAttachmentChanges(cursor: ActionCursor): Promise<void> {
|
||||
const options = this._activeBundle?.options;
|
||||
if (options?.fromOwnHistory && options.oldestSource &&
|
||||
Date.now() - options.oldestSource < HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD) {
|
||||
return;
|
||||
}
|
||||
const {action, docSession} = cursor;
|
||||
if (!isDataAction(action)) { return; }
|
||||
if (isRemoveRecordAction(action)) { return; }
|
||||
const tableId = getTableId(action);
|
||||
const step = await this._getMetaStep(cursor);
|
||||
const attachmentColumns = step.attachmentColumns;
|
||||
if (!attachmentColumns) { return; }
|
||||
const ac = attachmentColumns.get(tableId);
|
||||
if (!ac) { return; }
|
||||
const colIds = getColIdsFromDocAction(action);
|
||||
if (!colIds.some(colId => ac.has(colId))) { return; }
|
||||
if (await this.isOwner(docSession) || await this.canReadEverything(docSession)) { return; }
|
||||
const attIds = new Set<number>();
|
||||
for (const colId of colIds) {
|
||||
if (!ac.has(colId)) { continue; }
|
||||
const values = getColValuesFromDocAction(action, colId);
|
||||
if (!values) { continue; }
|
||||
for (const v of values) {
|
||||
// We expect an array. What should we do with other types?
|
||||
// If we were confident no part of Grist would interpret non-array
|
||||
// values as attachment ids, then we should let them be added, as
|
||||
// part of Grist's spreadsheet-style willingness to allow invalid
|
||||
// data. I decided to go ahead and require that numbers or number-like
|
||||
// strings should be checked as if they were attachment ids, just in
|
||||
// case. But if this proves awkward for someone, it could be reasonable
|
||||
// to only check ids in an array after confirming Grist is strict in
|
||||
// how it interprets material in attachment cells.
|
||||
if (typeof v === 'number') {
|
||||
attIds.add(v);
|
||||
} else if (Array.isArray(v)) {
|
||||
for (const p of v) {
|
||||
if (typeof p === 'number') {
|
||||
attIds.add(p);
|
||||
}
|
||||
}
|
||||
} else if (typeof v === 'boolean' || v === null) {
|
||||
// Nothing obvious to do here.
|
||||
} else if (isNumber(v)) {
|
||||
attIds.add(Math.round(parseFloat(v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const attId of attIds) {
|
||||
if (!await this.isAttachmentUploadedByUser(docSession, attId) &&
|
||||
!await this.findAttachmentCellForUser(docSession, attId)) {
|
||||
throw new ErrorWithCode('ACL_DENY', 'Cannot access attachment', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _getRuler(cursor: ActionCursor) {
|
||||
if (cursor.actionIdx === null) { return this._ruler; }
|
||||
const step = await this._getMetaStep(cursor);
|
||||
@ -2192,7 +2380,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
};
|
||||
const ruler = await this._getRuler(cursor);
|
||||
const permInfo = await ruler.getAccess(docSession);
|
||||
const user = await this._getUser(docSession);
|
||||
const inputs = await this.inputs(docSession);
|
||||
// Cache some data, as they are checked.
|
||||
const readRows = memoize(this._fetchQueryFromDB.bind(this));
|
||||
const hasAccess = async (cell: SingleCell) => {
|
||||
@ -2223,7 +2411,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
}
|
||||
}
|
||||
const rec = rows ? new RecordView(rows, 0) : undefined;
|
||||
const input: AclMatchInput = {user, rec, newRec: rec};
|
||||
const input: AclMatchInput = {...inputs, rec, newRec: rec};
|
||||
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
|
||||
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
|
||||
if (rowAccess === 'deny') { return false; }
|
||||
@ -2286,7 +2474,7 @@ export class Ruler {
|
||||
// TODO The intent of caching is to avoid duplicating rule evaluations while processing a
|
||||
// single request. Caching based on docSession is riskier since those persist across requests.
|
||||
return getSetMapValue(this._permissionInfoMap as Map<OptDocSession, Promise<PermissionInfo>>, docSession,
|
||||
async () => new PermissionInfo(this.ruleCollection, {user: await this._owner.getUser(docSession)}));
|
||||
async () => new PermissionInfo(this.ruleCollection, await this._owner.inputs(docSession)));
|
||||
}
|
||||
|
||||
public flushAccess(docSession: OptDocSession) {
|
||||
@ -2314,6 +2502,7 @@ export class Ruler {
|
||||
|
||||
export interface RulerOwner {
|
||||
getUser(docSession: OptDocSession): Promise<UserInfo>;
|
||||
inputs(docSession: OptDocSession): Promise<AclMatchInput>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2331,6 +2520,7 @@ export interface MetaStep {
|
||||
metaBefore?: {[key: string]: TableDataAction}; // cached structural metadata before action
|
||||
metaAfter?: {[key: string]: TableDataAction}; // cached structural metadata after action
|
||||
ruler?: Ruler; // rules at this step
|
||||
attachmentColumns?: Map<string, Set<string>>; // attachment columns after this step
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2497,7 +2687,7 @@ class CellAccessHelper {
|
||||
private _tableAccess: Map<string, boolean> = new Map();
|
||||
private _rowPermInfo: Map<string, Map<number, PermissionInfo>> = new Map();
|
||||
private _rows: Map<string, TableDataAction> = new Map();
|
||||
private _user!: UserInfo;
|
||||
private _inputs!: AclMatchInput;
|
||||
|
||||
constructor(
|
||||
private _granular: GranularAccess,
|
||||
@ -2511,7 +2701,7 @@ class CellAccessHelper {
|
||||
* Resolves access for all cells, and save the results in the cache.
|
||||
*/
|
||||
public async calculate(cells: SingleCell[]) {
|
||||
this._user = await this._granular.getUser(this._docSession);
|
||||
this._inputs = await this._granular.inputs(this._docSession);
|
||||
const tableIds = new Set(cells.map(cell => cell.tableId));
|
||||
for (const tableId of tableIds) {
|
||||
this._tableAccess.set(tableId, await this._granular.hasTableAccess(this._docSession, tableId));
|
||||
@ -2521,7 +2711,7 @@ class CellAccessHelper {
|
||||
for(const [idx, rowId] of rows[2].entries()) {
|
||||
if (rowIds.has(rowId) === false) { continue; }
|
||||
const rec = new RecordView(rows, idx);
|
||||
const input: AclMatchInput = {user: this._user, rec, newRec: rec};
|
||||
const input: AclMatchInput = {...this._inputs, rec, newRec: rec};
|
||||
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
|
||||
if (!this._rowPermInfo.has(tableId)) {
|
||||
this._rowPermInfo.set(tableId, new Map());
|
||||
@ -2892,6 +3082,7 @@ export class User implements UserInfo {
|
||||
public Origin: string | null = null;
|
||||
public LinkKey: Record<string, string | undefined> = {};
|
||||
public Email: string | null = null;
|
||||
public SessionID: string | null = null;
|
||||
public UserRef: string | null = null;
|
||||
[attribute: string]: any;
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord, DocAction, getTableId,
|
||||
RemoveRecord, ReplaceTableData, TableDataAction, UpdateRecord } from "app/common/DocActions";
|
||||
import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord,
|
||||
CellValue, DocAction, getTableId, RemoveRecord, ReplaceTableData,
|
||||
TableDataAction, UpdateRecord } from "app/common/DocActions";
|
||||
import { getSetMapValue } from "app/common/gutil";
|
||||
|
||||
/**
|
||||
@ -78,7 +79,7 @@ export function getRowIdsFromDocAction(docActions: RemoveRecord | BulkRemoveReco
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny helper to get the row ids mentioned in a record-related DocAction as a list
|
||||
* Tiny helper to get the col ids mentioned in a record-related DocAction as a list
|
||||
* (even if the action is not a bulk action). When the action touches the whole row,
|
||||
* it returns ["*"].
|
||||
*/
|
||||
@ -88,3 +89,21 @@ export function getColIdsFromDocAction(docActions: RemoveRecord | BulkRemoveReco
|
||||
if (docActions[3]) { return Object.keys(docActions[3]); }
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column values for a particular column as CellValue[] from a
|
||||
* record-related DocAction. Undefined if absent.
|
||||
*/
|
||||
export function getColValuesFromDocAction(docAction: RemoveRecord | BulkRemoveRecord | AddRecord |
|
||||
BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData |
|
||||
TableDataAction, colId: string): CellValue[]|undefined {
|
||||
const colValues = docAction[3];
|
||||
if (!colValues) { return undefined; }
|
||||
const cellValues = colValues[colId];
|
||||
if (!cellValues) { return undefined; }
|
||||
if (Array.isArray(docAction[2])) {
|
||||
return cellValues as CellValue[];
|
||||
} else {
|
||||
return [cellValues as CellValue];
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
LocalActionBundle,
|
||||
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 log from 'app/server/lib/log';
|
||||
@ -195,15 +196,16 @@ export class Sharing {
|
||||
const userActions: UserAction[] = [
|
||||
['ApplyDocActions', action.stored.map(envContent => envContent[1])]
|
||||
];
|
||||
return this._doApplyUserActions(action.info[1], userActions, Branch.Shared, null);
|
||||
return this._doApplyUserActions(action.info[1], userActions, Branch.Shared, null, null);
|
||||
}
|
||||
|
||||
private _doApplyUserActionBundle(action: UserActionBundle, docSession: OptDocSession|null): Promise<UserResult> {
|
||||
return this._doApplyUserActions(action.info, action.userActions, Branch.Local, docSession);
|
||||
return this._doApplyUserActions(action.info, action.userActions, Branch.Local, docSession, action.options || null);
|
||||
}
|
||||
|
||||
private async _doApplyUserActions(info: ActionInfo, userActions: UserAction[],
|
||||
branch: Branch, docSession: OptDocSession|null): Promise<UserResult> {
|
||||
branch: Branch, docSession: OptDocSession|null,
|
||||
options: ApplyUAExtendedOptions|null): Promise<UserResult> {
|
||||
const client = docSession && docSession.client;
|
||||
|
||||
if (docSession?.linkId) {
|
||||
@ -211,7 +213,7 @@ export class Sharing {
|
||||
}
|
||||
|
||||
const {sandboxActionBundle, undo, accessControl} =
|
||||
await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions));
|
||||
await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions, options));
|
||||
|
||||
try {
|
||||
|
||||
@ -389,7 +391,8 @@ export class Sharing {
|
||||
shortDesc(envAction[1])));
|
||||
}
|
||||
|
||||
private async _applyActionsToDataEngine(docSession: OptDocSession|null, userActions: UserAction[]) {
|
||||
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(
|
||||
@ -397,7 +400,8 @@ export class Sharing {
|
||||
const isDirect = getEnvContent(sandboxActionBundle.direct);
|
||||
|
||||
const accessControl = this._activeDoc.getGranularAccessForBundle(
|
||||
docSession || makeExceptionalDocSession('share'), docActions, undo, userActions, isDirect
|
||||
docSession || makeExceptionalDocSession('share'), docActions, undo, userActions, isDirect,
|
||||
options
|
||||
);
|
||||
try {
|
||||
// TODO: see if any of the code paths that have no docSession are relevant outside
|
||||
|
Loading…
Reference in New Issue
Block a user