gristlabs_grist-core/app/common/ACLRulesReader.ts
George Gevoian 0130409447 (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
2024-04-12 15:46:21 -07:00

455 lines
17 KiB
TypeScript

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;
}