(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();
if (doc) {
const permissionData = await pageModel.appModel.api.getDocAccess(doc.id);
if (this.isDisposed()) { return; }
this._usersInDoc = permissionData.users.map(user => ({
...user,
access: getRealAccess(user, permissionData),

View File

@@ -152,6 +152,7 @@ export class AccessRules extends Disposable {
* Replace internal state from the rules in DocData.
*/
public async update() {
if (this.isDisposed()) { return; }
this._errorMessage.set('');
const rules = this._ruleCollection;
[ , , 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 r = this.pendingRequests.get(reqId);
if (r) {
this.pendingRequests.delete(reqId);
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 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
// doesn't have access to, which is already handled.
console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`);
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;
try {
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 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
// doesn't have access to, which is already handled.
console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`);
window.location.reload();
}
if (message.details) {
err.details = message.details;
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) {
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;
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);
} finally {
this.pendingRequests.delete(reqId);
}
} else {
console.log("Comm: Response to unknown reqId " + reqId);
@@ -514,8 +517,8 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
}
if (error) {
console.log("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error);
this.pendingRequests.delete(reqId);
r.reject(new Error('Comm: ' + error));
this.pendingRequests.delete(reqId);
}
}

View File

@@ -17,6 +17,7 @@ export interface DocUserAction extends CommMessage {
data: {
docActions: DocAction[];
actionGroup: ActionGroup;
error?: string;
};
}
@@ -76,7 +77,15 @@ export class DocComm extends Disposable implements ActiveDocAPI {
this.listenTo(_comm, 'docShutdown', (m: CommMessage) => {
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

View File

@@ -293,6 +293,18 @@ export class GristDoc extends DisposableWithEvents {
public onDocUserAction(message: DocUserAction) {
console.log("GristDoc.onDocUserAction", message);
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)) {
const docActions = message.data.docActions;
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);
return [
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'}),
menuIcon("Page"), "Add Page", testId('dp-add-new-page'),
dom.cls('disabled', isReadonly)
),
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val),
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
{isNewPage: false, selectBy}),
menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'),
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)),
menuDivider(),
...importSources.map((importSource, i) =>

View File

@@ -48,6 +48,10 @@ export function getAppErrors(): string[] {
*/
export function reportError(err: Error|string): void {
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);
if (_notifier && !_notifier.isDisposed()) {
if (!isError(err)) {

View File

@@ -103,6 +103,11 @@ export const cssPageEntry = styled('div', `
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 > & {
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', `
white-space: nowrap;
overflow: hidden;

View File

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

View File

@@ -28,11 +28,6 @@ type NameType = Observable<string>|ko.Observable<string>;
// the item in the menu.
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 pageMenu = () => [
menuItem(() => isRenaming.set(true), "Rename", testId('rename'),
@@ -57,43 +52,44 @@ export function buildPageDom(name: NameType, actions: PageActions, ...args: DomE
return pageElem = dom(
'div',
dom.autoDispose(lis),
domComputed(isRenaming, (isrenaming) => (
isrenaming ?
cssPageItem(
cssPageInitial(dom.text((use) => use(name)[0])),
cssEditorInput(
{
initialValue: typeof name === 'function' ? name() : name.get() || '',
save: (val) => actions.onRename(val),
close: () => isRenaming.set(false)
},
testId('editor'),
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
// firefox.
) :
cssPageItem(
cssPageInitial(dom.text((use) => use(name)[0])),
cssPageName(dom.text(name), testId('label')),
cssPageMenuTrigger(
cssPageIcon('Dots'),
menu(pageMenu, {placement: 'bottom-start', parentSelectorToMark: '.' + itemHeader.className}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') :
domComputed(isRenaming, (isrenaming) => (
isrenaming ?
cssPageItem(
cssPageInitial(dom.text((use) => use(name)[0])),
cssEditorInput(
{
initialValue: typeof name === 'function' ? name() : name.get() || '',
save: (val) => actions.onRename(val),
close: () => isRenaming.set(false)
},
testId('editor'),
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
// firefox.
) :
cssPageItem(
cssPageInitial(dom.text((use) => use(name)[0])),
cssPageName(dom.text(name), testId('label')),
cssPageMenuTrigger(
cssPageIcon('Dots'),
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.
dom.on('mousedown', (ev) => ev.stopPropagation()),
testId('dots'),
),
// Prevents the default dragging behaviour that Firefox support for links which conflicts
// with our own dragging pages.
dom.on('dragstart', (ev) => ev.preventDefault()),
args
)
)),
);
// Let's prevent dragging to start when un-intentionally holding the mouse down on '...' menu.
dom.on('mousedown', (ev) => ev.stopPropagation()),
testId('dots'),
),
// Prevents the default dragging behaviour that Firefox support for links which conflicts
// with our own dragging pages.
dom.on('dragstart', (ev) => ev.preventDefault()),
args
)
)),
));
}
const cssPageItem = styled('a', `