(core) Fix null references in form fields bug

Summary:
Shares and documents would both produce a rule set for the same column
if the document rule set was for multiple columns. In this case, it was causing
one of the rules to be overwritten by the other (specifically, the rule granting
access to form references was not being applied in shares). The symptom was
`null` values in place of the referenced table's values.

We address this by splitting any rule sets for multiple columns that are also
affected by shares, so that they can be overridden by shares without causing a
conflicting rule set to be created (i.e. 2 column rule sets containing the same column).

Test Plan: Server tests.

Reviewers: dsagal, paulfitz

Reviewed By: dsagal, paulfitz

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4208
pull/936/head
George Gevoian 1 month ago
parent 8c53585bd7
commit 0130409447

@ -1,14 +1,12 @@
import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions';
import {AVAILABLE_BITS_COLUMNS, AVAILABLE_BITS_TABLES, trimPermissions} from 'app/common/ACLPermissions';
import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules';
import {ACLRulesReader} from 'app/common/ACLRulesReader';
import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil';
import {ShareOptions} from 'app/common/ShareOptions';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy');
export type ILogger = Pick<Console, 'log'|'debug'|'info'|'warn'|'error'>;
@ -463,39 +461,16 @@ function getHelperCols(docData: DocData, tableId: string, colIds: string[], log:
* UserAttributeRules. This is used by both client-side code and server-side.
*/
function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults {
// Wrap resources and rules tables so we can have "virtual" rules
// to implement special shares.
const resourcesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLResources'));
const rulesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLRules'));
const sharesTable = docData.getMetaTable('_grist_Shares');
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
let hasShares: boolean = false;
const shares = sharesTable.getRecords();
// ACLShareRules is used to edit resourcesTable and rulesTable in place.
const shareRules = new ACLShareRules(docData, resourcesTable, rulesTable);
// Add virtual rules to implement shares, if there are any.
// Add the virtual rules only when implementing/interpreting them, as
// opposed to accessing them for presentation or manipulation in the UI.
if (enrichRulesForImplementation && shares.length > 0) {
for (const share of shares) {
const options: ShareOptions = JSON.parse(share.options || '{}');
shareRules.addRulesForShare(share.id, options);
}
shareRules.addDefaultRulesForShares();
hasShares = true;
}
const aclRulesReader = new ACLRulesReader(docData, {
addShareRules: enrichRulesForImplementation,
});
// Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) {
getSetMapValue(rulesByResource, ruleRecord.resource, () => []).push(ruleRecord);
}
for (const [resourceId, rules] of rulesByResource.entries()) {
const resourceRec = resourcesTable.getRecord(resourceId);
for (const [resourceId, rules] of aclRulesReader.entries()) {
const resourceRec = aclRulesReader.getResourceById(resourceId);
if (!resourceRec) {
throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`);
}
@ -531,13 +506,7 @@ function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementat
} else if (rule.aclFormula && !rule.aclFormulaParsed) {
throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);
} else {
let aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
// If we have "virtual" rules to implement shares, then regular
// rules need to be tweaked so that they don't apply when the
// share is active.
if (hasShares && rule.id >= 0) {
aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed});
}
const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
let permissions = parsePermissions(String(rule.permissionsText));
if (tableId !== '*' && tableId !== SPECIAL_RULES_TABLE_ID) {
const availableBits = (colIds === '*') ? AVAILABLE_BITS_TABLES : AVAILABLE_BITS_COLUMNS;

@ -0,0 +1,454 @@
import { DocData } from 'app/common/DocData';
import { getSetMapValue } from 'app/common/gutil';
import { SchemaTypes } from 'app/common/schema';
import { ShareOptions } from 'app/common/ShareOptions';
import { MetaRowRecord, MetaTableData } from 'app/common/TableData';
import { isEqual, sortBy } from 'lodash';
/**
* For special shares, we need to refer to resources that may not
* be listed in the _grist_ACLResources table, and have rules that
* aren't backed by storage in _grist_ACLRules. So we implement
* a small helper to add an overlay of extra resources and rules.
* They are distinguishable from real, stored resources and rules
* by having negative IDs.
*/
export class TableWithOverlay<T extends keyof SchemaTypes> {
private _extraRecords = new Array<MetaRowRecord<T>>();
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
private _excludedRecordIds = new Set<number>();
private _nextFreeVirtualId: number = -1;
public constructor(private _originalTable: MetaTableData<T>) {}
// Add a record to the table, but only as an overlay - no
// persistent changes are made. Uses negative row IDs.
// Returns the ID assigned to the record. The passed in
// record is expected to have an ID of zero.
public addRecord(rec: MetaRowRecord<T>): number {
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
const id = this._nextFreeVirtualId;
const recWithCorrectId: MetaRowRecord<T> = {...rec, id};
this._extraRecords.push({...rec, id});
this._extraRecordsById.set(id, recWithCorrectId);
this._nextFreeVirtualId--;
return id;
}
public excludeRecord(id: number) {
this._excludedRecordIds.add(id);
}
// Support the few MetaTableData methods we actually use
// in ACLRulesReader.
public getRecord(id: number) {
if (this._excludedRecordIds.has(id)) { return undefined; }
if (id < 0) {
// Reroute negative IDs to our local stash of records.
return this._extraRecordsById.get(id);
} else {
// Everything else, we just pass along.
return this._originalTable.getRecord(id);
}
}
public getRecords() {
return this._filterExcludedRecords([
...this._originalTable.getRecords(),
...this._extraRecords,
]);
}
public filterRecords(properties: Partial<MetaRowRecord<T>>): Array<MetaRowRecord<T>> {
const originalRecords = this._originalTable.filterRecords(properties);
const extraRecords = this._extraRecords.filter((rec) => Object.keys(properties)
.every((p) => isEqual((rec as any)[p], (properties as any)[p])));
return this._filterExcludedRecords([...originalRecords, ...extraRecords]);
}
public findMatchingRowId(properties: Partial<MetaRowRecord<T>>): number {
const rowId = (
this._originalTable.findMatchingRowId(properties) ||
this._extraRecords.find((rec) => Object.keys(properties).every((p) =>
isEqual((rec as any)[p], (properties as any)[p]))
)?.id
);
return rowId && !this._excludedRecordIds.has(rowId) ? rowId : 0;
}
private _filterExcludedRecords(records: MetaRowRecord<T>[]) {
return records.filter(({id}) => !this._excludedRecordIds.has(id));
}
}
export interface ACLRulesReaderOptions {
/**
* Adds virtual rules for all shares in the document.
*
* If set to `true` and there are shares in the document, regular rules are
* modified so that they don't apply when a document is being accessed through
* a share, and new rules are added to grant access to the resources specified by
* the shares.
*
* This will also "split" any resources (and their rules) if they apply to multiple
* resources. Splitting produces copies of the original resource and rules
* rules, but with modifications in place so that each copy applies to a single
* resource. Normalizing the original rules in this way allows for a simpler mechanism
* to override the original rules/resources with share rules, for situations where a
* share needs to grant access to a resource that is protected by access rules (shares
* and access rules are mutually exclusive at this time).
*
* Note: a value of `true` will *not* cause any persistent modifications to be made to
* rules; all changes are "virtual" in the sense that they are applied on top of the
* persisted rules to enable shares.
*
* Defaults to `false`.
*/
addShareRules?: boolean;
}
/**
* Helper class for reading ACL rules from DocData.
*/
export class ACLRulesReader {
private _resourcesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLResources'));
private _rulesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLRules'));
private _sharesTable = this.docData.getMetaTable('_grist_Shares');
private _hasShares = this._options.addShareRules && this._sharesTable.numRecords() > 0;
/** Maps 'tableId:colId' to the comma-separated list of column IDs from the associated resource. */
private _resourceColIdsByTableAndColId: Map<string, string> = new Map();
public constructor(public docData: DocData, private _options: ACLRulesReaderOptions = {}) {
this._addOriginalRules();
this._maybeAddShareRules();
}
public entries() {
const rulesByResourceId = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) {
// If we have "virtual" rules to implement shares, then regular
// rules need to be tweaked so that they don't apply when the
// share is active.
if (this._hasShares && rule.id >= 0) {
disableRuleInShare(rule);
}
getSetMapValue(rulesByResourceId, rule.resource, () => []).push(rule);
}
return rulesByResourceId.entries();
}
public getResourceById(id: number) {
return this._resourcesTable.getRecord(id);
}
private _addOriginalRules() {
for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) {
const resource = this.getResourceById(rule.resource);
if (!resource) {
throw new Error(`ACLRule ${rule.id} refers to an invalid ACLResource ${rule.resource}`);
}
if (resource.tableId !== '*' && resource.colIds !== '*') {
const colIds = resource.colIds.split(',');
if (colIds.length === 1) { continue; }
for (const colId of colIds) {
this._resourceColIdsByTableAndColId.set(`${resource.tableId}:${colId}`, resource.colIds);
}
}
}
}
private _maybeAddShareRules() {
if (!this._hasShares) { return; }
for (const share of this._sharesTable.getRecords()) {
this._addRulesForShare(share);
}
this._addDefaultShareRules();
}
/**
* Add any rules needed for the specified share.
*
* The only kind of share we support for now is form endpoint
* sharing.
*/
private _addRulesForShare(share: MetaRowRecord<'_grist_Shares'>) {
// TODO: Unpublished shares could and should be blocked earlier,
// by home server
const {publish}: ShareOptions = JSON.parse(share.options || '{}');
if (!publish) {
this._blockShare(share.id);
return;
}
// Let's go looking for sections related to the share.
// It was decided that the relationship between sections and
// shares is via pages. Every section on a given page can belong
// to at most one share.
// Ignore sections which do not have `publish` set to `true` in
// `shareOptions`.
const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({
shareRef: share.id,
});
const parentViews = new Set(pages.map(page => page.viewRef));
const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter(
section => {
if (!parentViews.has(section.parentId)) { return false; }
const options = JSON.parse(section.shareOptions || '{}');
return Boolean(options.publish) && Boolean(options.form);
}
);
const tableRefs = new Set(sections.map(section => section.tableRef));
const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter(
table => tableRefs.has(table.id)
);
// For tables associated with forms, allow creation of records,
// and reading of referenced columns.
// TODO: should probably be limiting to a set of columns associated
// with section - but for form widget that could potentially be very
// confusing since it may not be easy to see that certain columns
// haven't been made visible for it? For now, just working at table
// level.
for (const table of tables) {
this._shareTableForForm(table, share.id);
}
}
/**
* When accessing a document via a share, by default no user tables are
* accessible. Everything added to the share gives additional
* access, and never reduces access, making it easy to grant
* access to multiple parts of the document.
*
* We do leave access unchanged for metadata tables, since they are
* censored via an alternative mechanism.
*/
private _addDefaultShareRules() {
// Block access to each table.
const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords()
.map(table => table.tableId)
.filter(tableId => !tableId.startsWith('_grist_'))
.sort();
for (const tableId of tableIds) {
this._addShareRule(this._findOrAddResource({tableId, colIds: '*'}), '-CRUDS');
}
// Block schema access at the default level.
this._addShareRule(this._findOrAddResource({tableId: '*', colIds: '*'}), '-S');
}
/**
* Allow creating records in a table.
*/
private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>,
shareRef: number) {
const resource = this._findOrAddResource({
tableId: table.tableId,
colIds: '*',
});
let aclFormula = `user.ShareRef == ${shareRef}`;
let aclFormulaParsed = JSON.stringify([
'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
[ 'Const', shareRef ] ]);
this._rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+C',
}));
// This is a hack to grant read schema access, needed for forms -
// Should not be needed once forms are actually available, but
// until them is very handy to allow using the web client to
// submit records.
aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`;
aclFormulaParsed = JSON.stringify(
[ 'And',
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ],
[ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]);
this._rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
}));
this._shareTableReferencesForForm(table, shareRef);
}
/**
* Give read access to referenced columns.
*/
private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>,
shareRef: number) {
const tables = this.docData.getMetaTable('_grist_Tables');
const columns = this.docData.getMetaTable('_grist_Tables_column');
const tableColumns = columns.filterRecords({
parentId: table.id,
}).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:'));
for (const column of tableColumns) {
const visibleColRef = column.visibleCol;
// This could be blank in tests, not sure about real life.
if (!visibleColRef) { continue; }
const visibleCol = columns.getRecord(visibleColRef);
if (!visibleCol) { continue; }
const referencedTable = tables.getRecord(visibleCol.parentId);
if (!referencedTable) { continue; }
const tableId = referencedTable.tableId;
const colId = visibleCol.colId;
const resourceColIds = this._resourceColIdsByTableAndColId.get(`${tableId}:${colId}`) ?? colId;
const maybeResourceId = this._resourcesTable.findMatchingRowId({tableId, colIds: resourceColIds});
if (maybeResourceId !== 0) {
this._maybeSplitResourceForShares(maybeResourceId);
}
const resource = this._findOrAddResource({tableId, colIds: colId});
const aclFormula = `user.ShareRef == ${shareRef}`;
const aclFormulaParsed = JSON.stringify(
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ]);
this._rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
}));
}
}
/**
* Splits a resource into multiple resources that are suitable for being
* overridden by shares. Rules are copied to each resource, with modifications
* that disable them in shares.
*
* Ignores resources for single columns, and resources created for shares
* (i.e. those with a negative ID); the former can already be overridden
* by shares without any additional work, and the latter are guaranteed to
* only be for single columns.
*
* The motivation for this method is to normalize document access rules so
* that rule sets apply to at most a single column. Document shares may
* automatically grant limited access to parts of a document, such as columns
* that are referenced from a form field. But for this to happen, extra rules
* first need to be added to the original or new resource, which requires looking
* up the resource by column ID to see if it exists. This lookup only works if
* the rule set of the resource is for a single column; otherwise, the lookup
* will fail and cause a new resource to be created, which consequently causes
* 2 resources to exist that both contain the same column. Since this is an
* unsupported scenario with ambiguous evaluation semantics, we pre-emptively call
* this method to avoid such scenarios altogether.
*/
private _maybeSplitResourceForShares(resourceId: number) {
if (resourceId < 0) { return; }
const resource = this.getResourceById(resourceId);
if (!resource) {
throw new Error(`Unable to find ACLResource with ID ${resourceId}`);
}
const {tableId} = resource;
const colIds = resource.colIds.split(',');
if (colIds.length === 1) { return; }
const rules = sortBy(this._rulesTable.filterRecords({resource: resourceId}), 'rulePos')
.map(r => disableRuleInShare(r));
// Prepare a new resource for each column, with copies of the original resource's rules.
for (const colId of colIds) {
const newResourceId = this._resourcesTable.addRecord({id: 0, tableId, colIds: colId});
for (const rule of rules) {
this._rulesTable.addRecord({...rule, id: 0, resource: newResourceId});
}
}
// Exclude the original resource and rules.
this._resourcesTable.excludeRecord(resourceId);
for (const rule of rules) {
this._rulesTable.excludeRecord(rule.id);
}
}
/**
* Find a resource we need, and return its rowId. The resource is
* added if it is not already present.
*/
private _findOrAddResource(properties: {
tableId: string,
colIds: string,
}): number {
const resource = this._resourcesTable.findMatchingRowId(properties);
if (resource !== 0) { return resource; }
return this._resourcesTable.addRecord({
id: 0,
...properties,
});
}
private _addShareRule(resourceRef: number, permissionsText: string) {
const aclFormula = 'user.ShareRef is not None';
const aclFormulaParsed = JSON.stringify([
'NotEq',
['Attr', ['Name', 'user'], 'ShareRef'],
['Const', null],
]);
this._rulesTable.addRecord(this._makeRule({
resource: resourceRef, aclFormula, aclFormulaParsed, permissionsText,
}));
}
private _blockShare(shareRef: number) {
const resource = this._findOrAddResource({
tableId: '*', colIds: '*',
});
const aclFormula = `user.ShareRef == ${shareRef}`;
const aclFormulaParsed = JSON.stringify(
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ]);
this._rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
}));
}
private _makeRule(options: {
resource: number,
aclFormula: string,
aclFormulaParsed: string,
permissionsText: string,
}): MetaRowRecord<'_grist_ACLRules'> {
const {resource, aclFormula, aclFormulaParsed, permissionsText} = options;
return {
id: 0,
resource,
aclFormula,
aclFormulaParsed,
memo: '',
permissionsText,
userAttributes: '',
rulePos: 0,
// The following fields are unused and deprecated.
aclColumn: 0,
permissions: 0,
principals: '',
};
}
}
/**
* Updates the ACL formula of `rule` such that it's disabled if a document is being
* accessed via a share.
*
* Modifies `rule` in place.
*/
function disableRuleInShare(rule: MetaRowRecord<'_grist_ACLRules'>) {
const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
const newAclFormulaParsed = [
'And',
[ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ],
aclFormulaParsed || [ 'Const', true ]
];
rule.aclFormula = 'user.ShareRef is None and (' + String(rule.aclFormula || 'True') + ')';
rule.aclFormulaParsed = JSON.stringify(newAclFormulaParsed);
return rule;
}

