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:
@@ -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),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
) :
|
||||
|
||||
@@ -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', `
|
||||
|
||||
Reference in New Issue
Block a user