(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:
Paul Fitzpatrick
2021-10-07 19:46:22 -04:00
parent df318ad6b3
commit 07558dceba
2 changed files with 99 additions and 55 deletions

View File

@@ -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 [];
}