(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
This commit is contained in:
Paul Fitzpatrick 2020-10-19 10:25:21 -04:00
parent 27fd894fc7
commit c879393a8e
9 changed files with 308 additions and 83 deletions

View File

@ -8,6 +8,7 @@ import {primaryButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars'; import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem, select} from 'app/client/ui2018/menus'; 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 {arrayRepeat, setDifference} from 'app/common/gutil';
import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual'); import isEqual = require('lodash/isEqual');
@ -23,12 +24,15 @@ function buildAclState(gristDoc: GristDoc): AclState {
const tableData = gristDoc.docModel.aclResources.tableData; const tableData = gristDoc.docModel.aclResources.tableData;
for (const res of tableData.getRecords()) { for (const res of tableData.getRecords()) {
const code = String(res.colIds); const code = String(res.colIds);
if (res.tableId && code === '~o') { const clause = decodeClause(code);
ownerOnlyTableIds.add(String(res.tableId)); if (clause) {
} if (clause.kind === 'doc') {
if (!res.tableId && code === '~o structure') {
ownerOnlyStructure = true; ownerOnlyStructure = true;
} }
if (clause.kind === 'table' && clause.tableId) {
ownerOnlyTableIds.add(clause.tableId);
}
}
} }
return {ownerOnlyTableIds, ownerOnlyStructure}; return {ownerOnlyTableIds, ownerOnlyStructure};
} }
@ -63,10 +67,15 @@ export class AccessRules extends Disposable {
await tableData.docData.bundleActions('Update Access Rules', async () => { await tableData.docData.bundleActions('Update Access Rules', async () => {
// If ownerOnlyStructure flag changed, add or remove the relevant resource record. // If ownerOnlyStructure flag changed, add or remove the relevant resource record.
if (currentState.ownerOnlyStructure !== latestState.ownerOnlyStructure) { if (currentState.ownerOnlyStructure !== latestState.ownerOnlyStructure) {
const clause: GranularAccessDocClause = {
kind: 'doc',
match: { kind: 'const', charId: 'Access', value: 'owners' },
};
const colIds = serializeClause(clause);
if (currentState.ownerOnlyStructure) { if (currentState.ownerOnlyStructure) {
await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds: "~o structure"}]); await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds}]);
} else { } else {
const rowId = tableData.findMatchingRowId({tableId: '', colIds: '~o structure'}); const rowId = tableData.findMatchingRowId({tableId: '', colIds});
if (rowId) { if (rowId) {
await this._gristDoc.docModel.aclResources.sendTableAction(['RemoveRecord', rowId]); await this._gristDoc.docModel.aclResources.sendTableAction(['RemoveRecord', rowId]);
} }
@ -78,11 +87,15 @@ export class AccessRules extends Disposable {
if (tablesAdded.size) { if (tablesAdded.size) {
await tableData.sendTableAction(['BulkAddRecord', arrayRepeat(tablesAdded.size, null), { await tableData.sendTableAction(['BulkAddRecord', arrayRepeat(tablesAdded.size, null), {
tableId: [...tablesAdded], 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); const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds);
if (tablesRemoved.size) { if (tablesRemoved.size) {
const rowIds = Array.from(tablesRemoved, t => tableData.findRow('tableId', t)).filter(r => r); const rowIds = Array.from(tablesRemoved, t => tableData.findRow('tableId', t)).filter(r => r);

View File

@ -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. * All possible access clauses. In future the clauses will become more generalized.
* In future the clauses will become more generalized, and start specifying * The consequences of clauses are currently combined in a naive and ad-hoc way,
* the principle / properties of the user to which they apply. * this will need systematizing.
*/ */
export type GranularAccessClause = export type GranularAccessClause =
GranularAccessDocClause | GranularAccessDocClause |
GranularAccessTableClause | GranularAccessTableClause |
GranularAccessRowClause; GranularAccessRowClause |
GranularAccessCharacteristicsClause;
/** /**
* A clause that forbids anyone but owners from modifying the document structure. * A clause that forbids anyone but owners from modifying the document structure.
*/ */
export interface GranularAccessDocClause { export interface GranularAccessDocClause {
kind: 'doc'; 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 { export interface GranularAccessTableClause {
kind: 'table'; kind: 'table';
tableId: string; tableId: string;
rule: 'only-owner-can-access'; match: MatchSpec;
} }
/** /**
* A clause that forbids anyone but owners from editing a particular table * A clause to control access to rows within a specific table.
* or viewing rows for which the named column contains a falsy value. * If "scope" is provided, this rule is simply ignored if the scope does not match
* the user.
*/ */
export interface GranularAccessRowClause { export interface GranularAccessRowClause {
kind: 'row'; kind: 'row';
tableId: string; tableId: string;
colId: string; match: MatchSpec;
rule: 'only-owner-can-edit-table-and-access-all-rows'; 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;
} }

View File

@ -12,6 +12,8 @@ import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {addCurrentOrgToPath} from 'app/common/urlUtils'; import {addCurrentOrgToPath} from 'app/common/urlUtils';
export {FullUser} from 'app/common/LoginSessionAPI';
// Nominal email address of the anonymous user. // Nominal email address of the anonymous user.
export const ANONYMOUS_USER_EMAIL = 'anon@getgrist.com'; export const ANONYMOUS_USER_EMAIL = 'anon@getgrist.com';

View File

@ -350,7 +350,8 @@ export class HomeDBManager extends EventEmitter {
} }
public getUserByKey(apiKey: string): Promise<User|undefined> { 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> { public getUser(userId: number): Promise<User|undefined> {

View File

@ -385,7 +385,10 @@ export class ActiveDoc extends EventEmitter {
this._onDemandActions = new OnDemandActions(this.docStorage, this.docData); this._onDemandActions = new OnDemandActions(this.docStorage, this.docData);
await this._actionHistory.initialize(); 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); this._sharing = new Sharing(this, this._actionHistory);
await this.openSharedDoc(docSession); await this.openSharedDoc(docSession);
@ -747,7 +750,8 @@ export class ActiveDoc extends EventEmitter {
localActionBundle.stored.forEach(da => docData.receiveAction(da[1])); localActionBundle.stored.forEach(da => docData.receiveAction(da[1]));
localActionBundle.calc.forEach(da => docData.receiveAction(da[1])); localActionBundle.calc.forEach(da => docData.receiveAction(da[1]));
const docActions = getEnvContent(localActionBundle.stored); 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))) { if (docActions.some(docAction => this._onDemandActions.isSchemaAction(docAction))) {
const indexes = this._onDemandActions.getDesiredIndexes(); const indexes = this._onDemandActions.getDesiredIndexes();
await this.docStorage.updateIndexes(indexes); await this.docStorage.updateIndexes(indexes);

View File

@ -1,7 +1,7 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {OpenDocMode} from 'app/common/DocListAPI'; import {OpenDocMode} from 'app/common/DocListAPI';
import {ErrorWithCode} from 'app/common/ErrorWithCode'; 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 {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
import {Document} from 'app/gen-server/entity/Document'; import {Document} from 'app/gen-server/entity/Document';
import {User} from 'app/gen-server/entity/User'; 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, 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); customHostSession);
return next(); return next();
@ -357,6 +357,9 @@ export interface Authorizer {
// get the id of user, or null if no authorization in place. // get the id of user, or null if no authorization in place.
getUserId(): number|null; getUserId(): number|null;
// get user profile if available.
getUser(): FullUser|null;
// get the id of the document. // get the id of the document.
getDocId(): string; getDocId(): string;
@ -382,7 +385,8 @@ export class DocAuthorizer implements Authorizer {
private _dbManager: HomeDBManager, private _dbManager: HomeDBManager,
private _key: DocAuthKey, private _key: DocAuthKey,
public readonly openMode: OpenDocMode, 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; return this._key.userId;
} }
public getUser(): FullUser|null {
return this._profile ? {id: this.getUserId(), ...this._profile} : null;
}
public getDocId(): string { public getDocId(): string {
// We've been careful to require urlId === docId, see DocManager. // We've been careful to require urlId === docId, see DocManager.
return this._key.urlId; return this._key.urlId;
@ -414,6 +422,7 @@ export class DocAuthorizer implements Authorizer {
export class DummyAuthorizer implements Authorizer { export class DummyAuthorizer implements Authorizer {
constructor(public role: Role|null, public docId: string) {} constructor(public role: Role|null, public docId: string) {}
public getUserId() { return null; } public getUserId() { return null; }
public getUser() { return null; }
public getDocId() { return this.docId; } public getDocId() { return this.docId; }
public async getDoc(): Promise<Document> { throw new Error("Not supported in standalone"); } public async getDoc(): Promise<Document> { throw new Error("Not supported in standalone"); }
public async assertAccess() { /* noop */ } public async assertAccess() { /* noop */ }

View File

@ -266,7 +266,7 @@ export class DocManager extends EventEmitter {
// than a docId. // than a docId.
throw new Error(`openDoc expected docId ${docAuth.docId} not urlId ${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 { } else {
log.debug(`DocManager.openDoc not using authorization for ${docId} because GRIST_SINGLE_USER`); log.debug(`DocManager.openDoc not using authorization for ${docId} because GRIST_SINGLE_USER`);
auth = new DummyAuthorizer('owners', docId); auth = new DummyAuthorizer('owners', docId);

View File

@ -1,7 +1,8 @@
import {BrowserSettings} from 'app/common/BrowserSettings'; import {BrowserSettings} from 'app/common/BrowserSettings';
import {Role} from 'app/common/roles'; import {Role} from 'app/common/roles';
import { FullUser } from 'app/common/UserAPI';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; 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'; import {Client} from 'app/server/lib/Client';
/** /**
@ -94,6 +95,33 @@ export function getDocSessionUserId(docSession: OptDocSession): number|null {
return 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 * 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 * sockets or rest api. Assumes that access has already been checked by wrappers

View File

@ -4,11 +4,11 @@ import { Query } from 'app/common/ActiveDocAPI';
import { BulkColValues, DocAction, TableDataAction, UserAction, CellValue } from 'app/common/DocActions'; import { BulkColValues, DocAction, TableDataAction, UserAction, CellValue } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData'; import { DocData } from 'app/common/DocData';
import { ErrorWithCode } from 'app/common/ErrorWithCode'; 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 { canView } from 'app/common/roles';
import { TableData } from 'app/common/TableData'; import { TableData } from 'app/common/TableData';
import { Permissions } from 'app/gen-server/lib/Permissions'; 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'); import pullAt = require('lodash/pullAt');
// Actions that may be allowed for a user with nuanced access to a document, depending // 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 * Manage granular access to a document. This allows nuances other than the coarse
* owners/editors/viewers distinctions. As a placeholder for a future representation, * owners/editors/viewers distinctions. As a placeholder for a future representation,
* nuances are stored in the _grist_ACLResources table. Supported nauances: * nuances are stored in the _grist_ACLResources table.
*
* - {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 { export class GranularAccess {
private _resources: TableData; private _resources: TableData;
private _clauses = new Array<GranularAccessClause>(); 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) { public constructor(private _docData: DocData, private _fetchQuery: (query: Query) => Promise<TableDataAction>) {
this.update();
} }
/** /**
* Update granular access from DocData. * Update granular access from DocData.
*/ */
public update() { public async update() {
this._resources = this._docData.getTable('_grist_ACLResources')!; this._resources = this._docData.getTable('_grist_ACLResources')!;
this._clauses.length = 0; this._clauses.length = 0;
for (const res of this._resources.getRecords()) { for (const res of this._resources.getRecords()) {
const code = String(res.colIds); const clause = decodeClause(String(res.colIds));
if (res.tableId && code === '~o') { if (clause) { this._clauses.push(clause); }
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'
});
} }
if (this._clauses.length > 0) {
// TODO: optimize this.
await this._updateCharacteristicTables();
} }
} }
@ -334,33 +315,60 @@ export class GranularAccess {
*/ */
public getTableAccess(docSession: OptDocSession, tableId: string): TableAccess { public getTableAccess(docSession: OptDocSession, tableId: string): TableAccess {
const access = getDocSessionAccess(docSession); 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: [] }; const tableAccess: TableAccess = { permission: 0, rowPermissionFunctions: [] };
let canChangeSchema: boolean = true; let canChangeSchema: boolean = true;
let canView: boolean = true; let canView: boolean = true;
// Don't apply access control to system requests (important to load characteristic
// tables).
if (docSession.mode !== 'system') {
for (const clause of this._clauses) { for (const clause of this._clauses) {
if (clause.kind === 'doc' && clause.rule === 'only-owner-can-modify-structure') { if (clause.kind === 'doc') {
const match = isOwner; const match = getMatchFunc(clause.match);
if (!match) { if (!match({ ch })) {
canChangeSchema = false; canChangeSchema = false;
} }
} }
if (clause.kind === 'table' && clause.tableId === tableId && if (clause.kind === 'table' && clause.tableId === tableId) {
clause.rule === 'only-owner-can-access') { const match = getMatchFunc(clause.match);
const match = isOwner; if (!match({ ch })) {
if (!match) {
canView = false; canView = false;
} }
} }
if (clause.kind === 'row' && clause.tableId === tableId && if (clause.kind === 'row' && clause.tableId === tableId) {
clause.rule === 'only-owner-can-edit-table-and-access-all-rows') { const scope = clause.scope ? getMatchFunc(clause.scope) : () => true;
const match = isOwner; if (scope({ ch })) {
if (!match) { const match = getMatchFunc(clause.match);
tableAccess.rowPermissionFunctions.push((rec) => { tableAccess.rowPermissionFunctions.push((rec) => {
return rec.get(clause.colId) ? Permissions.OWNER : 0; 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);
}
}
}
}
}
} }
tableAccess.permission = canView ? Permissions.OWNER : 0; tableAccess.permission = canView ? Permissions.OWNER : 0;
if (!canChangeSchema) { if (!canChangeSchema) {
@ -418,6 +426,50 @@ export class GranularAccess {
if (ids.has(availableIds[idx])) { op(idx, cols); } 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. // A function that computes permissions given a record.
@ -429,8 +481,14 @@ export interface TableAccess {
rowPermissionFunctions: Array<PermissionFunction>; 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. // 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) { public constructor(public data: TableDataAction, public index: number) {
} }
@ -440,4 +498,45 @@ export class RecordView {
} }
return this.data[3][colId][this.index]; 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;
} }