(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
pull/6/head
Paul Fitzpatrick 4 years ago
parent 99ab09651e
commit a4929bde72

@ -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…
Cancel
Save