diff --git a/app/client/ui/AccessRules.ts b/app/client/ui/AccessRules.ts index b363c178..c455cb92 100644 --- a/app/client/ui/AccessRules.ts +++ b/app/client/ui/AccessRules.ts @@ -8,6 +8,7 @@ import {primaryButton} from 'app/client/ui2018/buttons'; import {colors} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuItem, select} from 'app/client/ui2018/menus'; +import {decodeClause, GranularAccessDocClause, serializeClause} from 'app/common/GranularAccessClause'; import {arrayRepeat, setDifference} from 'app/common/gutil'; import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs'; import isEqual = require('lodash/isEqual'); @@ -23,11 +24,14 @@ function buildAclState(gristDoc: GristDoc): AclState { const tableData = gristDoc.docModel.aclResources.tableData; for (const res of tableData.getRecords()) { const code = String(res.colIds); - if (res.tableId && code === '~o') { - ownerOnlyTableIds.add(String(res.tableId)); - } - if (!res.tableId && code === '~o structure') { - ownerOnlyStructure = true; + const clause = decodeClause(code); + if (clause) { + if (clause.kind === 'doc') { + ownerOnlyStructure = true; + } + if (clause.kind === 'table' && clause.tableId) { + ownerOnlyTableIds.add(clause.tableId); + } } } return {ownerOnlyTableIds, ownerOnlyStructure}; @@ -63,10 +67,15 @@ export class AccessRules extends Disposable { await tableData.docData.bundleActions('Update Access Rules', async () => { // If ownerOnlyStructure flag changed, add or remove the relevant resource record. if (currentState.ownerOnlyStructure !== latestState.ownerOnlyStructure) { + const clause: GranularAccessDocClause = { + kind: 'doc', + match: { kind: 'const', charId: 'Access', value: 'owners' }, + }; + const colIds = serializeClause(clause); if (currentState.ownerOnlyStructure) { - await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds: "~o structure"}]); + await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds}]); } else { - const rowId = tableData.findMatchingRowId({tableId: '', colIds: '~o structure'}); + const rowId = tableData.findMatchingRowId({tableId: '', colIds}); if (rowId) { await this._gristDoc.docModel.aclResources.sendTableAction(['RemoveRecord', rowId]); } @@ -78,11 +87,15 @@ export class AccessRules extends Disposable { if (tablesAdded.size) { await tableData.sendTableAction(['BulkAddRecord', arrayRepeat(tablesAdded.size, null), { tableId: [...tablesAdded], - colIds: arrayRepeat(tablesAdded.size, "~o"), + colIds: [...tablesAdded].map(tableId => serializeClause({ + kind: 'table', + tableId, + match: { kind: 'const', charId: 'Access', value: 'owners' }, + })), }]); } - // Handle table removed from ownerOnlyTaleIds. + // Handle table removed from ownerOnlyTableIds. const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds); if (tablesRemoved.size) { const rowIds = Array.from(tablesRemoved, t => tableData.findRow('tableId', t)).filter(r => r); diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index 0499aeea..0214b4b4 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -1,37 +1,106 @@ +import { safeJsonParse } from 'app/common/gutil'; +import { CellValue } from 'app/plugin/GristData'; + /** - * All possible access clauses. There aren't all that many yet. - * In future the clauses will become more generalized, and start specifying - * the principle / properties of the user to which they apply. + * All possible access clauses. In future the clauses will become more generalized. + * The consequences of clauses are currently combined in a naive and ad-hoc way, + * this will need systematizing. */ export type GranularAccessClause = GranularAccessDocClause | GranularAccessTableClause | - GranularAccessRowClause; + GranularAccessRowClause | + GranularAccessCharacteristicsClause; /** * A clause that forbids anyone but owners from modifying the document structure. */ export interface GranularAccessDocClause { kind: 'doc'; - rule: 'only-owner-can-modify-structure'; + match: MatchSpec; } /** - * A clause that forbids anyone but owners from accessing a particular table. + * A clause to control access to a specific table. */ export interface GranularAccessTableClause { kind: 'table'; tableId: string; - rule: 'only-owner-can-access'; + match: MatchSpec; } /** - * A clause that forbids anyone but owners from editing a particular table - * or viewing rows for which the named column contains a falsy value. + * A clause to control access to rows within a specific table. + * If "scope" is provided, this rule is simply ignored if the scope does not match + * the user. */ export interface GranularAccessRowClause { kind: 'row'; tableId: string; - colId: string; - rule: 'only-owner-can-edit-table-and-access-all-rows'; + match: MatchSpec; + scope?: MatchSpec; +} + +/** + * A clause to make more information about the user/request available for access + * control decisions. + * - charId specifies a property of the user (e.g. Access/Email/UserID/Name, or a + * property added by another clause) to use as a key. + * - We look for a matching record in the specified table, comparing the specified + * column with the charId property. Outcome is currently unspecified if there are + * multiple matches. + * - Compare using lower case for now (because of Email). Could generalize in future. + * - All fields from a matching record are added to the variables available for MatchSpecs. + */ +export interface GranularAccessCharacteristicsClause { + kind: 'character'; + tableId: string; + charId: string; // characteristic to look up + lookupColId: string; // column in which to look it up +} + +// Type for expressing matches. +export type MatchSpec = ConstMatchSpec | TruthyMatchSpec | PairMatchSpec | NotMatchSpec; + +// Invert a match. +export interface NotMatchSpec { + kind: 'not'; + match: MatchSpec; +} + +// Compare property of user with a constant. +export interface ConstMatchSpec { + kind: 'const'; + charId: string; + value: CellValue; +} + +// Check if a table column is truthy. +export interface TruthyMatchSpec { + kind: 'truthy'; + colId: string; +} + +// Check if a property of user matches a table column. +export interface PairMatchSpec { + kind: 'pair'; + charId: string; + colId: string; +} + +// Convert a clause to a string. Trivial, but fluid currently. +export function serializeClause(clause: GranularAccessClause) { + return '~acl ' + JSON.stringify(clause); +} + +export function decodeClause(code: string): GranularAccessClause|null { + // TODO: be strict about format. But it isn't super-clear what to do with + // a document if access control gets corrupted. Maybe go into an emergency + // mode where only owners have access, and they have unrestricted access? + // Also, format should be plain JSON once no longer stored in a random + // reused column. + if (code.startsWith('~acl ')) { + return safeJsonParse(code.slice(5), null); + } + return null; } diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 09ba4564..cad3bcbd 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -12,6 +12,8 @@ import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; import {addCurrentOrgToPath} from 'app/common/urlUtils'; +export {FullUser} from 'app/common/LoginSessionAPI'; + // Nominal email address of the anonymous user. export const ANONYMOUS_USER_EMAIL = 'anon@getgrist.com'; diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index ca8eafa9..c82ec82e 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -350,7 +350,8 @@ export class HomeDBManager extends EventEmitter { } public getUserByKey(apiKey: string): Promise { - return User.findOne({apiKey}); + // Include logins relation for Authorization convenience. + return User.findOne({apiKey}, {relations: ["logins"]}); } public getUser(userId: number): Promise { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index d1dd7aa5..0b01c88b 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -385,7 +385,10 @@ export class ActiveDoc extends EventEmitter { this._onDemandActions = new OnDemandActions(this.docStorage, this.docData); await this._actionHistory.initialize(); - this._granularAccess = new GranularAccess(this.docData); + this._granularAccess = new GranularAccess(this.docData, (query) => { + return this.fetchQuery(makeExceptionalDocSession('system'), query, true) + }); + await this._granularAccess.update(); this._sharing = new Sharing(this, this._actionHistory); await this.openSharedDoc(docSession); @@ -747,7 +750,8 @@ export class ActiveDoc extends EventEmitter { localActionBundle.stored.forEach(da => docData.receiveAction(da[1])); localActionBundle.calc.forEach(da => docData.receiveAction(da[1])); const docActions = getEnvContent(localActionBundle.stored); - this._granularAccess.update(); + // TODO: call this update less indiscriminately! + await this._granularAccess.update(); if (docActions.some(docAction => this._onDemandActions.isSchemaAction(docAction))) { const indexes = this._onDemandActions.getDesiredIndexes(); await this.docStorage.updateIndexes(indexes); diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 01edd41c..e59bae55 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -1,7 +1,7 @@ import {ApiError} from 'app/common/ApiError'; import {OpenDocMode} from 'app/common/DocListAPI'; import {ErrorWithCode} from 'app/common/ErrorWithCode'; -import {UserProfile} from 'app/common/LoginSessionAPI'; +import {FullUser, UserProfile} from 'app/common/LoginSessionAPI'; import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles'; import {Document} from 'app/gen-server/entity/Document'; import {User} from 'app/gen-server/entity/User'; @@ -255,7 +255,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer } log.debug("Auth[%s]: id %s email %s host %s path %s org %s%s", mreq.method, - mreq.userId, profile && profile.email, mreq.get('host'), mreq.path, mreq.org, + mreq.userId, mreq.user?.loginEmail, mreq.get('host'), mreq.path, mreq.org, customHostSession); return next(); @@ -357,6 +357,9 @@ export interface Authorizer { // get the id of user, or null if no authorization in place. getUserId(): number|null; + // get user profile if available. + getUser(): FullUser|null; + // get the id of the document. getDocId(): string; @@ -382,7 +385,8 @@ export class DocAuthorizer implements Authorizer { private _dbManager: HomeDBManager, private _key: DocAuthKey, public readonly openMode: OpenDocMode, - private _docAuth?: DocAuthResult + private _docAuth?: DocAuthResult, + private _profile?: UserProfile ) { } @@ -390,6 +394,10 @@ export class DocAuthorizer implements Authorizer { return this._key.userId; } + public getUser(): FullUser|null { + return this._profile ? {id: this.getUserId(), ...this._profile} : null; + } + public getDocId(): string { // We've been careful to require urlId === docId, see DocManager. return this._key.urlId; @@ -414,6 +422,7 @@ export class DocAuthorizer implements Authorizer { export class DummyAuthorizer implements Authorizer { constructor(public role: Role|null, public docId: string) {} public getUserId() { return null; } + public getUser() { return null; } public getDocId() { return this.docId; } public async getDoc(): Promise { throw new Error("Not supported in standalone"); } public async assertAccess() { /* noop */ } diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 0b19bd8c..11fc14a6 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -266,7 +266,7 @@ 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, docAuth); + auth = new DocAuthorizer(dbManager, key, mode, docAuth, client.getProfile() || undefined); } else { log.debug(`DocManager.openDoc not using authorization for ${docId} because GRIST_SINGLE_USER`); auth = new DummyAuthorizer('owners', docId); diff --git a/app/server/lib/DocSession.ts b/app/server/lib/DocSession.ts index d66743bd..24b86309 100644 --- a/app/server/lib/DocSession.ts +++ b/app/server/lib/DocSession.ts @@ -1,7 +1,8 @@ import {BrowserSettings} from 'app/common/BrowserSettings'; import {Role} from 'app/common/roles'; +import { FullUser } from 'app/common/UserAPI'; import {ActiveDoc} from 'app/server/lib/ActiveDoc'; -import {Authorizer, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; +import {Authorizer, getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {Client} from 'app/server/lib/Client'; /** @@ -94,6 +95,33 @@ export function getDocSessionUserId(docSession: OptDocSession): number|null { return null; } +/** + * Get as much of user profile as we can (id, name, email). + */ +export function getDocSessionUser(docSession: OptDocSession): FullUser|null { + if (docSession.authorizer) { + return docSession.authorizer.getUser(); + } + if (docSession.req) { + const user = getUser(docSession.req); + const email = user.loginEmail; + if (email) { + return {id: user.id, name: user.name, email}; + } + } + if (docSession.client) { + const id = docSession.client.getCachedUserId(); + const profile = docSession.client.getProfile(); + if (id && profile) { + return { + id, + ...profile + }; + } + } + 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 diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index fc90073e..8e4cefc7 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -4,11 +4,11 @@ import { Query } from 'app/common/ActiveDocAPI'; import { BulkColValues, DocAction, TableDataAction, UserAction, CellValue } from 'app/common/DocActions'; import { DocData } from 'app/common/DocData'; import { ErrorWithCode } from 'app/common/ErrorWithCode'; -import { GranularAccessClause } from 'app/common/GranularAccessClause'; +import { decodeClause, GranularAccessCharacteristicsClause, GranularAccessClause, MatchSpec } from 'app/common/GranularAccessClause'; import { canView } from 'app/common/roles'; import { TableData } from 'app/common/TableData'; import { Permissions } from 'app/gen-server/lib/Permissions'; -import { getDocSessionAccess, OptDocSession } from 'app/server/lib/DocSession'; +import { getDocSessionAccess, getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession'; import pullAt = require('lodash/pullAt'); // Actions that may be allowed for a user with nuanced access to a document, depending @@ -54,52 +54,33 @@ const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']); * * Manage granular access to a document. This allows nuances other than the coarse * owners/editors/viewers distinctions. As a placeholder for a future representation, - * nuances are stored in the _grist_ACLResources table. Supported nauances: - * - * - {tableId, colIds: '~o'}: mark specified table as accessible by owners only. - * - {tableId: '', colIds: '~o structure'}: mark doc structure as editable by owners only. - * - {tableId, colIds: '~o row '}: mark specified table as editable only by - * owner, and rows with falsy as accessible only by owner. + * nuances are stored in the _grist_ACLResources table. * */ export class GranularAccess { private _resources: TableData; private _clauses = new Array(); + // Cache any tables that we need to look-up for access control decisions. + // This is an unoptimized implementation that is adequate if the tables + // are not large and don't change all that often. + private _characteristicTables = new Map(); - public constructor(private _docData: DocData) { - this.update(); + public constructor(private _docData: DocData, private _fetchQuery: (query: Query) => Promise) { } /** * Update granular access from DocData. */ - public update() { + public async update() { this._resources = this._docData.getTable('_grist_ACLResources')!; this._clauses.length = 0; for (const res of this._resources.getRecords()) { - const code = String(res.colIds); - if (res.tableId && code === '~o') { - this._clauses.push({ - kind: 'table', - tableId: String(res.tableId), - rule: 'only-owner-can-access', - }); - } - if (!res.tableId && code === '~o structure') { - this._clauses.push({ - kind: 'doc', - rule: 'only-owner-can-modify-structure', - }); - } - if (res.tableId && code.startsWith('~o row ')) { - const colId = code.split(' ')[2] || 'RowAccess'; - this._clauses.push({ - kind: 'row', - tableId: String(res.tableId), - colId, - rule: 'only-owner-can-edit-table-and-access-all-rows' - }); - } + const clause = decodeClause(String(res.colIds)); + if (clause) { this._clauses.push(clause); } + } + if (this._clauses.length > 0) { + // TODO: optimize this. + await this._updateCharacteristicTables(); } } @@ -334,31 +315,58 @@ export class GranularAccess { */ public getTableAccess(docSession: OptDocSession, tableId: string): TableAccess { const access = getDocSessionAccess(docSession); - const isOwner = access === 'owners'; + const characteristics: {[key: string]: CellValue} = {}; + const user = getDocSessionUser(docSession); + characteristics.Access = access; + characteristics.UserID = user?.id || null; + characteristics.Email = user?.email || null; + characteristics.Name = user?.name || null; + // Light wrapper around characteristics. + const ch: InfoView = { + get(key: string) { return characteristics[key]; }, + toJSON() { return characteristics; } + }; const tableAccess: TableAccess = { permission: 0, rowPermissionFunctions: [] }; let canChangeSchema: boolean = true; let canView: boolean = true; - for (const clause of this._clauses) { - if (clause.kind === 'doc' && clause.rule === 'only-owner-can-modify-structure') { - const match = isOwner; - if (!match) { - canChangeSchema = false; + // Don't apply access control to system requests (important to load characteristic + // tables). + if (docSession.mode !== 'system') { + for (const clause of this._clauses) { + if (clause.kind === 'doc') { + const match = getMatchFunc(clause.match); + if (!match({ ch })) { + canChangeSchema = false; + } } - } - if (clause.kind === 'table' && clause.tableId === tableId && - clause.rule === 'only-owner-can-access') { - const match = isOwner; - if (!match) { - canView = false; + if (clause.kind === 'table' && clause.tableId === tableId) { + const match = getMatchFunc(clause.match); + if (!match({ ch })) { + canView = false; + } } - } - if (clause.kind === 'row' && clause.tableId === tableId && - clause.rule === 'only-owner-can-edit-table-and-access-all-rows') { - const match = isOwner; - if (!match) { - tableAccess.rowPermissionFunctions.push((rec) => { - return rec.get(clause.colId) ? Permissions.OWNER : 0; - }); + if (clause.kind === 'row' && clause.tableId === tableId) { + const scope = clause.scope ? getMatchFunc(clause.scope) : () => true; + if (scope({ ch })) { + const match = getMatchFunc(clause.match); + tableAccess.rowPermissionFunctions.push((rec) => { + return match({ ch, rec }) ? Permissions.OWNER : 0; + }); + } + } + if (clause.kind === 'character') { + const key = this._getCharacteristicTableKey(clause); + const characteristicTable = this._characteristicTables.get(key); + if (characteristicTable) { + const character = this._normalizeValue(characteristics[clause.charId]); + const rowNum = characteristicTable.rowNums.get(character); + if (rowNum !== undefined) { + const rec = new RecordView(characteristicTable.data, rowNum); + for (const key of Object.keys(characteristicTable.data[3])) { + characteristics[key] = rec.get(key); + } + } + } } } } @@ -418,6 +426,50 @@ export class GranularAccess { if (ids.has(availableIds[idx])) { op(idx, cols); } } } + + /** + * When comparing user characteristics, we lowercase for the sake of email comparison. + * This is a bit weak. + */ + private _normalizeValue(value: CellValue): string { + return JSON.stringify(value).toLowerCase(); + } + + /** + * Load any tables needed for look-ups. + */ + private async _updateCharacteristicTables() { + this._characteristicTables.clear(); + for (const clause of this._clauses) { + if (clause.kind === 'character') { + this._updateCharacteristicTable(clause); + } + } + } + + /** + * Load a table needed for look-up. + */ + private async _updateCharacteristicTable(clause: GranularAccessCharacteristicsClause) { + const key = this._getCharacteristicTableKey(clause); + const data = await this._fetchQuery({tableId: clause.tableId, filters: {}}); + const rowNums = new Map(); + const matches = data[3][clause.lookupColId]; + for (let i = 0; i < matches.length; i++) { + rowNums.set(this._normalizeValue(matches[i]), i); + } + const result: CharacteristicTable = { + tableId: clause.tableId, + colId: clause.lookupColId, + rowNums, + data + } + this._characteristicTables.set(key, result); + } + + private _getCharacteristicTableKey(clause: GranularAccessCharacteristicsClause): string { + return JSON.stringify({ tableId: clause.tableId, colId: clause.lookupColId }); + } } // A function that computes permissions given a record. @@ -429,8 +481,14 @@ export interface TableAccess { rowPermissionFunctions: Array; } +// Light wrapper around characteristics or records. +export interface InfoView { + get(key: string): CellValue; + toJSON(): {[key: string]: any}; +} + // A row-like view of TableDataAction, which is columnar in nature. -export class RecordView { +export class RecordView implements InfoView { public constructor(public data: TableDataAction, public index: number) { } @@ -440,4 +498,45 @@ export class RecordView { } return this.data[3][colId][this.index]; } + + public toJSON() { + const results: {[key: string]: any} = {}; + for (const key of Object.keys(this.data[3])) { + results[key] = this.data[3][key][this.index]; + } + return results; + } +} + +// A function for matching characteristic and/or record information. +export type MatchFunc = (state: { ch?: InfoView, rec?: InfoView }) => boolean; + +// Convert a match specification to a function. +export function getMatchFunc(spec: MatchSpec): MatchFunc { + switch (spec.kind) { + case 'not': + { + const core = getMatchFunc(spec.match); + return (state) => !core(state); + } + case 'const': + return (state) => state.ch?.get(spec.charId) === spec.value; + case 'truthy': + return (state) => Boolean(state.rec?.get(spec.colId)); + case 'pair': + return (state) => state.ch?.get(spec.charId) === state.rec?.get(spec.colId); + default: + throw new Error('match spec not understood'); + } +} + +/** + * A cache of a table needed for look-ups, including a map from keys to + * row numbers. Keys are produced by _getCharacteristicTableKey(). + */ +export interface CharacteristicTable { + tableId: string; + colId: string; + rowNums: Map; + data: TableDataAction; }