mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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:
parent
aae4a58300
commit
4ab096d179
@ -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),
|
||||||
|
@ -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([
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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++) {
|
||||||
|
@ -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) =>
|
||||||
|
@ -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)) {
|
||||||
|
@ -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;
|
||||||
|
@ -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'),
|
||||||
) :
|
) :
|
||||||
|
@ -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', `
|
||||||
|
@ -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>>;
|
||||||
|
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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
@ -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'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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());
|
||||||
|
@ -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};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user