(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
This commit is contained in:
Paul Fitzpatrick
2020-12-14 12:42:09 -05:00
parent 02ed4c59a0
commit 3b3ae87ade
14 changed files with 149 additions and 15 deletions

View File

@@ -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.

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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`);

View File

@@ -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;