(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

@ -60,6 +60,7 @@ export class ACLUsersPopup extends Disposable {
const doc = pageModel.currentDoc.get(); const doc = pageModel.currentDoc.get();
if (doc) { if (doc) {
const permissionData = await pageModel.appModel.api.getDocAccess(doc.id); const permissionData = await pageModel.appModel.api.getDocAccess(doc.id);
if (this.isDisposed()) { return; }
this._usersInDoc = permissionData.users.map(user => ({ this._usersInDoc = permissionData.users.map(user => ({
...user, ...user,
access: getRealAccess(user, permissionData), access: getRealAccess(user, permissionData),

View File

@ -152,6 +152,7 @@ export class AccessRules extends Disposable {
* Replace internal state from the rules in DocData. * Replace internal state from the rules in DocData.
*/ */
public async update() { public async update() {
if (this.isDisposed()) { return; }
this._errorMessage.set(''); this._errorMessage.set('');
const rules = this._ruleCollection; const rules = this._ruleCollection;
[ , , this._aclResources] = await Promise.all([ [ , , this._aclResources] = await Promise.all([

View File

@ -447,32 +447,35 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
const reqId = message.reqId; const reqId = message.reqId;
const r = this.pendingRequests.get(reqId); const r = this.pendingRequests.get(reqId);
if (r) { if (r) {
this.pendingRequests.delete(reqId); try {
if ('errorCode' in message && message.errorCode === 'AUTH_NO_VIEW') { if ('errorCode' in message && message.errorCode === 'AUTH_NO_VIEW') {
// We should only arrive here if the user had view access, and then lost it. // We should only arrive here if the user had view access, and then lost it.
// We should not let the user see the document any more. Let's reload the // We should not let the user see the document any more. Let's reload the
// page, reducing this to the problem of arriving at a document the user // page, reducing this to the problem of arriving at a document the user
// doesn't have access to, which is already handled. // doesn't have access to, which is already handled.
console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`); console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`);
window.location.reload(); window.location.reload();
}
if (isCommResponseError(message)) {
const err: any = new Error(message.error);
let code = '';
if (message.errorCode) {
code = ` [${message.errorCode}]`;
err.code = message.errorCode;
} }
if (message.details) { if (isCommResponseError(message)) {
err.details = message.details; const err: any = new Error(message.error);
let code = '';
if (message.errorCode) {
code = ` [${message.errorCode}]`;
err.code = message.errorCode;
}
if (message.details) {
err.details = message.details;
}
err.shouldFork = message.shouldFork;
console.log(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}`
+ (message.shouldFork ? ` (should fork)` : ''));
r.reject(err);
} else {
console.log(`Comm response #${reqId} ${r.methodName} OK`);
r.resolve(message.data);
} }
err.shouldFork = message.shouldFork; } finally {
console.log(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}` this.pendingRequests.delete(reqId);
+ (message.shouldFork ? ` (should fork)` : ''));
r.reject(err);
} else {
console.log(`Comm response #${reqId} ${r.methodName} OK`);
r.resolve(message.data);
} }
} else { } else {
console.log("Comm: Response to unknown reqId " + reqId); console.log("Comm: Response to unknown reqId " + reqId);
@ -514,8 +517,8 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
} }
if (error) { if (error) {
console.log("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error); console.log("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error);
this.pendingRequests.delete(reqId);
r.reject(new Error('Comm: ' + error)); r.reject(new Error('Comm: ' + error));
this.pendingRequests.delete(reqId);
} }
} }

View File

@ -17,6 +17,7 @@ export interface DocUserAction extends CommMessage {
data: { data: {
docActions: DocAction[]; docActions: DocAction[];
actionGroup: ActionGroup; actionGroup: ActionGroup;
error?: string;
}; };
} }
@ -76,7 +77,15 @@ export class DocComm extends Disposable implements ActiveDocAPI {
this.listenTo(_comm, 'docShutdown', (m: CommMessage) => { this.listenTo(_comm, 'docShutdown', (m: CommMessage) => {
if (this.isActionFromThisDoc(m)) { this._isClosed = true; } if (this.isActionFromThisDoc(m)) { this._isClosed = true; }
}); });
this.onDispose(() => this._shutdown()); this.onDispose(async () => {
try {
await this._shutdown();
} catch (e) {
if (!String(e).match(/GristWSConnection disposed/)) {
reportError(e);
}
}
});
} }
// Returns the URL params that identifying this open document to the DocWorker // Returns the URL params that identifying this open document to the DocWorker

View File

@ -293,6 +293,18 @@ export class GristDoc extends DisposableWithEvents {
public onDocUserAction(message: DocUserAction) { public onDocUserAction(message: DocUserAction) {
console.log("GristDoc.onDocUserAction", message); console.log("GristDoc.onDocUserAction", message);
let schemaUpdated = false; let schemaUpdated = false;
/**
* If an operation is applied successfully to a document, and then information about
* it is broadcast to clients, and one of those broadcasts has a failure (due to
* granular access control, which is client-specific), then that error is logged on
* the server and also sent to the client via an `error` field. Under normal operation,
* there should be no such errors, but if they do arise it is best to make them as visible
* as possible.
*/
if (message.data.error) {
reportError(new Error(message.data.error));
return;
}
if (this.docComm.isActionFromThisDoc(message)) { if (this.docComm.isActionFromThisDoc(message)) {
const docActions = message.data.docActions; const docActions = message.data.docActions;
for (let i = 0, len = docActions.length; i < len; i++) { for (let i = 0, len = docActions.length; i < len; i++) {

View File

@ -294,18 +294,18 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
const selectBy = gristDoc.selectBy.bind(gristDoc); const selectBy = gristDoc.selectBy.bind(gristDoc);
return [ return [
menuItem( menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val), (elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val).catch(reportError),
{isNewPage: true, buttonLabel: 'Add Page'}), {isNewPage: true, buttonLabel: 'Add Page'}),
menuIcon("Page"), "Add Page", testId('dp-add-new-page'), menuIcon("Page"), "Add Page", testId('dp-add-new-page'),
dom.cls('disabled', isReadonly) dom.cls('disabled', isReadonly)
), ),
menuItem( menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val), (elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
{isNewPage: false, selectBy}), {isNewPage: false, selectBy}),
menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'), menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'),
dom.cls('disabled', isReadonly) dom.cls('disabled', isReadonly)
), ),
menuItem(() => gristDoc.addEmptyTable(), menuIcon("TypeTable"), "Add Empty Table", testId('dp-empty-table'), menuItem(() => gristDoc.addEmptyTable().catch(reportError), menuIcon("TypeTable"), "Add Empty Table", testId('dp-empty-table'),
dom.cls('disabled', isReadonly)), dom.cls('disabled', isReadonly)),
menuDivider(), menuDivider(),
...importSources.map((importSource, i) => ...importSources.map((importSource, i) =>

View File

@ -48,6 +48,10 @@ export function getAppErrors(): string[] {
*/ */
export function reportError(err: Error|string): void { export function reportError(err: Error|string): void {
log.error(`ERROR:`, err); log.error(`ERROR:`, err);
if (String(err).match(/GristWSConnection disposed/)) {
// This error can be emitted while a page is reloaded, and isn't worth reporting.
return;
}
_logError(err); _logError(err);
if (_notifier && !_notifier.isDisposed()) { if (_notifier && !_notifier.isDisposed()) {
if (!isError(err)) { if (!isError(err)) {

View File

@ -103,6 +103,11 @@ export const cssPageEntry = styled('div', `
color: ${colors.light}; color: ${colors.light};
--icon-color: ${colors.light}; --icon-color: ${colors.light};
} }
&-disabled, &-disabled:hover, &-disabled.weasel-popup-open {
background-color: initial;
color: ${colors.mediumGrey};
--icon-color: ${colors.mediumGrey};
}
.${cssTools.className}-collapsed > & { .${cssTools.className}-collapsed > & {
margin-right: 0; margin-right: 0;
} }
@ -126,6 +131,19 @@ export const cssPageLink = styled('a', `
} }
`); `);
// Styled like a cssPageLink, but in a disabled mode, without an actual link.
export const cssPageDisabledLink = styled('span', `
display: flex;
align-items: center;
height: 32px;
line-height: 32px;
padding-left: 24px;
outline: none;
text-decoration: none;
outline: none;
color: inherit;
`);
export const cssLinkText = styled('span', ` export const cssLinkText = styled('span', `
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@ -3,7 +3,7 @@ import { urlState } from "app/client/models/gristUrlState";
import { showExampleCard } from 'app/client/ui/ExampleCard'; import { showExampleCard } from 'app/client/ui/ExampleCard';
import { examples } from 'app/client/ui/ExampleInfo'; import { examples } from 'app/client/ui/ExampleInfo';
import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/client/ui/LeftPanelCommon'; import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/client/ui/LeftPanelCommon';
import { cssLinkText, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon'; import { cssLinkText, cssPageDisabledLink, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon';
import { colors } from 'app/client/ui2018/cssVars'; import { colors } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons'; import { icon } from 'app/client/ui2018/icons';
import { Disposable, dom, makeTestId, Observable, styled } from "grainjs"; import { Disposable, dom, makeTestId, Observable, styled } from "grainjs";
@ -12,7 +12,7 @@ const testId = makeTestId('test-tools-');
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element { export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI); const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI);
const isOwner = gristDoc.docPageModel.currentDoc.get()?.access === 'owners';
return cssTools( return cssTools(
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)), cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
cssSectionHeader("TOOLS"), cssSectionHeader("TOOLS"),
@ -20,9 +20,10 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
(aclUIEnabled ? (aclUIEnabled ?
cssPageEntry( cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'), cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
cssPageLink(cssPageIcon('EyeShow'), cssPageEntry.cls('-disabled', !isOwner),
(isOwner ? cssPageLink : cssPageDisabledLink)(cssPageIcon('EyeShow'),
cssLinkText('Access Rules'), cssLinkText('Access Rules'),
urlState().setLinkUrl({docPage: 'acl'}) isOwner ? urlState().setLinkUrl({docPage: 'acl'}) : null
), ),
testId('access-rules'), testId('access-rules'),
) : ) :

View File

@ -28,11 +28,6 @@ type NameType = Observable<string>|ko.Observable<string>;
// the item in the menu. // the item in the menu.
export function buildPageDom(name: NameType, actions: PageActions, ...args: DomElementArg[]) { export function buildPageDom(name: NameType, actions: PageActions, ...args: DomElementArg[]) {
// If name is blank, this page is censored, so don't include any options for manipulation.
// We can get fancier about this later.
const initName = ('peek' in name) ? name.peek() : name.get();
if (initName === '') { return dom('div', '-'); }
const isRenaming = observable(false); const isRenaming = observable(false);
const pageMenu = () => [ const pageMenu = () => [
menuItem(() => isRenaming.set(true), "Rename", testId('rename'), menuItem(() => isRenaming.set(true), "Rename", testId('rename'),
@ -57,43 +52,44 @@ export function buildPageDom(name: NameType, actions: PageActions, ...args: DomE
return pageElem = dom( return pageElem = dom(
'div', 'div',
dom.autoDispose(lis), dom.autoDispose(lis),
domComputed(isRenaming, (isrenaming) => ( domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') :
isrenaming ? domComputed(isRenaming, (isrenaming) => (
cssPageItem( isrenaming ?
cssPageInitial(dom.text((use) => use(name)[0])), cssPageItem(
cssEditorInput( cssPageInitial(dom.text((use) => use(name)[0])),
{ cssEditorInput(
initialValue: typeof name === 'function' ? name() : name.get() || '', {
save: (val) => actions.onRename(val), initialValue: typeof name === 'function' ? name() : name.get() || '',
close: () => isRenaming.set(false) save: (val) => actions.onRename(val),
}, close: () => isRenaming.set(false)
testId('editor'), },
dom.on('mousedown', (ev) => ev.stopPropagation()), testId('editor'),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) dom.on('mousedown', (ev) => ev.stopPropagation()),
), dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); })
// Note that we don't pass extra args when renaming is on, because they usually includes ),
// mouse event handlers interferring with input editor and yields wrong behavior on // Note that we don't pass extra args when renaming is on, because they usually includes
// firefox. // mouse event handlers interferring with input editor and yields wrong behavior on
) : // firefox.
cssPageItem( ) :
cssPageInitial(dom.text((use) => use(name)[0])), cssPageItem(
cssPageName(dom.text(name), testId('label')), cssPageInitial(dom.text((use) => use(name)[0])),
cssPageMenuTrigger( cssPageName(dom.text(name), testId('label')),
cssPageIcon('Dots'), cssPageMenuTrigger(
menu(pageMenu, {placement: 'bottom-start', parentSelectorToMark: '.' + itemHeader.className}), cssPageIcon('Dots'),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), menu(pageMenu, {placement: 'bottom-start', parentSelectorToMark: '.' + itemHeader.className}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
// Let's prevent dragging to start when un-intentionally holding the mouse down on '...' menu. // Let's prevent dragging to start when un-intentionally holding the mouse down on '...' menu.
dom.on('mousedown', (ev) => ev.stopPropagation()), dom.on('mousedown', (ev) => ev.stopPropagation()),
testId('dots'), testId('dots'),
), ),
// Prevents the default dragging behaviour that Firefox support for links which conflicts // Prevents the default dragging behaviour that Firefox support for links which conflicts
// with our own dragging pages. // with our own dragging pages.
dom.on('dragstart', (ev) => ev.preventDefault()), dom.on('dragstart', (ev) => ev.preventDefault()),
args args
) )
)), )),
); ));
} }
const cssPageItem = styled('a', ` const cssPageItem = styled('a', `

View File

@ -22,12 +22,19 @@ export interface RulePart {
memo?: string; memo?: string;
} }
// Light wrapper around characteristics or records. // Light wrapper for reading records or user attributes.
export interface InfoView { export interface InfoView {
get(key: string): CellValue; get(key: string): CellValue;
toJSON(): {[key: string]: any}; toJSON(): {[key: string]: any};
} }
// As InfoView, but also supporting writing.
export interface InfoEditor {
get(key: string): CellValue;
set(key: string, val: CellValue): this;
toJSON(): {[key: string]: any};
}
// Represents user info, which may include properties which are themselves RowRecords. // Represents user info, which may include properties which are themselves RowRecords.
export type UserInfo = Record<string, CellValue|InfoView|Record<string, string>>; export type UserInfo = Record<string, CellValue|InfoView|Record<string, string>>;

View File

@ -57,7 +57,7 @@ import {DocSession, getDocSessionAccess, getDocSessionUser, getDocSessionUserId,
makeExceptionalDocSession, OptDocSession} from './DocSession'; makeExceptionalDocSession, OptDocSession} from './DocSession';
import {DocStorage} from './DocStorage'; import {DocStorage} from './DocStorage';
import {expandQuery} from './ExpandedQuery'; import {expandQuery} from './ExpandedQuery';
import {GranularAccess} from './GranularAccess'; import {GranularAccess, GranularAccessForBundle} from './GranularAccess';
import {OnDemandActions} from './OnDemandActions'; import {OnDemandActions} from './OnDemandActions';
import {findOrAddAllEnvelope, Sharing} from './Sharing'; import {findOrAddAllEnvelope, Sharing} from './Sharing';
@ -419,7 +419,7 @@ export class ActiveDoc extends EventEmitter {
this._onDemandActions = new OnDemandActions(this.docStorage, this.docData); this._onDemandActions = new OnDemandActions(this.docStorage, this.docData);
await this._actionHistory.initialize(); 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); return this._fetchQueryFromDB(query, false);
}, this.recoveryMode, this._docManager.getHomeDbManager(), this.docName); }, this.recoveryMode, this._docManager.getHomeDbManager(), this.docName);
await this._granularAccess.update(); await this._granularAccess.update();
@ -1064,43 +1064,14 @@ export class ActiveDoc extends EventEmitter {
/** /**
* Called by Sharing manager when working on modifying the document. * Called by Sharing manager when working on modifying the document.
* Called when DocActions have been produced from UserActions, but * Called when DocActions have been produced from UserActions, but
* before those DocActions have been applied to the DB, to confirm * before those DocActions have been applied to the DB. GranularAccessBundle
* that those DocActions are legal according to any granular access * methods can confirm that those DocActions are legal according to any
* rules. * granular access rules.
*/ */
public async canApplyDocActions(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[]) { public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],
return this._granularAccess.canApplyDocActions(docSession, docActions, undo); userActions: UserAction[]): GranularAccessForBundle {
} this._granularAccess.getGranularAccessForBundle(docSession, docActions, undo, userActions);
return this._granularAccess;
/**
* 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));
} }
/** /**
@ -1385,23 +1356,6 @@ export class ActiveDoc extends EventEmitter {
log.origLog(level, `ActiveDoc ` + msg, ...args, this.getLogMeta(docSession)); 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. * 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') { if (e.code && e.code === 'NEED_RELOAD') {
sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf); sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf);
} else { } 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> { export interface PermissionSetWithContextOf<T = PermissionSet> {
perms: T; perms: T;
ruleType: 'full'|'table'|'column'; ruleType: 'full'|'table'|'column'|'row';
getMemos: () => MemoSet; 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 * 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. * 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>(); private _ruleResults = new Map<RuleSet, MixedPermissionSet>();
// Get permissions for "tableId:colId", defaulting to "tableId:*" and "*:*" as needed. // 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 { public getTableAccess(tableId: string): TablePermissionSetWithContext {
return { return {
perms: this.getTableAspect(tableId), perms: this.getTableAspect(tableId),
ruleType: 'table', ruleType: this._input?.rec ? 'row' : 'table',
getMemos: () => new MemoInfo(this._acls, this._input).getTableAspect(tableId) 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 { protected _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedPermissionSet): MixedPermissionSet {
return getSetMapValue(this._ruleResults, ruleSet, () => { return getSetMapValue(this._ruleResults, ruleSet, () => {
const pset = evaluateRule(ruleSet, this._input); 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 === 'allow') ? 'allow' :
bits.every(b => b === 'deny') ? 'deny' : bits.every(b => b === 'deny') ? 'deny' :
bits.every(b => b === 'allow' || b === 'deny') ? 'mixedColumns' : 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>]> { export function getRelatedRows(docActions: DocAction[]): ReadonlyArray<readonly [string, Set<number>]> {
// Relate tableIds for tables with what they were before the actions, if renamed. // Relate tableIds for tables with what they were before the actions, if renamed.
const tableIds = new Map<string, string>(); const tableIds = new Map<string, string>(); // key is current tableId
const rowIds = new Map<string, RowIdTracker>(); 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) { for (const docAction of docActions) {
const currentTableId = getTableId(docAction); const currentTableId = getTableId(docAction);
const tableId = tableIds.get(currentTableId) || currentTableId; const tableId = tableIds.get(currentTableId) || currentTableId;
if (docAction[0] === 'RenameTable') { if (docAction[0] === 'RenameTable') {
if (addedTables.has(currentTableId)) {
addedTables.delete(currentTableId);
addedTables.add(docAction[2])
continue;
}
tableIds.delete(currentTableId); tableIds.delete(currentTableId);
tableIds.set(docAction[2], tableId); tableIds.set(docAction[2], tableId);
continue; 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. // tableId will now be that prior to docActions, regardless of renames.
const tracker = getSetMapValue(rowIds, tableId, () => new RowIdTracker()); const tracker = getSetMapValue(rowIds, tableId, () => new RowIdTracker());

View File

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

View File

@ -4,7 +4,7 @@
import json import json
from acl_formula import parse_acl_grist_entities from acl_formula import parse_acl_grist_entities, parse_acl_formula_json
import action_obj import action_obj
import logger import logger
import textbuilder import textbuilder
@ -128,7 +128,9 @@ def prepare_acl_col_renames(docmodel, useractions, col_renames_dict):
patches.append(patch) patches.append(patch)
replacer = textbuilder.Replacer(textbuilder.Text(formula), patches) replacer = textbuilder.Replacer(textbuilder.Text(formula), patches)
rule_updates.append((rule_rec, {'aclFormula': replacer.get_text().encode('utf8')})) txt = replacer.get_text().encode('utf8')
rule_updates.append((rule_rec, {'aclFormula': txt,
'aclFormulaParsed': parse_acl_formula_json(txt)}))
def do_renames(): def do_renames():
useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates) useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)