(core) make user role available in ActiveDoc methods

Summary: This makes the user's role (owner/editor/viewer) available in ActiveDoc methods. No use of that information is made yet, other than to log it.  The bulk of the diff is getting a handle on the various ways the methods can be called, and systematizing it a bit more.  In passing, access control is added to broadcasts of document changes, so users who no longer have access to a document do not receive changes if they still have the document open.

Test Plan: existing tests pass; test for broadcast access control added

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2599
This commit is contained in:
Paul Fitzpatrick 2020-09-02 14:17:17 -04:00
parent 7a8debae16
commit 526fda4eba
10 changed files with 217 additions and 89 deletions

View File

@ -7,7 +7,6 @@
import * as assert from 'assert';
import * as bluebird from 'bluebird';
import {EventEmitter} from 'events';
import {Request} from 'express';
import {IMessage, MsgType} from 'grain-rpc';
import * as imageSize from 'image-size';
import flatten = require('lodash/flatten');
@ -36,7 +35,7 @@ import {UploadResult} from 'app/common/uploads';
import {DocReplacementOptions} from 'app/common/UserAPI';
import {ParseOptions} from 'app/plugin/FileParserAPI';
import {GristDocAPI} from 'app/plugin/GristAPI';
import {Authorizer, getUserId} from 'app/server/lib/Authorizer';
import {Authorizer} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client';
import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
@ -52,7 +51,8 @@ import {ActionHistoryImpl} from './ActionHistoryImpl';
import {ActiveDocImport} from './ActiveDocImport';
import {DocClients} from './DocClients';
import {DocPluginManager} from './DocPluginManager';
import {DocSession, OptDocSession} from './DocSession';
import {DocSession, getDocSessionAccess, getDocSessionUserId, makeExceptionalDocSession,
OptDocSession} from './DocSession';
import {DocStorage} from './DocStorage';
import {expandQuery} from './ExpandedQuery';
import {OnDemandActions} from './OnDemandActions';
@ -147,16 +147,18 @@ export class ActiveDoc extends EventEmitter {
public get docName(): string { return this._docName; }
// Helpers to log a message along with metadata about the request.
public logDebug(c: Client|OptDocSession|null, msg: string, ...args: any[]) { this._log('debug', c, msg, ...args); }
public logInfo(c: Client|OptDocSession|null, msg: string, ...args: any[]) { this._log('info', c, msg, ...args); }
public logWarn(c: Client|OptDocSession|null, msg: string, ...args: any[]) { this._log('warn', c, msg, ...args); }
public logError(c: Client|OptDocSession|null, msg: string, ...args: any[]) { this._log('error', c, msg, ...args); }
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); }
public logWarn(s: OptDocSession, msg: string, ...args: any[]) { this._log('warn', s, msg, ...args); }
public logError(s: OptDocSession, msg: string, ...args: any[]) { this._log('error', s, msg, ...args); }
// Constructs metadata for logging, given a Client or an OptDocSession.
public getLogMeta(cli: Client|OptDocSession|null, docMethod?: string): log.ILogMeta {
const client = cli && ('getProfile' in cli ? cli : cli.client);
public getLogMeta(docSession: OptDocSession, docMethod?: string): log.ILogMeta {
const client = docSession.client;
const access = getDocSessionAccess(docSession);
return {
docId: this._docName,
access,
...(docMethod ? {docMethod} : {}),
...(client ? client.getLogMeta() : {}),
};
@ -183,9 +185,9 @@ export class ActiveDoc extends EventEmitter {
* earliest actions first, later actions later. If `summarize` is set,
* action summaries are computed and included.
*/
public async getRecentActions(client: Client|null, summarize: boolean): Promise<ActionGroup[]> {
public async getRecentActions(docSession: OptDocSession, summarize: boolean): Promise<ActionGroup[]> {
const actions = await this._actionHistory.getRecentActions(MAX_RECENT_ACTIONS);
return actions.map(act => asActionGroup(this._actionHistory, act, {client, summarize}));
return actions.map(act => asActionGroup(this._actionHistory, act, {client: docSession.client, summarize}));
}
/** expose action history for tests */
@ -204,7 +206,7 @@ export class ActiveDoc extends EventEmitter {
// If we had a shutdown scheduled, unschedule it.
if (this._inactivityTimer.isEnabled()) {
this.logInfo(client, "will stay open");
this.logInfo(docSession, "will stay open");
this._inactivityTimer.disable();
}
return docSession;
@ -215,11 +217,12 @@ export class ActiveDoc extends EventEmitter {
* @returns {Promise} Promise for when database and data engine are done shutting down.
*/
public async shutdown(removeThisActiveDoc: boolean = true): Promise<void> {
this.logDebug(null, "shutdown starting");
const docSession = makeExceptionalDocSession('system');
this.logDebug(docSession, "shutdown starting");
this._inactivityTimer.disable();
if (this.docClients.clientCount() > 0) {
this.logWarn(null, `Doc being closed with ${this.docClients.clientCount()} clients left`);
this.docClients.broadcastDocMessage(null, 'docShutdown', null);
this.logWarn(docSession, `Doc being closed with ${this.docClients.clientCount()} clients left`);
await this.docClients.broadcastDocMessage(null, 'docShutdown', null);
this.docClients.removeAllClients();
}
@ -246,9 +249,9 @@ export class ActiveDoc extends EventEmitter {
} catch (err) {
// Initialization errors do not matter at this point.
}
this.logDebug(null, "shutdown complete");
this.logDebug(docSession, "shutdown complete");
} catch (err) {
this.logError(null, "failed to shutdown some resources", err);
this.logError(docSession, "failed to shutdown some resources", err);
}
}
@ -289,7 +292,7 @@ export class ActiveDoc extends EventEmitter {
docSession);
if (isNew) {
await this.createDoc(docSession);
await this.addInitialTable();
await this.addInitialTable(docSession);
} else {
await this.docStorage.openFile();
const tableNames = await this._loadOpenDoc(docSession);
@ -361,7 +364,7 @@ export class ActiveDoc extends EventEmitter {
*/
public async _initDoc(docSession: OptDocSession|null): Promise<void> {
const metaTableData = await this._dataEngine.pyCall('fetch_meta_tables');
this.docData = new DocData(tableId => this.fetchTable(null, tableId), metaTableData);
this.docData = new DocData(tableId => this.fetchTable(makeExceptionalDocSession('system'), tableId), metaTableData);
this._onDemandActions = new OnDemandActions(this.docStorage, this.docData);
await this._actionHistory.initialize();
@ -377,8 +380,8 @@ export class ActiveDoc extends EventEmitter {
/**
* Adds a small table to start off a newly-created blank document.
*/
public addInitialTable() {
return this._applyUserActions(null, [["AddEmptyTable"]]);
public addInitialTable(docSession: OptDocSession) {
return this._applyUserActions(docSession, [["AddEmptyTable"]]);
}
/**
@ -437,18 +440,13 @@ export class ActiveDoc extends EventEmitter {
* This function saves attachments from a given upload and creates an entry for them in the database.
* It returns the list of rowIds for the rows created in the _grist_Attachments table.
*/
public async addAttachments(docSessOrReq: DocSession|Request, uploadId: number): Promise<number[]> {
// TODO Refactor to accept Request generally when DocSession is absent (for API calls), and
// include user/org info in logging too.
const [docSession, userId] = ('authorizer' in docSessOrReq ?
[docSessOrReq, docSessOrReq.authorizer.getUserId()] :
[{client: null}, getUserId(docSessOrReq)]);
public async addAttachments(docSession: OptDocSession, uploadId: number): Promise<number[]> {
const userId = getDocSessionUserId(docSession);
const upload: UploadInfo = globalUploadSet.getUploadInfo(uploadId, this.makeAccessId(userId));
try {
const userActions: UserAction[] = await Promise.all(
upload.files.map(file => this._prepAttachment(docSession, file)));
const result = await this._applyUserActions(docSession.client, userActions);
const result = await this._applyUserActions(docSession, userActions);
return result.retValues;
} finally {
await globalUploadSet.cleanup(uploadId);
@ -479,18 +477,18 @@ export class ActiveDoc extends EventEmitter {
* field of the _grist_Attachments table).
* @returns {Promise<Buffer>} Promise for the data of this attachment; rejected on error.
*/
public async getAttachmentData(client: Client|null, fileIdent: string): Promise<Buffer> {
public async getAttachmentData(docSession: OptDocSession, fileIdent: string): Promise<Buffer> {
const data = await this.docStorage.getFileData(fileIdent);
if (!data) { throw new ApiError("Invalid attachment identifier", 404); }
this.logInfo(client, "getAttachment: %s -> %s bytes", fileIdent, data.length);
this.logInfo(docSession, "getAttachment: %s -> %s bytes", fileIdent, data.length);
return data;
}
/**
* Fetches the meta tables to return to the client when first opening a document.
*/
public async fetchMetaTables(client: Client) {
this.logInfo(client, "fetchMetaTables");
public async fetchMetaTables(docSession: OptDocSession) {
this.logInfo(docSession, "fetchMetaTables");
if (!this.docData) { throw new Error("No doc data"); }
// Get metadata from local cache rather than data engine, so that we can
// still get it even if data engine is busy calculating.
@ -521,7 +519,7 @@ export class ActiveDoc extends EventEmitter {
* @returns {Promise} Promise for the TableData object, which is a BulkAddRecord-like array of the
* form of the form ["TableData", table_id, row_ids, column_values].
*/
public async fetchTable(docSession: DocSession|null, tableId: string,
public async fetchTable(docSession: OptDocSession, tableId: string,
waitForFormulas: boolean = false): Promise<TableDataAction> {
this.logInfo(docSession, "fetchTable(%s, %s)", docSession, tableId);
return this.fetchQuery(docSession, {tableId, filters: {}}, waitForFormulas);
@ -534,7 +532,7 @@ export class ActiveDoc extends EventEmitter {
* @param {Boolean} waitForFormulas: If true, wait for all data to be loaded/calculated. If false,
* special "pending" values may be returned.
*/
public async fetchQuery(docSession: DocSession|null, query: Query,
public async fetchQuery(docSession: OptDocSession, query: Query,
waitForFormulas: boolean = false): Promise<TableDataAction> {
this._inactivityTimer.ping(); // The doc is in active use; ping it to stay open longer.
@ -581,7 +579,7 @@ export class ActiveDoc extends EventEmitter {
* Makes a query (documented elsewhere) and subscribes to it, so that the client receives
* docActions that affect this query's results.
*/
public async useQuerySet(docSession: DocSession, query: Query): Promise<QueryResult> {
public async useQuerySet(docSession: OptDocSession, query: Query): Promise<QueryResult> {
this.logInfo(docSession, "useQuerySet(%s, %s)", docSession, query);
// TODO implement subscribing to the query.
// - Convert tableId+colIds to TableData/ColData references
@ -651,7 +649,7 @@ export class ActiveDoc extends EventEmitter {
// there'll be a deadlock.
await this.waitForInitialization();
const newOptions = {linkId: docSession.linkId, ...options};
const result: ApplyUAResult = await this._applyUserActions(docSession.client, actions, newOptions);
const result: ApplyUAResult = await this._applyUserActions(docSession, actions, newOptions);
docSession.linkId = docSession.shouldBundleActions ? result.actionNum : 0;
return result;
}
@ -726,11 +724,11 @@ export class ActiveDoc extends EventEmitter {
public async removeInstanceFromDoc(docSession: DocSession): Promise<void> {
const instanceId = await this._sharing.removeInstanceFromDoc();
await this._applyUserActions(docSession.client, [['RemoveInstance', instanceId]]);
await this._applyUserActions(docSession, [['RemoveInstance', instanceId]]);
}
public async renameDocTo(client: Client, newName: string): Promise<void> {
this.logDebug(client, 'renameDoc', newName);
public async renameDocTo(docSession: OptDocSession, newName: string): Promise<void> {
this.logDebug(docSession, 'renameDoc', newName);
await this.docStorage.renameDocTo(newName);
this._docName = newName;
}
@ -812,7 +810,7 @@ export class ActiveDoc extends EventEmitter {
// Get recent actions in ActionGroup format with summaries included.
public async getActionSummaries(docSession: DocSession): Promise<ActionGroup[]> {
return this.getRecentActions(docSession.client, true);
return this.getRecentActions(docSession, true);
}
/**
@ -882,10 +880,22 @@ export class ActiveDoc extends EventEmitter {
return this._docManager.makeAccessId(userId);
}
/**
* 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);
}
/**
* Loads an open document from DocStorage. Returns a list of the tables it contains.
*/
protected async _loadOpenDoc(docSession: OptDocSession|null): Promise<string[]> {
protected async _loadOpenDoc(docSession: OptDocSession): Promise<string[]> {
// Fetch the schema version of document and sandbox, and migrate if the sandbox is newer.
const [schemaVersion, docInfoData] = await Promise.all([
this._dataEngine.pyCall('get_version'),
@ -947,9 +957,10 @@ export class ActiveDoc extends EventEmitter {
* isModification: true if document was changed by one or more actions.
* }
*/
protected async _applyUserActions(client: Client|null, actions: UserAction[],
protected async _applyUserActions(docSession: OptDocSession, actions: UserAction[],
options: ApplyUAOptions = {}): Promise<ApplyUAResult> {
this.logDebug(client, "_applyUserActions(%s, %s)", client, shortDesc(actions));
const client = docSession.client;
this.logDebug(docSession, "_applyUserActions(%s, %s)", client, shortDesc(actions));
this._inactivityTimer.ping(); // The doc is in active use; ping it to stay open longer.
const user = client && client.session ? (await client.session.getEmail()) : "";
@ -970,7 +981,7 @@ export class ActiveDoc extends EventEmitter {
const result: ApplyUAResult = await new Promise<ApplyUAResult>(
(resolve, reject) =>
this._sharing!.addUserAction({action, client, resolve, reject}));
this.logDebug(client, "_applyUserActions returning %s", util.inspect(result));
this.logDebug(docSession, "_applyUserActions returning %s", util.inspect(result));
if (result.isModification) {
this._fetchCache.clear(); // This could be more nuanced.
@ -1024,7 +1035,7 @@ export class ActiveDoc extends EventEmitter {
* expect different schema versions. The returned actions at the moment aren't even shared with
* collaborators.
*/
private async _migrate(docSession: OptDocSession|null): Promise<void> {
private async _migrate(docSession: OptDocSession): Promise<void> {
// TODO: makeBackup should possibly be in docManager directly.
const backupPath = await this._docManager.storageManager.makeBackup(this._docName, "migrate");
this.logInfo(docSession, "_migrate: backup made at %s", backupPath);
@ -1069,7 +1080,7 @@ export class ActiveDoc extends EventEmitter {
private async _finishInitialization(docSession: OptDocSession, pendingTableNames: string[], startTime: number) {
try {
await this._loadTables(docSession, pendingTableNames);
await this._applyUserActions(null, [['Calculate']]);
await this._applyUserActions(docSession, [['Calculate']]);
await this._reportDataEngineMemory();
this._fullyLoaded = true;
const endTime = Date.now();
@ -1115,8 +1126,8 @@ export class ActiveDoc extends EventEmitter {
}
}
private _log(level: string, cli: Client|OptDocSession|null, msg: string, ...args: any[]) {
log.origLog(level, `ActiveDoc ` + msg, ...args, this.getLogMeta(cli));
private _log(level: string, docSession: OptDocSession, msg: string, ...args: any[]) {
log.origLog(level, `ActiveDoc ` + msg, ...args, this.getLogMeta(docSession));
}
}

View File

@ -340,18 +340,26 @@ export interface ResourceSummary {
/**
*
* Handle authorization for a single resource accessed by a given user.
* Handle authorization for a single document accessed by a given user.
*
*/
export interface Authorizer {
// get the id of user, or null if no authorization in place.
getUserId(): number|null;
// get the id of the document.
getDocId(): string;
// Fetch the doc metadata from HomeDBManager.
getDoc(): Promise<Document>;
// Check access, throw error if the requested level of access isn't available.
assertAccess(role: 'viewers'|'editors'): Promise<void>;
// Get the lasted access information calculated for the doc. This is useful
// for logging - but access control itself should use assertAccess() to
// ensure the data is fresh.
getCachedAuth(): DocAuthResult;
}
/**
@ -364,6 +372,7 @@ export class DocAuthorizer implements Authorizer {
private _dbManager: HomeDBManager,
private _key: DocAuthKey,
public readonly openMode: OpenDocMode,
private _docAuth?: DocAuthResult
) {
}
@ -371,21 +380,40 @@ export class DocAuthorizer implements Authorizer {
return this._key.userId;
}
public getDocId(): string {
// We've been careful to require urlId === docId, see DocManager.
return this._key.urlId;
}
public async getDoc(): Promise<Document> {
return this._dbManager.getDoc(this._key);
}
public async assertAccess(role: 'viewers'|'editors'): Promise<void> {
const docAuth = await this._dbManager.getDocAuthCached(this._key);
this._docAuth = docAuth;
assertAccess(role, docAuth, {openMode: this.openMode});
}
public getCachedAuth(): DocAuthResult {
if (!this._docAuth) { throw Error('no cached authentication'); }
return this._docAuth;
}
}
export class DummyAuthorizer implements Authorizer {
constructor(public role: Role|null) {}
constructor(public role: Role|null, public docId: string) {}
public getUserId() { return null; }
public getDocId() { return this.docId; }
public async getDoc(): Promise<Document> { throw new Error("Not supported in standalone"); }
public async assertAccess() { /* noop */ }
public getCachedAuth(): DocAuthResult {
return {
access: this.role,
docId: this.docId,
removed: false,
};
}
}

View File

@ -11,6 +11,7 @@ import { ActiveDoc } from "app/server/lib/ActiveDoc";
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, getUserId, isAnonymousUser,
RequestWithLogin } from 'app/server/lib/Authorizer';
import { DocManager } from "app/server/lib/DocManager";
import { makeExceptionalDocSession } from "app/server/lib/DocSession";
import { DocWorker } from "app/server/lib/DocWorker";
import { expressWrap } from 'app/server/lib/expressWrap';
import { GristServer } from 'app/server/lib/GristServer';
@ -100,7 +101,8 @@ export class DocWorkerApi {
throw new ApiError("Invalid query: filter values must be arrays", 400);
}
const tableId = req.params.tableId;
const tableData = await handleSandboxError(tableId, [], activeDoc.fetchQuery(null, {tableId, filters}, true));
const tableData = await handleSandboxError(tableId, [], activeDoc.fetchQuery(
{client: null, req}, {tableId, filters}, true));
// Apply sort/limit parameters, if set. TODO: move sorting/limiting into data engine
// and sql.
const params = getQueryParameters(req);
@ -111,7 +113,7 @@ export class DocWorkerApi {
// Returns the list of rowIds for the rows created in the _grist_Attachments table.
this._app.post('/api/docs/:docId/attachments', canEdit, withDoc(async (activeDoc, req, res) => {
const uploadResult = await handleUpload(req, res);
res.json(await activeDoc.addAttachments(req, uploadResult.uploadId));
res.json(await activeDoc.addAttachments({client: null, req}, uploadResult.uploadId));
}));
// Returns the metadata for a given attachment ID (i.e. a rowId in _grist_Attachments table).
@ -129,7 +131,7 @@ export class DocWorkerApi {
const ext = path.extname(fileIdent);
const origName = attRecord.fileName as string;
const fileName = ext ? path.basename(origName, path.extname(origName)) + ext : origName;
const fileData = await activeDoc.getAttachmentData(null, fileIdent);
const fileData = await activeDoc.getAttachmentData({client: null, req}, fileIdent);
res.status(200)
.type(ext)
// Construct a content-disposition header of the form 'attachment; filename="NAME"'
@ -331,7 +333,10 @@ export class DocWorkerApi {
const isAnonymous = isAnonymousUser(req);
const {docId} = makeForkIds({userId, isAnonymous, trunkDocId: NEW_DOCUMENT_CODE,
trunkUrlId: NEW_DOCUMENT_CODE});
await this._docManager.fetchDoc({ client: null, req: req as RequestWithLogin, browserSettings }, docId);
await this._docManager.fetchDoc(makeExceptionalDocSession('nascent', {
req: req as RequestWithLogin,
browserSettings
}), docId);
return res.status(200).json(docId);
}));
}

View File

@ -74,13 +74,28 @@ export class DocClients {
* @param {String} type: The type of the message, e.g. 'docUserAction'.
* @param {Object} messageData: The data for this type of message.
*/
public broadcastDocMessage(client: Client|null, type: string, messageData: any): void {
for (let i = 0, len = this._docSessions.length; i < len; i++) {
const curr = this._docSessions[i];
public async broadcastDocMessage(client: Client|null, type: string, messageData: any): Promise<void> {
await Promise.all(this._docSessions.map(async curr => {
const fromSelf = (curr.client === client);
sendDocMessage(curr.client, curr.fd, type, messageData, fromSelf);
}
try {
// Make sure user still has view access.
await curr.authorizer.assertAccess('viewers');
sendDocMessage(curr.client, curr.fd, type, messageData, fromSelf);
} catch (e) {
if (e.code === 'AUTH_NO_VIEW') {
// Skip sending data to this user, they have no view access.
log.rawDebug('skip broadcastDocMessage because AUTH_NO_VIEW', {
docId: curr.authorizer.getDocId(),
...curr.client.getLogMeta()
});
// Go further and trigger a shutdown for this user, in case they are granted
// access again later.
sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf);
} else {
throw(e);
}
}
}));
if (type === "docUserAction" && messageData.docActions) {
for (const action of messageData.docActions) {
this.activeDoc.docPluginManager.receiveAction(action);

View File

@ -16,7 +16,7 @@ import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {assertAccess, Authorizer, DocAuthorizer, DummyAuthorizer,
isSingleUserMode} from 'app/server/lib/Authorizer';
import {Client} from 'app/server/lib/Client';
import {makeOptDocSession, OptDocSession} from 'app/server/lib/DocSession';
import {makeExceptionalDocSession, makeOptDocSession, OptDocSession} from 'app/server/lib/DocSession';
import * as docUtils from 'app/server/lib/docUtils';
import {GristServer} from 'app/server/lib/GristServer';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
@ -109,8 +109,9 @@ export class DocManager extends EventEmitter {
*/
public async createNewDoc(client: Client): Promise<string> {
log.debug('DocManager.createNewDoc');
const activeDoc: ActiveDoc = await this.createNewEmptyDoc(makeOptDocSession(client), 'Untitled');
await activeDoc.addInitialTable();
const docSession = makeExceptionalDocSession('nascent', {client});
const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, 'Untitled');
await activeDoc.addInitialTable(docSession);
return activeDoc.docName;
}
@ -172,7 +173,8 @@ export class DocManager extends EventEmitter {
if (!this._homeDbManager) { throw new Error("HomeDbManager not available"); }
const accessId = this.makeAccessId(userId);
const result = await this._doImportDoc(makeOptDocSession(null, browserSettings),
const docSession = makeExceptionalDocSession('nascent', {browserSettings});
const result = await this._doImportDoc(docSession,
globalUploadSet.getUploadInfo(uploadId, accessId), {
naming: workspaceId ? 'saved' : 'unsaved',
userId,
@ -265,14 +267,16 @@ export class DocManager extends EventEmitter {
// than a docId.
throw new Error(`openDoc expected docId ${docAuth.docId} not urlId ${docId}`);
}
auth = new DocAuthorizer(dbManager, key, mode);
auth = new DocAuthorizer(dbManager, key, mode, docAuth);
} else {
log.debug(`DocManager.openDoc not using authorization for ${docId} because GRIST_SINGLE_USER`);
auth = new DummyAuthorizer('owners');
auth = new DummyAuthorizer('owners', docId);
}
// Fetch the document, and continue when we have the ActiveDoc (which may be immediately).
const activeDoc: ActiveDoc = await this.fetchDoc(makeOptDocSession(client), docId);
const docSessionPrecursor = makeOptDocSession(client);
docSessionPrecursor.authorizer = auth;
const activeDoc: ActiveDoc = await this.fetchDoc(docSessionPrecursor, docId);
if (activeDoc.muted) {
log.debug('DocManager.openDoc interrupting, called for a muted doc', docId);
@ -282,8 +286,8 @@ export class DocManager extends EventEmitter {
const docSession = activeDoc.addClient(client, auth);
const [metaTables, recentActions] = await Promise.all([
activeDoc.fetchMetaTables(client),
activeDoc.getRecentActions(client, false)
activeDoc.fetchMetaTables(docSession),
activeDoc.getRecentActions(docSession, false)
]);
this.emit('open-doc', this.storageManager.getPath(activeDoc.docName));
@ -333,7 +337,7 @@ export class DocManager extends EventEmitter {
const docPromise = this._activeDocs.get(oldName);
if (docPromise) {
const adoc: ActiveDoc = await docPromise;
await adoc.renameDocTo(client, newName);
await adoc.renameDocTo({client}, newName);
this._activeDocs.set(newName, docPromise);
this._activeDocs.delete(oldName);
} else {

View File

@ -10,6 +10,7 @@ import {GristDocAPI} from "app/plugin/GristAPI";
import {Storage} from 'app/plugin/StorageAPI';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {DocPluginData} from 'app/server/lib/DocPluginData';
import {makeExceptionalDocSession} from 'app/server/lib/DocSession';
import {FileParserElement} from 'app/server/lib/FileParserElement';
import {GristServer} from 'app/server/lib/GristServer';
import * as log from 'app/server/lib/log';
@ -38,11 +39,12 @@ class GristDocAPIImpl implements GristDocAPI {
}
public async fetchTable(tableId: string): Promise<TableColValues> {
return fromTableDataAction(await this._activeDoc.fetchTable(null, tableId));
return fromTableDataAction(await this._activeDoc.fetchTable(
makeExceptionalDocSession('plugin'), tableId));
}
public applyUserActions(actions: any[][]): Promise<ApplyUAResult> {
return this._activeDoc.applyUserActions({client: null}, actions);
return this._activeDoc.applyUserActions(makeExceptionalDocSession('plugin'), actions);
}
}

View File

@ -1,6 +1,7 @@
import {BrowserSettings} from 'app/common/BrowserSettings';
import {Role} from 'app/common/roles';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer';
import {Authorizer, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {Client} from 'app/server/lib/Client';
/**
@ -13,6 +14,8 @@ export interface OptDocSession {
linkId?: number;
browserSettings?: BrowserSettings;
req?: RequestWithLogin;
mode?: 'nascent'|'plugin'|'system'; // special permissions for creating, plugins, and system access
authorizer?: Authorizer;
}
export function makeOptDocSession(client: Client|null, browserSettings?: BrowserSettings): OptDocSession {
@ -20,6 +23,22 @@ export function makeOptDocSession(client: Client|null, browserSettings?: Browser
return {client, browserSettings};
}
/**
* Create an OptDocSession with special access rights.
* - nascent: user is treated as owner (because doc is being created)
* - plugin: user is treated as editor (because plugin access control is crude)
* - system: user is treated as owner (because of some operation bypassing access control)
*/
export function makeExceptionalDocSession(mode: 'nascent'|'plugin'|'system',
options: {client?: Client,
req?: RequestWithLogin,
browserSettings?: BrowserSettings} = {}): OptDocSession {
const docSession = makeOptDocSession(options.client || null, options.browserSettings);
docSession.mode = mode;
docSession.req = options.req;
return docSession;
}
/**
* DocSession objects maintain information for a single session<->doc instance.
*/
@ -47,3 +66,48 @@ export class DocSession implements OptDocSession {
// Browser settings (like timezone) obtained from the Client.
public get browserSettings(): BrowserSettings { return this.client.browserSettings; }
}
/**
* Extract userId from OptDocSession. Use Authorizer if available (for web socket
* sessions), or get it from the Request if that is available (for rest api calls),
* or from the Client if that is available. Returns null if userId information is
* not available or not cached.
*/
export function getDocSessionUserId(docSession: OptDocSession): number|null {
if (docSession.authorizer) {
return docSession.authorizer.getUserId();
}
if (docSession.req) {
return getUserId(docSession.req);
}
if (docSession.client) {
return docSession.client.getCachedUserId();
}
return null;
}
/**
* Extract user's role from OptDocSession. Method depends on whether using web
* sockets or rest api. Assumes that access has already been checked by wrappers
* for api methods and that cached access information is therefore available.
*/
export function getDocSessionAccess(docSession: OptDocSession): Role {
// "nascent" DocSessions are for when a document is being created, and user is
// its only owner as yet.
// "system" DocSessions are for access without access control.
if (docSession.mode === 'nascent' || docSession.mode === 'system') { return 'owners'; }
// "plugin" DocSessions are for access from plugins, which is currently quite crude,
// and granted only to editors.
if (docSession.mode === 'plugin') { return 'editors'; }
if (docSession.authorizer) {
const access = docSession.authorizer.getCachedAuth().access;
if (!access) { throw new Error('getDocSessionAccess expected authorizer.getCachedAuth'); }
return access;
}
if (docSession.req) {
const access = docSession.req.docAuth?.access;
if (!access) { throw new Error('getDocSessionAccess expected req.docAuth.access'); }
return access;
}
throw new Error('getDocSessionAccess could not find access information in DocSession');
}

View File

@ -4,10 +4,10 @@
*/
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {assertAccess, getOrSetDocAuth, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {Client} from 'app/server/lib/Client';
import * as Comm from 'app/server/lib/Comm';
import {DocSession} from 'app/server/lib/DocSession';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import * as log from 'app/server/lib/log';
import {integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils';
@ -35,9 +35,9 @@ export class DocWorker {
public async getAttachment(req: express.Request, res: express.Response): Promise<void> {
try {
const client = this._comm.getClient(stringParam(req.query.clientId));
const activeDoc = this._getActiveDoc(stringParam(req.query.clientId),
integerParam(req.query.docFD));
const docSession = this._getDocSession(stringParam(req.query.clientId),
integerParam(req.query.docFD));
const activeDoc = docSession.activeDoc;
const ext = path.extname(stringParam(req.query.ident));
const type = mimeTypes.lookup(ext);
@ -48,7 +48,7 @@ export class DocWorker {
// Construct a content-disposition header of the form 'inline|attachment; filename="NAME"'
const contentDispType = inline ? "inline" : "attachment";
const contentDispHeader = contentDisposition(stringParam(req.query.name), {type: contentDispType});
const data = await activeDoc.getAttachmentData(client, stringParam(req.query.ident));
const data = await activeDoc.getAttachmentData(docSession, stringParam(req.query.ident));
res.status(200)
.type(ext)
.set('Content-Disposition', contentDispHeader)
@ -136,8 +136,8 @@ export class DocWorker {
let urlId: string|undefined;
try {
if (optStringParam(req.query.clientId)) {
const activeDoc = this._getActiveDoc(stringParam(req.query.clientId),
integerParam(req.query.docFD));
const activeDoc = this._getDocSession(stringParam(req.query.clientId),
integerParam(req.query.docFD)).activeDoc;
// TODO: The docId should be stored in the ActiveDoc class. Currently docName is
// used instead, which will coincide with the docId for hosted grist but not for
// standalone grist.
@ -158,10 +158,9 @@ export class DocWorker {
}
}
private _getActiveDoc(clientId: string, docFD: number): ActiveDoc {
private _getDocSession(clientId: string, docFD: number): DocSession {
const client = this._comm.getClient(clientId);
const docSession = client.getDocSession(docFD);
return docSession.activeDoc;
return client.getDocSession(docFD);
}
}
@ -175,7 +174,7 @@ async function activeDocMethod(role: 'viewers'|'editors'|null, methodName: strin
const activeDoc = docSession.activeDoc;
if (role) { await docSession.authorizer.assertAccess(role); }
// Include a basic log record for each ActiveDoc method call.
log.rawDebug('activeDocMethod', activeDoc.getLogMeta(client, methodName));
log.rawDebug('activeDocMethod', activeDoc.getLogMeta(docSession, methodName));
return (activeDoc as any)[methodName](docSession, ...args);
}

View File

@ -275,7 +275,7 @@ export class Sharing {
retValues: sandboxActionBundle.retValues,
summarize: true,
});
await this._activeDoc.docClients.broadcastDocMessage(client || null, 'docUserAction', {
await this._activeDoc.broadcastDocUpdate(client || null, 'docUserAction', {
actionGroup,
docActions: getEnvContent(localActionBundle.stored).concat(
getEnvContent(localActionBundle.calc))

View File

@ -37,7 +37,7 @@ function generateCSV(req, res, comm) {
res.set('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', contentDisposition(name));
return makeCSV(activeDoc, viewSectionId, activeSortOrder)
return makeCSV(activeDoc, viewSectionId, activeSortOrder, req)
.then(data => res.send(data));
}
exports.generateCSV = generateCSV;
@ -51,7 +51,7 @@ exports.generateCSV = generateCSV;
* @param {Integer[]} activeSortOrder (optional) - overriding sort order.
* @return {Promise<string>} Promise for the resulting CSV.
*/
function makeCSV(activeDoc, viewSectionId, sortOrder) {
function makeCSV(activeDoc, viewSectionId, sortOrder, req) {
return Promise.try(() => {
const tables = activeDoc.docData.getTables();
const viewSection = tables.get('_grist_Views_section').getRecord(viewSectionId);
@ -88,7 +88,7 @@ function makeCSV(activeDoc, viewSectionId, sortOrder) {
return directionalColRef > 0 ? effectiveColRef : -effectiveColRef;
});
return [activeDoc.fetchTable(null, table.tableId, true), tableColumns, viewColumns];
return [activeDoc.fetchTable({client: null, req}, table.tableId, true), tableColumns, viewColumns];
}).spread((data, tableColumns, viewColumns) => {
const rowIds = data[2];
const dataByColId = data[3];