(core) implement a safe mode for opening documents with rule problems

Summary:
Adds an "enter safe mode" option and explanation in modal that appears when a document fails to load, if user is owner. If "enter safe mode" is selected, document is reloaded on server in a special mode. Currently, the only difference is that if the acl rules fail to load, they are replaced with a fallback that grants full access to owners and no access to anyone else. An extra tag is shown to mark the document as safe mode, with an "x" for cancelling safe mode.

There are other ways a document could fail to load than just acl rules, so this is just a start.

Test Plan: added test

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2686
pull/3/head
Paul Fitzpatrick 3 years ago
parent 02ed4c59a0
commit 3b3ae87ade

@ -11,6 +11,7 @@ import {buildPagesDom} from 'app/client/ui/Pages';
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
import {tools} from 'app/client/ui/Tools';
import {testId} from 'app/client/ui2018/cssVars';
import {bigBasicButton} from 'app/client/ui2018/buttons';
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
@ -30,6 +31,7 @@ export interface DocInfo extends Document {
isSample: boolean;
isPreFork: boolean;
isFork: boolean;
isRecoveryMode: boolean;
isBareFork: boolean; // a document created without logging in, which is treated as a
// fork without an original.
idParts: UrlIdParts;
@ -53,6 +55,7 @@ export interface DocPageModel {
isReadonly: Observable<boolean>;
isPrefork: Observable<boolean>;
isFork: Observable<boolean>;
isRecoveryMode: Observable<boolean>;
isBareFork: Observable<boolean>;
isSample: Observable<boolean>;
@ -89,6 +92,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false);
public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false);
public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
public readonly isRecoveryMode = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isRecoveryMode : false);
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
public readonly isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false);
@ -198,12 +202,21 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
// Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,
// show a modal, and include a toast for the sake of the "Report error" link.
reportError(err);
const isOwner = this.currentDoc.get()?.access === 'owners';
confirmModal(
"Error opening document",
"Reload",
async () => window.location.reload(true),
err.message,
{hideCancel: true},
isOwner ? `You can try reloading the document, or using recovery mode. ` +
`Recovery mode opens the document to be fully accessible to owners, and ` +
`inaccessible to others. ` +
`[${err.message}]` : err.message,
{ hideCancel: true,
extraButtons: isOwner ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
window.location.reload(true);
}), testId('modal-recovery-mode')) : null,
},
);
}
@ -232,6 +245,10 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
flow.onDispose(() => comm.releaseDocConnection(doc.id));
const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
if (openDocResponse.recoveryMode) {
doc.isRecoveryMode = true;
this.currentDoc.set({...doc});
}
const gdModule = await gristDocModulePromise;
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
flow.checkIfCancelled();
@ -311,6 +328,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
return {
...doc,
isFork,
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
isSample,
isPreFork,
isBareFork,

@ -43,9 +43,11 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, {
docNameSave: renameDoc,
pageNameSave: getRenamePageFn(gristDoc),
cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc),
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
isFork: pageModel.isFork,
isRecoveryMode: pageModel.isRecoveryMode,
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)),
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
@ -91,6 +93,13 @@ function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {
};
}
function getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise<void> {
return async () => {
await gristDoc.app.topAppModel.api.getDocAPI(gristDoc.docPageModel.currentDocId.get()!)
.recover(false);
};
}
function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {
return cssHoverCircle(
cssTopBarUndoBtn(iconName),

@ -59,6 +59,14 @@ const cssTag = styled('span', `
margin-left: 4px;
`);
const cssAlertTag = styled(cssTag, `
background-color: ${colors.error};
--icon-color: white;
a {
cursor: pointer;
}
`);
interface PartialWorkspace {
id: number;
name: string;
@ -76,10 +84,12 @@ export function docBreadcrumbs(
options: {
docNameSave: (val: string) => Promise<void>,
pageNameSave: (val: string) => Promise<void>,
cancelRecoveryMode: () => Promise<void>,
isDocNameReadOnly?: BindableValue<boolean>,
isPageNameReadOnly?: BindableValue<boolean>,
isFork: Observable<boolean>,
isFiddle: Observable<boolean>,
isRecoveryMode: Observable<boolean>,
isSnapshot?: Observable<boolean>,
isPublic?: Observable<boolean>,
}
@ -106,6 +116,13 @@ export function docBreadcrumbs(
if (use(options.isFork)) {
return cssTag('unsaved', testId('unsaved-tag'));
}
if (use(options.isRecoveryMode)) {
return cssAlertTag('recovery mode',
dom('a', dom.on('click', async () => {
await options.cancelRecoveryMode()
}), icon('CrossSmall')),
testId('recovery-mode-tag'));
}
if (use(options.isFiddle)) {
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
}

@ -4,7 +4,7 @@ import {reportError} from 'app/client/models/errors';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {Computed, dom, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
import {Computed, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
export interface IModalControl {
close(): void;
@ -90,6 +90,7 @@ export interface ISaveModalOptions {
hideCancel?: boolean; // If set, hide the Cancel button
width?: ModalWidth; // Set a width style for the dialog.
modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element.
extraButtons?: DomContents; // More buttons!
}
/**
@ -160,6 +161,7 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
dom.on('click', save),
testId('modal-confirm'),
),
options.extraButtons,
options.hideCancel ? null : bigBasicButton('Cancel',
dom.on('click', () => ctl.close()),
testId('modal-cancel'),
@ -182,7 +184,7 @@ export function confirmModal(
btnText: string,
onConfirm: () => Promise<void>,
explanation?: Element|string,
{hideCancel}: {hideCancel?: boolean} = {},
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
): void {
return saveModal((ctl, owner): ISaveModalOptions => ({
title,
@ -191,6 +193,7 @@ export function confirmModal(
saveFunc: onConfirm,
hideCancel,
width: 'normal',
extraButtons,
}));
}

@ -30,6 +30,24 @@ const DEFAULT_RULE_SET: RuleSet = {
}],
};
// If the user-created rules become dysfunctional, we can swap in this emergency set.
// It grants full access to owners, and no access to anyone else.
const EMERGENCY_RULE_SET: RuleSet = {
tableId: '*',
colIds: '*',
body: [{
aclFormula: "user.Access in ['owners']",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "",
matchFunc: defaultMatchFunc,
permissions: parsePermissions('none'),
permissionsText: 'none',
}],
};
export class ACLRuleCollection {
// In the absence of rules, some checks are skipped. For now this is important to maintain all
// existing behavior. TODO should make sure checking access against default rules is equivalent
@ -54,6 +72,10 @@ export class ACLRuleCollection {
// Maps name to the corresponding UserAttributeRule.
private _userAttributeRules = new Map<string, UserAttributeRule>();
// Store error if one occurs while reading rules. Rules are replaced with emergency rules
// in this case.
public ruleError: Error|undefined;
// Whether there are ANY user-defined rules.
public haveRules(): boolean {
return this._haveRules;
@ -93,7 +115,7 @@ export class ACLRuleCollection {
* Update granular access from DocData.
*/
public async update(docData: DocData, options: ReadAclOptions) {
const {ruleSets, userAttributes} = readAclRules(docData, options);
const {ruleSets, userAttributes} = this._safeReadAclRules(docData, options);
// Build a map of user characteristics rules.
const userAttributeMap = new Map<string, UserAttributeRule>();
@ -143,6 +165,16 @@ export class ACLRuleCollection {
this._tableIds = [...tableIds];
this._userAttributeRules = userAttributeMap;
}
private _safeReadAclRules(docData: DocData, options: ReadAclOptions): ReadAclResults {
try {
this.ruleError = undefined;
return readAclRules(docData, options);
} catch(e) {
this.ruleError = e; // Report the error indirectly.
return {ruleSets: [EMERGENCY_RULE_SET], userAttributes: []};
}
}
}
export interface ReadAclOptions {

@ -42,6 +42,7 @@ export interface OpenLocalDocResult {
doc: {[tableId: string]: TableDataAction};
log: ActionGroup[];
plugins: LocalPlugin[];
recoveryMode?: boolean;
}
export interface DocListAPI {

@ -308,6 +308,7 @@ export interface DocAPI {
replace(source: DocReplacementOptions): Promise<void>;
getSnapshots(): Promise<DocSnapshots>;
forceReload(): Promise<void>;
recover(recoveryMode: boolean): Promise<void>;
// Compare two documents, optionally including details of the changes.
compareDoc(remoteDocId: string, options?: { detail: boolean }): Promise<DocStateComparison>;
// Compare two versions within a document, including details of the changes.
@ -715,6 +716,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
});
}
public async recover(recoveryMode: boolean): Promise<void> {
await this.request(`${this._url}/recover`, {
body: JSON.stringify({recoveryMode}),
method: 'POST'
});
}
public async compareDoc(remoteDocId: string, options: {
detail?: boolean
} = {}): Promise<DocStateComparison> {

@ -38,6 +38,7 @@ export class DocApiForwarder {
const withDocWithoutAuth = expressWrap(this._forwardToDocWorker.bind(this, true, null));
app.use('/api/docs/:docId/tables', withDoc);
app.use('/api/docs/:docId/force-reload', withDoc);
app.use('/api/docs/:docId/recover', withDoc);
app.use('/api/docs/:docId/remove', withDoc);
app.delete('/api/docs/:docId', withDoc);
app.use('/api/docs/:docId/download', withDoc);

@ -123,9 +123,11 @@ export class ActiveDoc extends EventEmitter {
// Timer for shutting down the ActiveDoc a bit after all clients are gone.
private _inactivityTimer = new InactivityTimer(() => this.shutdown(), Deps.ACTIVEDOC_TIMEOUT * 1000);
private _recoveryMode: boolean = false;
constructor(docManager: DocManager, docName: string) {
constructor(docManager: DocManager, docName: string, wantRecoveryMode?: boolean) {
super();
if (wantRecoveryMode) { this._recoveryMode = true; }
this._docManager = docManager;
this._docName = docName;
this.docStorage = new DocStorage(docManager.storageManager, docName);
@ -154,6 +156,8 @@ export class ActiveDoc extends EventEmitter {
public get docName(): string { return this._docName; }
public get recoveryMode(): boolean { return this._recoveryMode; }
// Helpers to log a message along with metadata about the request.
public logDebug(s: OptDocSession, msg: string, ...args: any[]) { this._log('debug', s, msg, ...args); }
public logInfo(s: OptDocSession, msg: string, ...args: any[]) { this._log('info', s, msg, ...args); }
@ -411,7 +415,7 @@ export class ActiveDoc extends EventEmitter {
await this._actionHistory.initialize();
this._granularAccess = new GranularAccess(this.docData, (query) => {
return this._fetchQueryFromDB(query, false);
});
}, this.recoveryMode);
await this._granularAccess.update();
this._sharing = new Sharing(this, this._actionHistory, this._modificationLock);
@ -871,6 +875,10 @@ export class ActiveDoc extends EventEmitter {
return this.shutdown();
}
public isOwner(docSession: OptDocSession): boolean {
return this._granularAccess.isOwner(docSession);
}
/**
* Fork the current document. In fact, all that requires is calculating a good
* ID for the fork. TODO: reconcile the two ways there are now of preparing a fork.

@ -222,11 +222,22 @@ export class DocWorkerApi {
// Reload a document forcibly (in fact this closes the doc, it will be automatically
// reopened on use).
this._app.post('/api/docs/:docId/force-reload', canEdit, withDoc(async (activeDoc, req, res) => {
this._app.post('/api/docs/:docId/force-reload', canEdit, throttled(async (req, res) => {
const activeDoc = await this._getActiveDoc(req);
await activeDoc.reloadDoc();
res.json(null);
}));
this._app.post('/api/docs/:docId/recover', canEdit, throttled(async (req, res) => {
const recoveryModeRaw = req.body.recoveryMode;
const recoveryMode = (typeof recoveryModeRaw === 'boolean') ? recoveryModeRaw : undefined;
if (!this._isOwner(req)) { throw new Error('Only owners can control recovery mode'); }
const activeDoc = await this._docManager.fetchDoc(docSessionFromRequest(req), getDocId(req), recoveryMode);
res.json({
recoveryMode: activeDoc.recoveryMode
});
}));
// DELETE /api/docs/:docId
// Delete the specified doc.
this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => {

@ -296,7 +296,8 @@ export class DocManager extends EventEmitter {
clientId: docSession.client.clientId,
doc: metaTables,
log: recentActions,
plugins: activeDoc.docPluginManager.getPlugins()
plugins: activeDoc.docPluginManager.getPlugins(),
recoveryMode: activeDoc.recoveryMode,
};
}
@ -364,12 +365,21 @@ export class DocManager extends EventEmitter {
/**
* Fetches an ActiveDoc object. Used by openDoc.
*/
public async fetchDoc(docSession: OptDocSession, docName: string): Promise<ActiveDoc> {
public async fetchDoc(docSession: OptDocSession, docName: string,
wantRecoveryMode?: boolean): Promise<ActiveDoc> {
log.debug('DocManager.fetchDoc', docName);
// Repeat until we acquire an ActiveDoc that is not muted (shutting down).
for (;;) {
if (this._activeDocs.has(docName) && wantRecoveryMode !== undefined) {
const activeDoc = await this._activeDocs.get(docName);
if (activeDoc && activeDoc.recoveryMode !== wantRecoveryMode && activeDoc.isOwner(docSession)) {
// shutting doc down to have a chance to re-open in the correct mode.
// TODO: there could be a battle with other users opening it in a different mode.
await activeDoc.shutdown();
}
}
if (!this._activeDocs.has(docName)) {
const newDoc = this.gristServer.create.ActiveDoc(this, docName);
const newDoc = this.gristServer.create.ActiveDoc(this, docName, wantRecoveryMode);
// Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan)
newDoc.on('backupMade', (bakPath: string) => {
this.emit('backupMade', bakPath);

@ -109,7 +109,7 @@ export class GranularAccess {
// Flag tracking whether a set of actions have been applied to the database or not.
private _applied: boolean = false;
public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise<TableDataAction>) {
public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise<TableDataAction>, private _recoveryMode: boolean) {
}
/**
@ -292,9 +292,19 @@ export class GranularAccess {
}
/**
* Check whether user has owner-level access to the document.
* Check whether user has full access to the document. Currently that is interpreted
* as equivalent owner-level access to the document.
* TODO: uses of this method should be checked to see if they can be fleshed out
* now we have more of the ACL implementation done.
*/
public hasFullAccess(docSession: OptDocSession): boolean {
return this.isOwner(docSession);
}
/**
* Check whether user has owner-level access to the document.
*/
public isOwner(docSession: OptDocSession): boolean {
const access = getDocSessionAccess(docSession);
return access === 'owners';
}
@ -765,6 +775,12 @@ export class GranularAccess {
// TODO: could also get this for websocket access, just via a different route.
user.Origin = docSession.req?.get('origin') || null;
if (this._ruleCollection.ruleError && !this._recoveryMode) {
// It is important to signal that the doc is in an unexpected state,
// and prevent it opening.
throw this._ruleCollection.ruleError;
}
for (const clause of this._ruleCollection.getUserAttributeRules().values()) {
if (clause.name in user) {
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);

@ -29,7 +29,7 @@ export interface ICreate {
// should not interfere with each other.
ExternalStorage(purpose: 'doc' | 'meta', testExtraPrefix: string): ExternalStorage|undefined;
ActiveDoc(docManager: DocManager, docName: string): ActiveDoc;
ActiveDoc(docManager: DocManager, docName: string, safeMode?: boolean): ActiveDoc;
DocManager(storageManager: IDocStorageManager, pluginManager: PluginManager,
homeDbManager: HomeDBManager|null, gristServer: GristServer): DocManager;
NSandbox(options: ISandboxCreationOptions): ISandbox;

@ -31,7 +31,7 @@ export const create: ICreate = {
};
},
ExternalStorage() { return undefined; },
ActiveDoc(docManager, docName) { return new ActiveDoc(docManager, docName); },
ActiveDoc(docManager, docName, wantSafeMode) { return new ActiveDoc(docManager, docName, wantSafeMode); },
DocManager(storageManager, pluginManager, homeDBManager, gristServer) {
return new DocManager(storageManager, pluginManager, homeDBManager, gristServer);
},

Loading…
Cancel
Save