@ -1,296 +0,0 @@
import { DocData } from 'app/common/DocData';
import { SchemaTypes } from 'app/common/schema';
import { ShareOptions } from 'app/common/ShareOptions';
import { MetaRowRecord, MetaTableData } from 'app/common/TableData';
import { isEqual } from 'lodash';
/**
* For special shares, we need to refer to resources that may not
* be listed in the _grist_ACLResources table, and have rules that
* aren't backed by storage in _grist_ACLRules. So we implement
* a small helper to add an overlay of extra resources and rules.
* They are distinguishable from real, stored resources and rules
* by having negative IDs.
*/
export class TableWithOverlay<T extends keyof SchemaTypes> {
private _extraRecords = new Array<MetaRowRecord<T>>();
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
private _nextFreeVirtualId: number = -1;
public constructor(private _originalTable: MetaTableData<T>) {}
// Add a record to the table, but only as an overlay - no
// persistent changes are made. Uses negative row IDs.
// Returns the ID assigned to the record. The passed in
// record is expected to have an ID of zero.
public addRecord(rec: MetaRowRecord<T>): number {
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
const id = this._nextFreeVirtualId;
const recWithCorrectId: MetaRowRecord<T> = {...rec, id};
this._extraRecords.push({...rec, id});
this._extraRecordsById.set(id, recWithCorrectId);
this._nextFreeVirtualId--;
return id;
}
// Support the few MetaTableData methods we actually use
// in ACLRuleCollection and ACLShareRules.
public getRecord(resourceId: number) {
// Reroute negative IDs to our local stash of records.
if (resourceId < 0) {
return this._extraRecordsById.get(resourceId);
}
// Everything else, we just pass along.
return this._originalTable.getRecord(resourceId);
}
public getRecords() {
return [...this._originalTable.getRecords(), ...this._extraRecords];
}
public findMatchingRowId(properties: Partial<MetaRowRecord<T>>): number {
// Check stored records.
const rowId = this._originalTable.findMatchingRowId(properties);
if (rowId) { return rowId; }
// Check overlay.
return this._extraRecords.find((rec) =>
Object.keys(properties).every((p) => isEqual(
(rec as any)[p],
(properties as any)[p])))?.id || 0;
}
}
/**
* Helper for managing special share rules.
*/
export class ACLShareRules {
public constructor(
public docData: DocData,
public resourcesTable: TableWithOverlay<'_grist_ACLResources'>,
public rulesTable: TableWithOverlay<'_grist_ACLRules'>,
) {}
/**
* Add any rules needed for the specified share.
*
* The only kind of share we support for now is form endpoint
* sharing.
*/
public addRulesForShare(shareRef: number, shareOptions: ShareOptions) {
// TODO: Unpublished shares could and should be blocked earlier,
// by home server
if (!shareOptions.publish) {
this._blockShare(shareRef);
return;
}
// Let's go looking for sections related to the share.
// It was decided that the relationship between sections and
// shares is via pages. Every section on a given page can belong
// to at most one share.
// Ignore sections which do not have `publish` set to `true` in
// `shareOptions`.
const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({
shareRef,
});
const parentViews = new Set(pages.map(page => page.viewRef));
const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter(
section => {
if (!parentViews.has(section.parentId)) { return false; }
const options = JSON.parse(section.shareOptions || '{}');
return Boolean(options.publish) && Boolean(options.form);
}
);
const tableRefs = new Set(sections.map(section => section.tableRef));
const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter(
table => tableRefs.has(table.id)
);
// For tables associated with forms, allow creation of records,
// and reading of referenced columns.
// TODO: should probably be limiting to a set of columns associated
// with section - but for form widget that could potentially be very
// confusing since it may not be easy to see that certain columns
// haven't been made visible for it? For now, just working at table
// level.
for (const table of tables) {
this._shareTableForForm(table, shareRef);
}
}
/**
* When accessing a document via a share, by default no user tables are
* accessible. Everything added to the share gives additional
* access, and never reduces access, making it easy to grant
* access to multiple parts of the document.
*
* We do leave access unchanged for metadata tables, since they are
* censored via an alternative mechanism.
*/
public addDefaultRulesForShares() {
const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords()
.map(table => table.tableId)
.filter(tableId => !tableId.startsWith('_grist_'))
.sort();
for (const tableId of tableIds) {
const resource = this._findOrAddResource({
tableId, colIds: '*',
});
const aclFormula = `user.ShareRef is not None`;
const aclFormulaParsed = JSON.stringify([
'NotEq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', null] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
}));
}
}
/**
* When accessing a document via a share, any regular granular access
* rules should not apply. This requires an extra conditional.
*/
public transformNonShareRules(state: {
rule: MetaRowRecord<'_grist_ACLRules'>,
aclFormulaParsed: object,
}) {
state.rule.aclFormula = 'user.ShareRef is None and (' + String(state.rule.aclFormula || 'True') + ')';
state.aclFormulaParsed = [
'And',
[ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ],
state.aclFormulaParsed || [ 'Const', true ]
];
state.rule.aclFormulaParsed = JSON.stringify(state.aclFormulaParsed);
return state.aclFormulaParsed;
}
/**
* Allow creating records in a table.
*/
private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>,
shareRef: number) {
const resource = this._findOrAddResource({
tableId: table.tableId,
colIds: '*',
});
let aclFormula = `user.ShareRef == ${shareRef}`;
let aclFormulaParsed = JSON.stringify([
'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
[ 'Const', shareRef ] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+C',
}));
// This is a hack to grant read schema access, needed for forms -
// Should not be needed once forms are actually available, but
// until them is very handy to allow using the web client to
// submit records.
aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`;
aclFormulaParsed = JSON.stringify(
[ 'And',
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ],
[ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
}));
this._shareTableReferencesForForm(table, shareRef);
}
/**
* Give read access to referenced columns.
*/
private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>,
shareRef: number) {
const tables = this.docData.getMetaTable('_grist_Tables');
const columns = this.docData.getMetaTable('_grist_Tables_column');
const tableColumns = columns.filterRecords({
parentId: table.id,
}).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:'));
for (const column of tableColumns) {
const visibleColRef = column.visibleCol;
// This could be blank in tests, not sure about real life.
if (!visibleColRef) { continue; }
const visibleCol = columns.getRecord(visibleColRef);
if (!visibleCol) { continue; }
const referencedTable = tables.getRecord(visibleCol.parentId);
if (!referencedTable) { continue; }
const tableId = referencedTable.tableId;
const colId = visibleCol.colId;
const resource = this._findOrAddResource({
tableId: tableId,
colIds: colId,
});
const aclFormula = `user.ShareRef == ${shareRef}`;
const aclFormulaParsed = JSON.stringify(
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
}));
}
}
/**
* Find a resource we need, and return its rowId. The resource is
* added if it is not already present.
*/
private _findOrAddResource(properties: {
tableId: string,
colIds: string,
}): number {
const resource = this.resourcesTable.findMatchingRowId(properties);
if (resource !== 0) { return resource; }
return this.resourcesTable.addRecord({
id: 0,
...properties,
});
}
private _blockShare(shareRef: number) {
const resource = this._findOrAddResource({
tableId: '*', colIds: '*',
});
const aclFormula = `user.ShareRef == ${shareRef}`;
const aclFormulaParsed = JSON.stringify(
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
}));
}
private _makeRule(options: {
resource: number,
aclFormula: string,
aclFormulaParsed: string,
permissionsText: string,
}): MetaRowRecord<'_grist_ACLRules'> {
const {resource, aclFormula, aclFormulaParsed, permissionsText} = options;
return {
id: 0,
resource,
aclFormula,
aclFormulaParsed,
memo: '',
permissionsText,
userAttributes: '',
rulePos: 0,
// The following fields are unused and deprecated.
aclColumn: 0,
permissions: 0,
principals: '',
};
}
}

@ -0,0 +1,443 @@
import {ACLRulesReader} from 'app/common/ACLRulesReader';
import {DocData} from 'app/common/DocData';
import {MetaRowRecord} from 'app/common/TableData';
import {CellValue} from 'app/plugin/GristData';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {makeExceptionalDocSession} from 'app/server/lib/DocSession';
import {assert} from 'chai';
import * as sinon from 'sinon';
import {createDocTools} from 'test/server/docTools';
describe('ACLRulesReader', function() {
this.timeout(10000);
const docTools = createDocTools({persistAcrossCases: true});
const fakeSession = makeExceptionalDocSession('system');
let activeDoc: ActiveDoc;
let docData: DocData;
before(async function () {
activeDoc = await docTools.createDoc('ACLRulesReader');
docData = activeDoc.docData!;
});
describe('without shares', function() {
it('entries', async function() {
// Check output of reading the resources and rules of an empty document.
for (const options of [undefined, {addShareRules: true}]) {
assertResourcesAndRules(new ACLRulesReader(docData, options), [
DEFAULT_UNUSED_RESOURCE_AND_RULE,
]);
}
// Add some table and default rules and re-check output.
await activeDoc.applyUserActions(fakeSession, [
['AddTable', 'Private', [{id: 'A'}]],
['AddTable', 'PartialPrivate', [{id: 'A'}]],
['AddRecord', 'PartialPrivate', null, { A: 0 }],
['AddRecord', 'PartialPrivate', null, { A: 1 }],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'PartialPrivate', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'user.Access == "owners"',
permissionsText: 'all',
memo: 'owner check',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'user.Access != "owners" and rec.A > 0', permissionsText: 'none',
}],
['AddTable', 'Public', [{id: 'A'}]],
]);
for (const options of [undefined, {addShareRules: true}]) {
assertResourcesAndRules(new ACLRulesReader(docData, options), [
{
resource: {id: 2, tableId: 'Private', colIds: '*'},
rules: [
{
aclFormula: 'user.Access == "owners"',
permissionsText: 'all',
},
{
aclFormula: '',
permissionsText: 'none',
},
],
},
{
resource: {id: 3, tableId: '*', colIds: '*'},
rules: [
{
aclFormula: 'user.Access != "owners"',
permissionsText: '-S',
},
],
},
{
resource: {id: 4, tableId: 'PartialPrivate', colIds: '*'},
rules: [
{
aclFormula: 'user.Access != "owners" and rec.A > 0',
permissionsText: 'none',
},
],
},
DEFAULT_UNUSED_RESOURCE_AND_RULE,
]);
}
});
it('getResourceById', async function() {
for (const options of [undefined, {addShareRules: true}]) {
// Check output of valid resource ids.
assert.deepEqual(
new ACLRulesReader(docData, options).getResourceById(1),
{id: 1, tableId: '', colIds: ''}
);
assert.deepEqual(
new ACLRulesReader(docData, options).getResourceById(2),
{id: 2, tableId: 'Private', colIds: '*'}
);
assert.deepEqual(
new ACLRulesReader(docData, options).getResourceById(3),
{id: 3, tableId: '*', colIds: '*'}
);
assert.deepEqual(
new ACLRulesReader(docData, options).getResourceById(4),
{id: 4, tableId: 'PartialPrivate', colIds: '*'}
);
// Check output of non-existent resource ids.
assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(5));
assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(0));
assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(-1));
}
});
});
describe('with shares', function() {
before(async function() {
sinon.stub(ActiveDoc.prototype as any, '_getHomeDbManagerOrFail').returns({
syncShares: () => Promise.resolve(),
});
activeDoc = await docTools.loadFixtureDoc('FilmsWithImages.grist');
docData = activeDoc.docData!;
await activeDoc.applyUserActions(fakeSession, [
['AddRecord', '_grist_Shares', null, {
linkId: 'x',
options: '{"publish": true}'
}],
]);
});
after(function() {
sinon.restore();
});
it('entries', async function() {
// Check output of reading the resources and rules of an empty document.
assertResourcesAndRules(new ACLRulesReader(docData), [
DEFAULT_UNUSED_RESOURCE_AND_RULE,
]);
// Check output of reading the resources and rules of an empty document, with share rules.
assertResourcesAndRules(new ACLRulesReader(docData, {addShareRules: true}), [
{
resource: {id: -1, tableId: 'Films', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-CRUDS',
},
],
},
{
resource: {id: -2, tableId: 'Friends', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-CRUDS',
},
],
},
{
resource: {id: -3, tableId: 'Performances', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-CRUDS',
},
],
},
{
resource: {id: -4, tableId: '*', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-S',
},
],
},
{
resource: {id: 1, tableId: '', colIds: ''},
rules: [
{
aclFormula: 'user.ShareRef is None and (True)',
permissionsText: '',
},
],
},
]);
// Add some default, table, and column rules.
await activeDoc.applyUserActions(fakeSession, [
['UpdateRecord', '_grist_Views_section', 7,
{shareOptions: '{"publish": true, "form": true}'}],
['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Poster,PosterDup'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.access != OWNER', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'True', permissionsText: 'all',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'True', permissionsText: 'all',
}],
]);
// Re-check output without share rules.
assertResourcesAndRules(new ACLRulesReader(docData), [
{
resource: {id: 2, tableId: 'Films', colIds: 'Title,Poster,PosterDup'},
rules: [
{
aclFormula: 'user.access != OWNER',
permissionsText: '-R',
},
],
},
{
resource: {id: 3, tableId: 'Films', colIds: '*'},
rules: [
{
aclFormula: 'True',
permissionsText: 'all',
},
],
},
{
resource: {id: 4, tableId: '*', colIds: '*'},
rules: [
{
aclFormula: 'True',
permissionsText: 'all',
},
],
},
DEFAULT_UNUSED_RESOURCE_AND_RULE,
]);
// Re-check output with share rules.
assertResourcesAndRules(new ACLRulesReader(docData, {addShareRules: true}), [
{
resource: {id: -1, tableId: 'Friends', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef == 1',
permissionsText: '+C',
},
{
aclFormula: 'user.ShareRef == 1 and rec.id == 0',
permissionsText: '+R',
},
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-CRUDS',
},
],
},
// Resource -2, -3, and -4, were split from resource 2.
{
resource: {id: -2, tableId: 'Films', colIds: 'Title'},
rules: [
{
aclFormula: 'user.ShareRef == 1',
permissionsText: '+R',
},
{
aclFormula: 'user.ShareRef is None and (user.access != OWNER)',
permissionsText: '-R',
},
],
},
{
resource: {id: 3, tableId: 'Films', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-CRUDS',
},
{
aclFormula: 'user.ShareRef is None and (True)',
permissionsText: 'all',
},
],
},
{
resource: {id: -5, tableId: 'Performances', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-CRUDS',
},
],
},
{
resource: {id: 4, tableId: '*', colIds: '*'},
rules: [
{
aclFormula: 'user.ShareRef is not None',
permissionsText: '-S',
},
{
aclFormula: 'user.ShareRef is None and (True)',
permissionsText: 'all',
},
],
},
// Resource -3 and -4 were split from resource 2.
{
resource: {id: -3, tableId: 'Films', colIds: 'Poster'},
rules: [
{
aclFormula: 'user.ShareRef is None and (user.access != OWNER)',
permissionsText: '-R',
},
],
},
{
resource: {id: -4, tableId: 'Films', colIds: 'PosterDup'},
rules: [
{
aclFormula: 'user.ShareRef is None and (user.access != OWNER)',
permissionsText: '-R',
},
],
},
{
resource: {id: 1, tableId: '', colIds: ''},
rules: [
{
aclFormula: 'user.ShareRef is None and (True)',
permissionsText: '',
},
],
},
]);
});
it('getResourceById', async function() {
// Check output of valid resource ids.
assert.deepEqual(
new ACLRulesReader(docData).getResourceById(1),
{id: 1, tableId: '', colIds: ''}
);
assert.deepEqual(
new ACLRulesReader(docData).getResourceById(2),
{id: 2, tableId: 'Films', colIds: 'Title,Poster,PosterDup'}
);
assert.deepEqual(
new ACLRulesReader(docData).getResourceById(3),
{id: 3, tableId: 'Films', colIds: '*'}
);
assert.deepEqual(
new ACLRulesReader(docData).getResourceById(4),
{id: 4, tableId: '*', colIds: '*'}
);
// Check output of non-existent resource ids.
assert.isUndefined(new ACLRulesReader(docData).getResourceById(5));
assert.isUndefined(new ACLRulesReader(docData).getResourceById(0));
assert.isUndefined(new ACLRulesReader(docData).getResourceById(-1));
// Check output of valid resource ids (with share rules).
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(1),
{id: 1, tableId: '', colIds: ''}
);
assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(2));
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(3),
{id: 3, tableId: 'Films', colIds: '*'}
);
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(4),
{id: 4, tableId: '*', colIds: '*'}
);
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-1),
{id: -1, tableId: 'Friends', colIds: '*'}
);
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-2),
{id: -2, tableId: 'Films', colIds: 'Title'}
);
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-3),
{id: -3, tableId: 'Films', colIds: 'Poster'}
);
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-4),
{id: -4, tableId: 'Films', colIds: 'PosterDup'}
);
assert.deepEqual(
new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-5),
{id: -5, tableId: 'Performances', colIds: '*'}
);
// Check output of non-existent resource ids (with share rules).
assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(5));
assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(0));
assert.isUndefined(new ACLRulesReader(docData, {addShareRules: true}).getResourceById(-6));
});
});
});
interface ACLResourceAndRules {
resource: MetaRowRecord<'_grist_ACLResources'>|undefined;
rules: {aclFormula: CellValue, permissionsText: CellValue}[];
}
function assertResourcesAndRules(
aclRulesReader: ACLRulesReader,
expected: ACLResourceAndRules[]
) {
const actual: ACLResourceAndRules[] = [...aclRulesReader.entries()].map(([resourceId, rules]) => {
return {
resource: aclRulesReader.getResourceById(resourceId),
rules: rules.map(({aclFormula, permissionsText}) => ({aclFormula, permissionsText})),
};
});
assert.deepEqual(actual, expected);
}
/**
* An unused resource and rule that's automatically included in every Grist document.
*
* See comment in `UserActions.InitNewDoc` (from `useractions.py`) for context.
*/
const DEFAULT_UNUSED_RESOURCE_AND_RULE: ACLResourceAndRules = {
resource: {id: 1, tableId: '', colIds: ''},
rules: [{aclFormula: '', permissionsText: ''}],
};
Loading…
Cancel
Save