(core) support adding user characteristic tables for granular ACLs

Summary:
This is a prototype for expanding the conditions that can be used in granular ACLs.

When processing ACLs, the following variables (called "characteristics") are now available in conditions:
 * UserID
 * Email
 * Name
 * Access (owners, editors, viewers)

The set of variables can be expanded by adding a "characteristic" clause.  This is a clause which specifies:
 * A tableId
 * The name of an existing characteristic
 * A colId
The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column.

Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff.

Issues I'm not dealing with here:
 * How clauses combine.  (The scope on GranularAccessRowClause is a hack to save me worrying about that yet).
 * The full set of matching methods we'll allow.
 * Refreshing row access in clients when the tables mentioned in characteristic tables change.
 * Full CRUD permission control.
 * Default rules (part of combination).
 * Reporting errors in access rules.

That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control.

Test Plan: added tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2642
pull/6/head
Paul Fitzpatrick 4 years ago
parent 27fd894fc7
commit c879393a8e

@ -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);

@ -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;
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;
rule: 'only-owner-can-edit-table-and-access-all-rows';
}
// 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;
}

@ -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';

@ -350,7 +350,8 @@ export class HomeDBManager extends EventEmitter {
}
public getUserByKey(apiKey: string): Promise<User|undefined> {
return User.findOne({apiKey});
// Include logins relation for Authorization convenience.
return User.findOne({apiKey}, {relations: ["logins"]});
}
public getUser(userId: number): Promise<User|undefined> {

@ -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);

@ -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<Document> { throw new Error("Not supported in standalone"); }
public async assertAccess() { /* noop */ }

@ -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);

@ -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

@ -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 <colId>'}: mark specified table as editable only by
* owner, and rows with <colId> falsy as accessible only by owner.
* nuances are stored in the _grist_ACLResources table.
*
*/
export class GranularAccess {
private _resources: TableData;
private _clauses = new Array<GranularAccessClause>();
// 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<string, CharacteristicTable>();
public constructor(private _docData: DocData) {
this.update();
public constructor(private _docData: DocData, private _fetchQuery: (query: Query) => Promise<TableDataAction>) {
}
/**
* 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<string, number>();
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<PermissionFunction>;
}
// 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<string, number>;
data: TableDataAction;
}

Loading…
Cancel
Save