mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) add some row-level access control
Summary: This implements a form of row-level access control where for a given table, you may specify that only owners have access to rows for which a given column has falsy values. For simplicity: * Only owners may edit that table. * Non-owners with the document open will have forced reloads whenever the table is modified. Baby steps... Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2633
This commit is contained in:
parent
99ab09651e
commit
a4929bde72
37
app/common/GranularAccessClause.ts
Normal file
37
app/common/GranularAccessClause.ts
Normal file
@ -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';
|
||||
}
|
@ -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!;
|
||||
|
@ -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) {
|
||||
|
@ -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 <colId>'}: mark specified table as editable only by
|
||||
* owner, and rows with <colId> falsy as accessible only by owner.
|
||||
*
|
||||
*/
|
||||
export class GranularAccess {
|
||||
private _resources: TableData;
|
||||
|
||||
// Tables marked as accessible only by owners.
|
||||
private _ownerOnlyTableIds = new Set<string>();
|
||||
|
||||
// Document structure modifiable only by owners?
|
||||
private _onlyOwnersCanModifyStructure: boolean = false;
|
||||
private _clauses = new Array<GranularAccessClause>();
|
||||
|
||||
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<number> = 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<string> {
|
||||
const tables = new Set<string>();
|
||||
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<number>,
|
||||
op: (idx: number, cols: BulkColValues) => unknown) {
|
||||
private _censor(table: TableDataAction, ids: Set<number>,
|
||||
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<PermissionFunction>;
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user