mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) avoid censorship for one client clobbering data for another client
Summary: When filtering document updates to send to clients after a change, censorship of individual cells was being applied to state shared across the clients. This diff eliminates that shared state, and extends testing of broadcasts to check different orderings. Test Plan: extends a test to tickle a reported bug, and gives DocClients a knob to control message order needed to tickle the bug reliably. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D3064
This commit is contained in:
parent
df318ad6b3
commit
07558dceba
@ -11,6 +11,11 @@ import {sendDocMessage} from 'app/server/lib/Comm';
|
||||
import {DocSession, OptDocSession} from 'app/server/lib/DocSession';
|
||||
import * as log from 'app/server/lib/log';
|
||||
|
||||
// Allow tests to impose a serial order for broadcasts if they need that for repeatability.
|
||||
export const Deps = {
|
||||
BROADCAST_ORDER: 'parallel' as 'parallel' | 'series',
|
||||
};
|
||||
|
||||
export class DocClients {
|
||||
private _docSessions: DocSession[] = [];
|
||||
|
||||
@ -78,48 +83,62 @@ export class DocClients {
|
||||
public async broadcastDocMessage(client: Client|null, type: string, messageData: any,
|
||||
filterMessage?: (docSession: OptDocSession,
|
||||
messageData: any) => Promise<any>): Promise<void> {
|
||||
await Promise.all(this._docSessions.map(async curr => {
|
||||
const fromSelf = (curr.client === client);
|
||||
try {
|
||||
// Make sure user still has view access.
|
||||
await curr.authorizer.assertAccess('viewers');
|
||||
if (!filterMessage) {
|
||||
sendDocMessage(curr.client, curr.fd, type, messageData, fromSelf);
|
||||
} else {
|
||||
try {
|
||||
const filteredMessageData = await filterMessage(curr, messageData);
|
||||
if (filteredMessageData) {
|
||||
sendDocMessage(curr.client, curr.fd, type, filteredMessageData, fromSelf);
|
||||
} else {
|
||||
this.activeDoc.logDebug(curr, 'skip broadcastDocMessage because it is not allowed for this client');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code && e.code === 'NEED_RELOAD') {
|
||||
sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf);
|
||||
} else {
|
||||
sendDocMessage(curr.client, curr.fd, 'docUserAction', {error: String(e)}, fromSelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === 'AUTH_NO_VIEW') {
|
||||
// Skip sending data to this user, they have no view access.
|
||||
log.rawDebug('skip broadcastDocMessage because AUTH_NO_VIEW', {
|
||||
docId: curr.authorizer.getDocId(),
|
||||
...curr.client.getLogMeta()
|
||||
});
|
||||
// Go further and trigger a shutdown for this user, in case they are granted
|
||||
// access again later.
|
||||
sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf);
|
||||
} else {
|
||||
throw(e);
|
||||
}
|
||||
const send = (curr: DocSession) => this._send(curr, client, type, messageData, filterMessage);
|
||||
if (Deps.BROADCAST_ORDER === 'parallel') {
|
||||
await Promise.all(this._docSessions.map(send));
|
||||
} else {
|
||||
for (const session of this._docSessions) {
|
||||
await send(session);
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (type === "docUserAction" && messageData.docActions) {
|
||||
for (const action of messageData.docActions) {
|
||||
this.activeDoc.docPluginManager.receiveAction(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a single client. See broadcastDocMessage for parameters.
|
||||
*/
|
||||
private async _send(target: DocSession, client: Client|null, type: string, messageData: any,
|
||||
filterMessage?: (docSession: OptDocSession,
|
||||
messageData: any) => Promise<any>): Promise<void> {
|
||||
const fromSelf = (target.client === client);
|
||||
try {
|
||||
// Make sure user still has view access.
|
||||
await target.authorizer.assertAccess('viewers');
|
||||
if (!filterMessage) {
|
||||
sendDocMessage(target.client, target.fd, type, messageData, fromSelf);
|
||||
} else {
|
||||
try {
|
||||
const filteredMessageData = await filterMessage(target, messageData);
|
||||
if (filteredMessageData) {
|
||||
sendDocMessage(target.client, target.fd, type, filteredMessageData, fromSelf);
|
||||
} else {
|
||||
this.activeDoc.logDebug(target, 'skip broadcastDocMessage because it is not allowed for this client');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code && e.code === 'NEED_RELOAD') {
|
||||
sendDocMessage(target.client, target.fd, 'docShutdown', null, fromSelf);
|
||||
} else {
|
||||
sendDocMessage(target.client, target.fd, 'docUserAction', {error: String(e)}, fromSelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === 'AUTH_NO_VIEW') {
|
||||
// Skip sending data to this user, they have no view access.
|
||||
log.rawDebug('skip broadcastDocMessage because AUTH_NO_VIEW', {
|
||||
docId: target.authorizer.getDocId(),
|
||||
...target.client.getLogMeta()
|
||||
});
|
||||
// Go further and trigger a shutdown for this user, in case they are granted
|
||||
// access again later.
|
||||
sendDocMessage(target.client, target.fd, 'docShutdown', null, fromSelf);
|
||||
} else {
|
||||
throw(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -676,7 +676,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
const tableId = getTableId(data);
|
||||
if (this.getReadPermission(permInfo.getTableAccess(tableId)) === 'mixed') {
|
||||
const readAccessCheck = this._readAccessCheck(docSession);
|
||||
await this._filterRowsAndCells(cursor, data, data, readAccessCheck, true);
|
||||
await this._filterRowsAndCells(cursor, data, data, readAccessCheck, {allowRowRemoval: true});
|
||||
}
|
||||
|
||||
// Filter columns, omitting any to which the user has no access, regardless of rows.
|
||||
@ -932,10 +932,14 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
|
||||
// Return the results, also applying any cell-level access control.
|
||||
const readAccessCheck = this._readAccessCheck(cursor.docSession);
|
||||
const filteredDocActions: DocAction[] = [];
|
||||
for (const a of revisedDocActions) {
|
||||
await this._filterRowsAndCells({...cursor, action: a}, rowsAfter, rowsAfter, readAccessCheck, false);
|
||||
const {filteredAction} =
|
||||
await this._filterRowsAndCells({...cursor, action: a}, rowsAfter, rowsAfter, readAccessCheck,
|
||||
{allowRowRemoval: false, copyOnModify: true});
|
||||
if (filteredAction) { filteredDocActions.push(filteredAction); }
|
||||
}
|
||||
return revisedDocActions;
|
||||
return filteredDocActions;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -947,7 +951,10 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
// This check applies to data changes only.
|
||||
if (!isDataAction(action)) { return; }
|
||||
const {rowsBefore, rowsAfter} = await this._getRowsForRecAndNewRec(cursor);
|
||||
await this._filterRowsAndCells(cursor, rowsBefore, rowsAfter, accessCheck, false);
|
||||
// If any change is needed, this call will fail immediately because we are using
|
||||
// access checks that throw.
|
||||
await this._filterRowsAndCells(cursor, rowsBefore, rowsAfter, accessCheck,
|
||||
{allowRowRemoval: false});
|
||||
}
|
||||
|
||||
private async _getRowsBeforeAndAfter(cursor: ActionCursor) {
|
||||
@ -988,10 +995,15 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify action in place, scrubbing any rows and cells to which access is not granted.
|
||||
* Returns filteredAction, which is the provided action or null - it is null if the
|
||||
* action was entirely eliminated (and was not a bulk action). Also returns
|
||||
* censoredRows, a set of indexes of rows that have a censored value in them.
|
||||
* Scrub any rows and cells to which access is not granted from an
|
||||
* action. Returns filteredAction, which is the provided action, a
|
||||
* modified copy of the provided action, or null. It is null if the
|
||||
* action was entirely eliminated (and was not a bulk action). It is
|
||||
* a modified copy if any scrubbing was needed and copyOnModify is
|
||||
* set, otherwise the original is modified in place.
|
||||
*
|
||||
* Also returns censoredRows, a set of indexes of rows that have a
|
||||
* censored value in them.
|
||||
*
|
||||
* If allowRowRemoval is false, then rows will not be removed, and if the user
|
||||
* does not have access to a row and the action itself is not a remove action, then
|
||||
@ -1001,17 +1013,20 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
*/
|
||||
private async _filterRowsAndCells(cursor: ActionCursor, rowsBefore: TableDataAction, rowsAfter: TableDataAction,
|
||||
accessCheck: IAccessCheck,
|
||||
allowRowRemoval: boolean): Promise<{
|
||||
options: {
|
||||
allowRowRemoval?: boolean,
|
||||
copyOnModify?: boolean,
|
||||
}): Promise<{
|
||||
filteredAction: DocAction | null,
|
||||
censoredRows: Set<number>
|
||||
}> {
|
||||
const censoredRows = new Set<number>();
|
||||
const ruler = await this._getRuler(cursor);
|
||||
const {docSession, action} = cursor;
|
||||
let filteredAction: DocAction | null = action;
|
||||
if (action && isSchemaAction(action)) {
|
||||
return {filteredAction, censoredRows};
|
||||
return {filteredAction: action, censoredRows};
|
||||
}
|
||||
let filteredAction: DocAction | null = action;
|
||||
|
||||
// For user convenience, for creations and deletions we equate rec and newRec.
|
||||
// This makes writing rules that control multiple permissions easier to write in
|
||||
@ -1029,16 +1044,25 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec};
|
||||
|
||||
const [, tableId, , colValues] = action;
|
||||
let filteredColValues: ColValues | BulkColValues | undefined | null = null;
|
||||
const rowIds = getRowIdsFromDocAction(action);
|
||||
const toRemove: number[] = [];
|
||||
|
||||
// Call this to make sure we are modifying a copy, not the original, if copyOnModify is set.
|
||||
const copyOnNeed = () => {
|
||||
if (filteredColValues === null) {
|
||||
filteredAction = options?.copyOnModify ? cloneDeep(action) : action;
|
||||
filteredColValues = filteredAction[3];
|
||||
}
|
||||
return filteredColValues;
|
||||
};
|
||||
let censorAt: (colId: string, idx: number) => void;
|
||||
if (colValues === undefined) {
|
||||
censorAt = () => 1;
|
||||
} else if (Array.isArray(action[2])) {
|
||||
censorAt = (colId, idx) => (colValues as BulkColValues)[colId][idx] = ['C']; // censored
|
||||
censorAt = (colId, idx) => (copyOnNeed() as BulkColValues)[colId][idx] = ['C']; // censored
|
||||
} else {
|
||||
censorAt = (colId) => (colValues as ColValues)[colId] = ['C']; // censored
|
||||
censorAt = (colId) => (copyOnNeed() as ColValues)[colId] = ['C']; // censored
|
||||
}
|
||||
|
||||
// These map an index of a row in the action to its index in rowsBefore and in rowsAfter.
|
||||
@ -1076,15 +1100,16 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
if (allowRowRemoval) {
|
||||
if (Array.isArray(action[2])) {
|
||||
this._removeRowsAt(toRemove, action[2], action[3]);
|
||||
if (options.allowRowRemoval) {
|
||||
copyOnNeed();
|
||||
if (Array.isArray(filteredAction[2])) {
|
||||
this._removeRowsAt(toRemove, filteredAction[2], filteredAction[3]);
|
||||
} else {
|
||||
filteredAction = null;
|
||||
}
|
||||
} else {
|
||||
// Artificially introduced removals are ok, otherwise this is suspect.
|
||||
if (action[0] !== 'RemoveRecord' && action[0] !== 'BulkRemoveRecord') {
|
||||
if (filteredAction[0] !== 'RemoveRecord' && filteredAction[0] !== 'BulkRemoveRecord') {
|
||||
throw new Error('Unexpected row removal');
|
||||
}
|
||||
}
|
||||
@ -1499,7 +1524,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
const {rowsBefore, rowsAfter} = await this._getRowsForRecAndNewRec(cursor);
|
||||
const {censoredRows, filteredAction} = await this._filterRowsAndCells({...cursor, action: cloneDeep(action)},
|
||||
rowsBefore, rowsAfter, accessCheck,
|
||||
true);
|
||||
{allowRowRemoval: true});
|
||||
if (filteredAction === null) {
|
||||
return [];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user