diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts new file mode 100644 index 00000000..0499aeea --- /dev/null +++ b/app/common/GranularAccessClause.ts @@ -0,0 +1,37 @@ +/** + * 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. + */ +export type GranularAccessClause = + GranularAccessDocClause | + GranularAccessTableClause | + GranularAccessRowClause; + +/** + * A clause that forbids anyone but owners from modifying the document structure. + */ +export interface GranularAccessDocClause { + kind: 'doc'; + rule: 'only-owner-can-modify-structure'; +} + +/** + * A clause that forbids anyone but owners from accessing a particular table. + */ +export interface GranularAccessTableClause { + kind: 'table'; + tableId: string; + rule: 'only-owner-can-access'; +} + +/** + * A clause that forbids anyone but owners from editing a particular table + * or viewing rows for which the named column contains a falsy value. + */ +export interface GranularAccessRowClause { + kind: 'row'; + tableId: string; + colId: string; + rule: 'only-owner-can-edit-table-and-access-all-rows'; +} diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 69d365d7..d1dd7aa5 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -33,6 +33,7 @@ import * as marshal from 'app/common/marshal'; import {Peer} from 'app/common/sharing'; import {UploadResult} from 'app/common/uploads'; import {DocReplacementOptions, DocState} from 'app/common/UserAPI'; +import {Permissions} from 'app/gen-server/lib/Permissions'; import {ParseOptions} from 'app/plugin/FileParserAPI'; import {GristDocAPI} from 'app/plugin/GristAPI'; import {Authorizer} from 'app/server/lib/Authorizer'; @@ -564,14 +565,18 @@ export class ActiveDoc extends EventEmitter { this._inactivityTimer.ping(); // The doc is in active use; ping it to stay open longer. // If user does not have rights to access what this query is asking for, fail. - if (!this._granularAccess.hasQueryAccess(docSession, query)) { + const tableAccess = this._granularAccess.getTableAccess(docSession, query.tableId); + if (!(tableAccess.permission & Permissions.VIEW)) { throw new Error('not authorized to read table'); } // Some tests read _grist_ tables via the api. The _fetchQueryFromDB method // currently cannot read those tables, so we load them from the data engine // when ready. - const wantFull = waitForFormulas || query.tableId.startsWith('_grist_'); + // Also, if row-level access is being controlled, we wait for formula columns + // to be populated. + const wantFull = waitForFormulas || query.tableId.startsWith('_grist_') || + tableAccess.rowPermissionFunctions.length > 0; const onDemand = this._onDemandActions.isOnDemand(query.tableId); this.logInfo(docSession, "fetchQuery(%s, %s) %s", docSession, JSON.stringify(query), onDemand ? "(onDemand)" : "(regular)"); @@ -591,6 +596,10 @@ export class ActiveDoc extends EventEmitter { data = await mapGetOrSet(this._fetchCache, key, () => this._fetchQueryFromDataEngine(query)); } } + // If row-level access is being controlled, filter the data appropriately. + if (tableAccess.rowPermissionFunctions.length > 0) { + this._granularAccess.filterData(data!, tableAccess); + } this.logInfo(docSession, "fetchQuery -> %d rows, cols: %s", data![2].length, Object.keys(data![3]).join(", ")); return data!; diff --git a/app/server/lib/DocClients.ts b/app/server/lib/DocClients.ts index dae6237b..6f2adaab 100644 --- a/app/server/lib/DocClients.ts +++ b/app/server/lib/DocClients.ts @@ -86,11 +86,19 @@ export class DocClients { if (!filterMessage) { sendDocMessage(curr.client, curr.fd, type, messageData, fromSelf); } else { - const filteredMessageData = filterMessage(curr, messageData); - if (filteredMessageData) { - sendDocMessage(curr.client, curr.fd, type, filteredMessageData, fromSelf); - } else { - this.activeDoc.logDebug(curr, 'skip broadcastDocMessage because it is not allowed for this client'); + try { + const filteredMessageData = filterMessage(curr, messageData); + if (filteredMessageData) { + sendDocMessage(curr.client, curr.fd, type, filteredMessageData, fromSelf); + } else { + this.activeDoc.logDebug(curr, 'skip broadcastDocMessage because it is not allowed for this client'); + } + } catch (e) { + if (e.code && e.code === 'NEED_RELOAD') { + sendDocMessage(curr.client, curr.fd, 'docShutdown', null, fromSelf); + } else { + throw e; + } } } } catch (e) { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index fa7b85fa..fc90073e 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -1,11 +1,15 @@ import { ActionGroup } from 'app/common/ActionGroup'; import { createEmptyActionSummary } from 'app/common/ActionSummary'; import { Query } from 'app/common/ActiveDocAPI'; -import { BulkColValues, DocAction, TableDataAction, UserAction } from 'app/common/DocActions'; +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 { 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 pullAt = require('lodash/pullAt'); // Actions that may be allowed for a user with nuanced access to a document, depending // on what table they refer to. @@ -54,16 +58,13 @@ const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']); * * - {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. * */ export class GranularAccess { private _resources: TableData; - - // Tables marked as accessible only by owners. - private _ownerOnlyTableIds = new Set(); - - // Document structure modifiable only by owners? - private _onlyOwnersCanModifyStructure: boolean = false; + private _clauses = new Array(); public constructor(private _docData: DocData) { this.update(); @@ -74,15 +75,30 @@ export class GranularAccess { */ public update() { this._resources = this._docData.getTable('_grist_ACLResources')!; - this._ownerOnlyTableIds.clear(); - this._onlyOwnersCanModifyStructure = false; + this._clauses.length = 0; for (const res of this._resources.getRecords()) { const code = String(res.colIds); if (res.tableId && code === '~o') { - this._ownerOnlyTableIds.add(String(res.tableId)); + this._clauses.push({ + kind: 'table', + tableId: String(res.tableId), + rule: 'only-owner-can-access', + }); } if (!res.tableId && code === '~o structure') { - this._onlyOwnersCanModifyStructure = true; + 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' + }); } } } @@ -98,7 +114,7 @@ export class GranularAccess { * Check whether user has access to table. */ public hasTableAccess(docSession: OptDocSession, tableId: string) { - return !this._ownerOnlyTableIds.has(tableId) || this.hasFullAccess(docSession); + return Boolean(this.getTableAccess(docSession, tableId).permission & Permissions.VIEW); } /** @@ -169,7 +185,20 @@ export class GranularAccess { if (tableId.startsWith('_grist_') && direction === 'in') { return !this.hasNuancedAccess(docSession); } - return this.hasTableAccess(docSession, tableId); + const tableAccess = this.getTableAccess(docSession, tableId); + // For now, if there are any row restrictions, forbid editing. + // To allow editing, we'll need something that has access to full row, + // e.g. data engine (and then an equivalent for ondemand tables), or + // to fetch rows at this point. + if (tableAccess.rowPermissionFunctions.length > 0) { + // If sending to client, for now just get it to reload from scratch, + // we don't have the information we need to filter updates. Reloads + // would be very annoying if user is working on something, but at least + // data won't be stale. TODO: improve! + if (direction === 'out') { throw new ErrorWithCode('NEED_RELOAD', 'document needs reload'); } + return false; + } + return Boolean(tableAccess.permission & Permissions.VIEW); } return false; } @@ -180,9 +209,7 @@ export class GranularAccess { * access is simple and without nuance. */ public hasNuancedAccess(docSession: OptDocSession): boolean { - if (this._ownerOnlyTableIds.size === 0 && !this._onlyOwnersCanModifyStructure) { - return false; - } + if (this._clauses.length === 0) { return false; } return !this.hasFullAccess(docSession); } @@ -190,8 +217,13 @@ export class GranularAccess { * Check whether user can read everything in document. */ public canReadEverything(docSession: OptDocSession): boolean { - if (this._ownerOnlyTableIds.size === 0) { return true; } - return this.hasFullAccess(docSession); + for (const tableId of this.getTablesInClauses()) { + const tableData = this.getTableAccess(docSession, tableId); + if (!(tableData.permission & Permissions.VIEW) || tableData.rowPermissionFunctions.length > 0) { + return false; + } + } + return true; } /** @@ -237,7 +269,7 @@ export class GranularAccess { tables = JSON.parse(JSON.stringify(tables)); // Collect a list of all tables (by tableRef) to which the user has no access. const censoredTables: Set = new Set(); - for (const tableId of this._ownerOnlyTableIds) { + for (const tableId of this.getTablesInClauses()) { if (this.hasTableAccess(docSession, tableId)) { continue; } const tableRef = this._docData.getTable('_grist_Tables')?.findRow('tableId', tableId); if (tableRef) { censoredTables.add(tableRef); } @@ -296,12 +328,90 @@ export class GranularAccess { return tables; } + /** + * Distill the clauses for the given session and table, to figure out the + * access level and any row-level access functions needed. + */ + public getTableAccess(docSession: OptDocSession, tableId: string): TableAccess { + const access = getDocSessionAccess(docSession); + const isOwner = access === 'owners'; + 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; + } + } + if (clause.kind === 'table' && clause.tableId === tableId && + clause.rule === 'only-owner-can-access') { + const match = isOwner; + if (!match) { + 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; + }); + } + } + } + tableAccess.permission = canView ? Permissions.OWNER : 0; + if (!canChangeSchema) { + tableAccess.permission = tableAccess.permission & ~Permissions.SCHEMA_EDIT; + } + return tableAccess; + } + + /** + * Get the set of all tables mentioned in access clauses. + */ + public getTablesInClauses(): Set { + const tables = new Set(); + for (const clause of this._clauses) { + if ('tableId' in clause) { tables.add(clause.tableId); } + } + return tables; + } + + /** + * Modify table data in place, removing any rows to which access + * is not granted. + */ + public filterData(data: TableDataAction, tableAccess: TableAccess) { + const toRemove: number[] = []; + const rec = new RecordView(data, 0); + for (let idx = 0; idx < data[2].length; idx++) { + rec.index = idx; + let permission = Permissions.OWNER; + for (const fn of tableAccess.rowPermissionFunctions) { + permission = permission & fn(rec); + } + if (!(permission & Permissions.VIEW)) { + toRemove.push(idx); + } + } + if (toRemove.length > 0) { + pullAt(data[2], toRemove); + const cols = data[3]; + for (const [, values] of Object.entries(cols)) { + pullAt(values, toRemove); + } + } + } + /** * Modify the given TableDataAction in place by calling the supplied operation with * the indexes of any ids supplied and the columns in that TableDataAction. */ - public _censor(table: TableDataAction, ids: Set, - op: (idx: number, cols: BulkColValues) => unknown) { + private _censor(table: TableDataAction, ids: Set, + op: (idx: number, cols: BulkColValues) => unknown) { const availableIds = table[2]; const cols = table[3]; for (let idx = 0; idx < availableIds.length; idx++) { @@ -309,3 +419,25 @@ export class GranularAccess { } } } + +// A function that computes permissions given a record. +export type PermissionFunction = (rec: RecordView) => number; + +// A summary of table-level access information. +export interface TableAccess { + permission: number; + rowPermissionFunctions: Array; +} + +// A row-like view of TableDataAction, which is columnar in nature. +export class RecordView { + public constructor(public data: TableDataAction, public index: number) { + } + + public get(colId: string): CellValue { + if (colId === 'id') { + return this.data[2][this.index]; + } + return this.data[3][colId][this.index]; + } +}