mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user