(core) granular access control in the presence of schema changes

Summary:
 - Support schema changes in the presence of non-trivial ACL rules.
 - Fix update of `aclFormulaParsed` when updating formulas automatically after schema change.
 - Filter private metadata in broadcasts, not just fetches.  Censorship method is unchanged, just refactored.
 - Allow only owners to change ACL rules.
 - Force reloads if rules are changed.
 - Track rule changes within bundle, for clarity during schema changes - tableId and colId changes create a muddle otherwise.
 - Show or forbid pages dynamically depending on user's access to its sections. Logic unchanged, just no longer requires reload.
 - Fix calculation of pre-existing rows touched by a bundle, in the presence of schema changes.
 - Gray out acl page for non-owners.

Test Plan: added tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2734
This commit is contained in:
Paul Fitzpatrick
2021-03-01 11:51:30 -05:00
parent aae4a58300
commit 4ab096d179
18 changed files with 930 additions and 454 deletions

View File

@@ -57,7 +57,7 @@ import {DocSession, getDocSessionAccess, getDocSessionUser, getDocSessionUserId,
makeExceptionalDocSession, OptDocSession} from './DocSession';
import {DocStorage} from './DocStorage';
import {expandQuery} from './ExpandedQuery';
import {GranularAccess} from './GranularAccess';
import {GranularAccess, GranularAccessForBundle} from './GranularAccess';
import {OnDemandActions} from './OnDemandActions';
import {findOrAddAllEnvelope, Sharing} from './Sharing';
@@ -419,7 +419,7 @@ export class ActiveDoc extends EventEmitter {
this._onDemandActions = new OnDemandActions(this.docStorage, this.docData);
await this._actionHistory.initialize();
this._granularAccess = new GranularAccess(this.docData, (query) => {
this._granularAccess = new GranularAccess(this.docData, this.docClients, (query) => {
return this._fetchQueryFromDB(query, false);
}, this.recoveryMode, this._docManager.getHomeDbManager(), this.docName);
await this._granularAccess.update();
@@ -1064,43 +1064,14 @@ export class ActiveDoc extends EventEmitter {
/**
* Called by Sharing manager when working on modifying the document.
* Called when DocActions have been produced from UserActions, but
* before those DocActions have been applied to the DB, to confirm
* that those DocActions are legal according to any granular access
* rules.
* before those DocActions have been applied to the DB. GranularAccessBundle
* methods can confirm that those DocActions are legal according to any
* granular access rules.
*/
public async canApplyDocActions(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[]) {
return this._granularAccess.canApplyDocActions(docSession, docActions, undo);
}
/**
* Called by Sharing manager when working on modifying the document.
* Called when DocActions have been produced from UserActions, and
* have been applied to the DB, but before the changes have been
* broadcast to clients.
*/
public async appliedActions(docActions: DocAction[], undo: DocAction[]) {
await this._granularAccess.appliedActions(docActions, undo);
}
/**
* Called by Sharing manager when done working on modifying the document,
* regardless of whether the modification succeeded or failed.
*/
public async finishedActions() {
await this._granularAccess.finishedActions();
}
/**
* Broadcast document changes to all the document's clients. Doesn't involve
* ActiveDoc directly, but placed here to facilitate future work on granular
* access control.
*/
public async broadcastDocUpdate(client: Client|null, type: string, message: {
actionGroup: ActionGroup,
docActions: DocAction[]
}) {
await this.docClients.broadcastDocMessage(client, 'docUserAction', message,
(docSession) => this._filterDocUpdate(docSession, message));
public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],
userActions: UserAction[]): GranularAccessForBundle {
this._granularAccess.getGranularAccessForBundle(docSession, docActions, undo, userActions);
return this._granularAccess;
}
/**
@@ -1385,23 +1356,6 @@ export class ActiveDoc extends EventEmitter {
log.origLog(level, `ActiveDoc ` + msg, ...args, this.getLogMeta(docSession));
}
/**
* This filters a message being broadcast to all clients to be appropriate for one
* particular client, if that client may need some material filtered out.
*/
private async _filterDocUpdate(docSession: OptDocSession, message: {
actionGroup: ActionGroup,
docActions: DocAction[]
}) {
if (await this._granularAccess.canReadEverything(docSession)) { return message; }
const result = {
actionGroup: await this._granularAccess.filterActionGroup(docSession, message.actionGroup),
docActions: await this._granularAccess.filterOutgoingDocActions(docSession, message.docActions),
};
if (result.docActions.length === 0) { return null; }
return result;
}
/**
* Called before a migration. Makes sure a back-up is made.
*/

View File

@@ -97,7 +97,7 @@ export class DocClients {
if (e.code && e.code === 'NEED_RELOAD') {
sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf);
} else {
throw e;
sendDocMessage(curr.client, curr.fd, 'docUserAction', {error: String(e)}, fromSelf);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ import { mapValues } from 'lodash';
*/
export interface PermissionSetWithContextOf<T = PermissionSet> {
perms: T;
ruleType: 'full'|'table'|'column';
ruleType: 'full'|'table'|'column'|'row';
getMemos: () => MemoSet;
}
@@ -111,11 +111,18 @@ export class MemoInfo extends RuleInfo<MemoSet, MemoSet> {
}
}
export interface IPermissionInfo {
getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext;
getTableAccess(tableId: string): TablePermissionSetWithContext;
getFullAccess(): MixedPermissionSetWithContext;
getRuleCollection(): ACLRuleCollection;
}
/**
* Helper for evaluating rules given a particular user and optionally a record. It evaluates rules
* for a column, table, or document, with caching to avoid evaluating the same rule multiple times.
*/
export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermissionSet> {
export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermissionSet> implements IPermissionInfo {
private _ruleResults = new Map<RuleSet, MixedPermissionSet>();
// Get permissions for "tableId:colId", defaulting to "tableId:*" and "*:*" as needed.
@@ -138,7 +145,7 @@ export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermission
public getTableAccess(tableId: string): TablePermissionSetWithContext {
return {
perms: this.getTableAspect(tableId),
ruleType: 'table',
ruleType: this._input?.rec ? 'row' : 'table',
getMemos: () => new MemoInfo(this._acls, this._input).getTableAspect(tableId)
};
}
@@ -154,6 +161,10 @@ export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermission
};
}
public getRuleCollection() {
return this._acls;
}
protected _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedPermissionSet): MixedPermissionSet {
return getSetMapValue(this._ruleResults, ruleSet, () => {
const pset = evaluateRule(ruleSet, this._input);
@@ -166,7 +177,7 @@ export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermission
bits.every(b => b === 'allow') ? 'allow' :
bits.every(b => b === 'deny') ? 'deny' :
bits.every(b => b === 'allow' || b === 'deny') ? 'mixedColumns' :
'mixed'
'mixed'
));
}

View File

@@ -18,16 +18,30 @@ class RowIdTracker {
*/
export function getRelatedRows(docActions: DocAction[]): ReadonlyArray<readonly [string, Set<number>]> {
// Relate tableIds for tables with what they were before the actions, if renamed.
const tableIds = new Map<string, string>();
const rowIds = new Map<string, RowIdTracker>();
const tableIds = new Map<string, string>(); // key is current tableId
const rowIds = new Map<string, RowIdTracker>(); // key is pre-existing tableId
const addedTables = new Set<string>(); // track newly added tables to ignore; key is current tableId
for (const docAction of docActions) {
const currentTableId = getTableId(docAction);
const tableId = tableIds.get(currentTableId) || currentTableId;
if (docAction[0] === 'RenameTable') {
if (addedTables.has(currentTableId)) {
addedTables.delete(currentTableId);
addedTables.add(docAction[2])
continue;
}
tableIds.delete(currentTableId);
tableIds.set(docAction[2], tableId);
continue;
}
if (docAction[0] === 'AddTable') {
addedTables.add(currentTableId);
}
if (docAction[0] === 'RemoveTable') {
addedTables.delete(currentTableId);
continue;
}
if (addedTables.has(currentTableId)) { continue; }
// tableId will now be that prior to docActions, regardless of renames.
const tracker = getSetMapValue(rowIds, tableId, () => new RowIdTracker());

View File

@@ -213,7 +213,7 @@ export class Sharing {
info.linkId = docSession.linkId;
}
const {sandboxActionBundle, undo, docActions} =
const {sandboxActionBundle, undo, accessControl} =
await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions));
// A trivial action does not merit allocating an actionNum,
@@ -285,13 +285,10 @@ export class Sharing {
internal: isCalculate,
});
try {
await this._activeDoc.appliedActions(docActions, undo);
await this._activeDoc.broadcastDocUpdate(client || null, 'docUserAction', {
actionGroup,
docActions,
});
await accessControl.appliedBundle();
await accessControl.sendDocUpdateForBundle(actionGroup);
} finally {
await this._activeDoc.finishedActions();
await accessControl.finishedBundle();
}
if (docSession) {
docSession.linkId = docSession.shouldBundleActions ? localActionBundle.actionNum : 0;
@@ -375,18 +372,18 @@ export class Sharing {
const docActions = getEnvContent(sandboxActionBundle.stored).concat(
getEnvContent(sandboxActionBundle.calc));
const accessControl = this._activeDoc.getGranularAccessForBundle(docSession || makeExceptionalDocSession('share'), docActions, undo, userActions);
try {
// TODO: see if any of the code paths that have no docSession are relevant outside
// of tests.
await this._activeDoc.canApplyDocActions(docSession || makeExceptionalDocSession('share'),
docActions, undo);
await accessControl.canApplyBundle();
} catch (e) {
// should not commit. Don't write to db. Remove changes from sandbox.
await this._activeDoc.applyActionsToDataEngine([['ApplyUndoActions', undo]]);
await this._activeDoc.finishedActions();
await accessControl.finishedBundle();
throw e;
}
return {sandboxActionBundle, undo, docActions};
return {sandboxActionBundle, undo, docActions, accessControl};
}
}