diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index ce5bedeb..abf50e49 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -16,7 +16,7 @@ import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018 import {confirmModal} from 'app/client/ui2018/modals'; import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow'; import {delay} from 'app/common/delay'; -import {OpenDocMode} from 'app/common/DocListAPI'; +import {OpenDocMode, UserOverride} from 'app/common/DocListAPI'; import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {getReconnectTimeout} from 'app/common/gutil'; import {canEdit} from 'app/common/roles'; @@ -32,6 +32,7 @@ export interface DocInfo extends Document { isPreFork: boolean; isFork: boolean; isRecoveryMode: boolean; + userOverride: UserOverride|null; isBareFork: boolean; // a document created without logging in, which is treated as a // fork without an original. idParts: UrlIdParts; @@ -56,6 +57,7 @@ export interface DocPageModel { isPrefork: Observable; isFork: Observable; isRecoveryMode: Observable; + userOverride: Observable; isBareFork: Observable; isSample: Observable; @@ -93,6 +95,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { 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 userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null); 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); @@ -245,8 +248,9 @@ 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; + if (openDocResponse.recoveryMode || openDocResponse.userOverride) { + doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode); + doc.userOverride = openDocResponse.userOverride || null; this.currentDoc.set({...doc}); } const gdModule = await gristDocModulePromise; @@ -329,6 +333,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo { ...doc, isFork, isRecoveryMode: false, // we don't know yet, will learn when doc is opened. + userOverride: null, // ditto. isSample, isPreFork, isBareFork, diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 9d915f75..4a492db8 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -44,10 +44,12 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode docNameSave: renameDoc, pageNameSave: getRenamePageFn(gristDoc), cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc), + cancelUserOverride: getCancelUserOverrideFn(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, + userOverride: pageModel.userOverride, 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)), @@ -100,6 +102,15 @@ function getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise { }; } +function getCancelUserOverrideFn(gristDoc: GristDoc): () => Promise { + return async () => { + const url = new URL(window.location.href); + url.searchParams.delete('aclAsUser_'); + url.searchParams.delete('aclAsUserId_'); + window.location.assign(url.href); + }; +} + function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element { return cssHoverCircle( cssTopBarUndoBtn(iconName), diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index c4ad9a61..a20eaa8b 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -9,6 +9,7 @@ import { urlState } from 'app/client/models/gristUrlState'; import { colors, testId } from 'app/client/ui2018/cssVars'; import { editableLabel } from 'app/client/ui2018/editableLabel'; import { icon } from 'app/client/ui2018/icons'; +import { UserOverride } from 'app/common/DocListAPI'; import { BindableValue, dom, Observable, styled } from 'grainjs'; import { tooltip } from 'popweasel'; @@ -85,11 +86,13 @@ export function docBreadcrumbs( docNameSave: (val: string) => Promise, pageNameSave: (val: string) => Promise, cancelRecoveryMode: () => Promise, + cancelUserOverride: () => Promise, isDocNameReadOnly?: BindableValue, isPageNameReadOnly?: BindableValue, isFork: Observable, isFiddle: Observable, isRecoveryMode: Observable, + userOverride: Observable, isSnapshot?: Observable, isPublic?: Observable, } @@ -118,11 +121,17 @@ export function docBreadcrumbs( } if (use(options.isRecoveryMode)) { return cssAlertTag('recovery mode', - dom('a', dom.on('click', async () => { - await options.cancelRecoveryMode() - }), icon('CrossSmall')), + dom('a', dom.on('click', () => options.cancelRecoveryMode()), + icon('CrossSmall')), testId('recovery-mode-tag')); } + const userOverride = use(options.userOverride); + if (userOverride) { + return cssAlertTag(userOverride.user?.email || 'override', + dom('a', dom.on('click', () => options.cancelUserOverride()), + icon('CrossSmall')), + testId('user-override-tag')); + } if (use(options.isFiddle)) { return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag')); } diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index 493ce5a9..ce96df27 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -1,7 +1,9 @@ import {ActionGroup} from 'app/common/ActionGroup'; import {TableDataAction} from 'app/common/DocActions'; import {LocalPlugin} from 'app/common/plugin'; +import {Role} from 'app/common/roles'; import {StringUnion} from 'app/common/StringUnion'; +import {FullUser} from 'app/common/UserAPI'; // Possible flavors of items in a list of documents. export type DocEntryTag = ''|'sample'|'invite'|'shared'; @@ -43,6 +45,12 @@ export interface OpenLocalDocResult { log: ActionGroup[]; plugins: LocalPlugin[]; recoveryMode?: boolean; + userOverride?: UserOverride; +} + +export class UserOverride { + user: FullUser|null; + access: Role|null; } export interface DocListAPI { diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index a146378a..68304f66 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -359,7 +359,7 @@ export class HomeDBManager extends EventEmitter { } public getUser(userId: number): Promise { - return User.findOne(userId); + return User.findOne(userId, {relations: ["logins"]}); } public async getFullUser(userId: number): Promise { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 17299924..1b97aa00 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -160,6 +160,10 @@ export class ActiveDoc extends EventEmitter { public get recoveryMode(): boolean { return this._recoveryMode; } + public async getUserOverride(docSession: OptDocSession) { + return this._granularAccess.getUserOverride(docSession); + } + // 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); } @@ -417,7 +421,7 @@ export class ActiveDoc extends EventEmitter { await this._actionHistory.initialize(); this._granularAccess = new GranularAccess(this.docData, (query) => { return this._fetchQueryFromDB(query, false); - }, this.recoveryMode); + }, this.recoveryMode, this._docManager.getHomeDbManager(), this.docName); await this._granularAccess.update(); this._sharing = new Sharing(this, this._actionHistory, this._modificationLock); diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index ec1b21c5..e5564b9a 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -54,6 +54,10 @@ export class DocManager extends EventEmitter { this._homeDbManager = dbManager; } + public getHomeDbManager() { + return this._homeDbManager; + } + /** * Returns an implementation of the DocListAPI for the given Client object. */ @@ -303,6 +307,7 @@ export class DocManager extends EventEmitter { log: recentActions, plugins: activeDoc.docPluginManager.getPlugins(), recoveryMode: activeDoc.recoveryMode, + userOverride: await activeDoc.getUserOverride(docSession), }; } diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 8e459dbb..1be5fcbf 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -12,14 +12,18 @@ import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActi import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions'; import { TableDataAction, UserAction } from 'app/common/DocActions'; import { DocData } from 'app/common/DocData'; +import { UserOverride } from 'app/common/DocListAPI'; import { ErrorWithCode } from 'app/common/ErrorWithCode'; import { AclMatchInput, InfoView } from 'app/common/GranularAccessClause'; import { RuleSet, UserInfo } from 'app/common/GranularAccessClause'; import { getSetMapValue, isObject } from 'app/common/gutil'; -import { canView } from 'app/common/roles'; +import { canView, Role } from 'app/common/roles'; +import { FullUser } from 'app/common/UserAPI'; +import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { getDocSessionAccess, getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession'; import * as log from 'app/server/lib/log'; +import { integerParam } from 'app/server/lib/requestUtils'; import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess'; import cloneDeep = require('lodash/cloneDeep'); import get = require('lodash/get'); @@ -114,7 +118,9 @@ export class GranularAccess { public constructor( private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise, - private _recoveryMode: boolean) { + private _recoveryMode: boolean, + private _homeDbManager: HomeDBManager | null, + private _docId: string) { } /** @@ -488,6 +494,11 @@ export class GranularAccess { this._filterColumns(data[3], (colId) => permInfo.getColumnAccess(tableId, colId).read !== 'deny'); } + public async getUserOverride(docSession: OptDocSession): Promise { + await this._getUser(docSession); + return this._getUserAttributes(docSession).override; + } + /** * Strip out any denied columns from an action. Returns null if nothing is left. * accessFn may throw if denials are fatal. @@ -799,9 +810,36 @@ export class GranularAccess { * created by user-attribute rules. */ private async _getUser(docSession: OptDocSession): Promise { - const access = getDocSessionAccess(docSession); - const fullUser = getDocSessionUser(docSession); + const linkParameters = docSession.authorizer?.getLinkParameters() || {}; + let access: Role | null; + let fullUser: FullUser | null; const attrs = this._getUserAttributes(docSession); + access = getDocSessionAccess(docSession); + + // If aclAsUserId/aclAsUser is set, then override user for acl purposes. + if (linkParameters.aclAsUserId || linkParameters.aclAsUser) { + if (!this.isOwner(docSession)) { throw new Error('only an owner can override user'); } + if (attrs.override) { + // Used cached properties. + access = attrs.override.access; + fullUser = attrs.override.user; + } else { + // Look up user information in database. + if (!this._homeDbManager) { throw new Error('database required'); } + const user = linkParameters.aclAsUserId ? + (await this._homeDbManager.getUser(integerParam(linkParameters.aclAsUserId))) : + (await this._homeDbManager.getUserByLogin(linkParameters.aclAsUser)); + const docAuth = user && await this._homeDbManager.getDocAuthCached({ + urlId: this._docId, + userId: user.id + }); + access = docAuth?.access || null; + fullUser = user && this._homeDbManager.makeFullUser(user) || null; + attrs.override = { access, user: fullUser }; + } + } else { + fullUser = getDocSessionUser(docSession); + } const user: UserInfo = {}; user.Access = access; user.UserID = fullUser?.id || null; @@ -809,7 +847,7 @@ export class GranularAccess { user.Name = fullUser?.name || null; // If viewed from a websocket, collect any link parameters included. // TODO: could also get this from rest api access, just via a different route. - user.Link = docSession.authorizer?.getLinkParameters() || {}; + user.Link = linkParameters; // Include origin info if accessed via the rest api. // TODO: could also get this for websocket access, just via a different route. user.Origin = docSession.req?.get('origin') || null; @@ -1114,6 +1152,7 @@ class EmptyRecordView implements InfoView { */ class UserAttributes { public rows: {[clauseName: string]: InfoView} = {}; + public override?: UserOverride; } // A function for extracting one of the create/read/update/delete/schemaEdit permissions