(core) Implement much of the general AccessRules UI.

Summary:
- Factored out ACLRuleCollection into its own file, and use for building UI.
- Moved AccessRules out of UserManager to a page linked from left panel.
- Changed default RulePart to be the last part of a rule for simpler code.
- Implemented much of the UI for adding/deleting rules.
  - For now, editing the ACLFormula and Permissions is done using text inputs.
- Implemented saving rules by syncing a bundle of them.
- Fixed DocData to clean up action bundle in case of an early error.

Test Plan: WIP planning to add some new browser tests for the UI

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2678
pull/3/head
Dmitry S 4 years ago
parent 3519d0efce
commit 8c788005c3

@ -27,8 +27,9 @@ import {DocData} from 'app/client/models/DocData';
import {DocInfoRec, DocModel, ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {UserError} from 'app/client/models/errors';
import {IDocPage, urlState} from 'app/client/models/gristUrlState';
import {urlState} from 'app/client/models/gristUrlState';
import {QuerySetManager} from 'app/client/models/QuerySet';
import {AccessRules} from 'app/client/ui/AccessRules';
import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory';
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
@ -40,7 +41,7 @@ import {delay} from 'app/common/delay';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {isSchemaAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {HashLink} from 'app/common/gristUrls';
import {HashLink, IDocPage} from 'app/common/gristUrls';
import {encodeQueryParams, waitObs} from 'app/common/gutil';
import {StringUnion} from 'app/common/StringUnion';
import {TableData} from 'app/common/TableData';
@ -237,6 +238,7 @@ export class GristDoc extends DisposableWithEvents {
return cssViewContentPane(testId('gristdoc'),
dom.domComputed<IDocPage>(this.activeViewId, (viewId) => (
viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) :
viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, this))) :
viewId === 'new' ? null :
dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId)))
)),

@ -96,14 +96,14 @@ export class DocData extends BaseDocData {
this._triggerBundleFinalize?.();
await this._lastBundlePromise;
}
this._nextDesc = options.description;
this._lastActionNum = null;
this._triggerBundleFinalize = triggerFinalize;
const value = await options.prepare();
prepareResolve(value);
this._shouldIncludeInBundle = options.shouldIncludeInBundle;
try {
this._nextDesc = options.description;
this._lastActionNum = null;
this._triggerBundleFinalize = triggerFinalize;
const value = await options.prepare();
prepareResolve(value);
this._shouldIncludeInBundle = options.shouldIncludeInBundle;
await triggerFinalizePromise;
// Unset _shouldIncludeInBundle so that actions sent by finalize() are included in the
// bundle. If they were checked and incorrectly failed the check, we'd have a deadlock.

@ -86,8 +86,6 @@ function _getLoginLogoutUrl(method: 'login'|'logout'|'signin', nextUrl: string):
return startUrl.href;
}
export type IDocPage = number | 'new' | 'code';
/**
* Implements the interface expected by UrlState. It is only exported for the sake of tests; the
* only public interface is the urlState() accessor.

@ -3,197 +3,852 @@
*/
import {GristDoc} from 'app/client/components/GristDoc';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {reportError, UserError} from 'app/client/models/errors';
import {TableData} from 'app/client/models/TableData';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {primaryButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem, select} from 'app/client/ui2018/menus';
import {readAclRules} from 'app/common/GranularAccessClause';
import {setDifference} from 'app/common/gutil';
import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs';
import {menu, menuItemAsync} from 'app/client/ui2018/menus';
import {emptyPermissionSet, parsePermissions} from 'app/common/ACLPermissions';
import {PartialPermissionSet, permissionSetToText} from 'app/common/ACLPermissions';
import {ACLRuleCollection} from 'app/common/ACLRuleCollection';
import {BulkColValues, RowRecord, UserAction} from 'app/common/DocActions';
import {RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {isObject} from 'app/common/gutil';
import {SchemaTypes} from 'app/common/schema';
import {BaseObservable, Computed, Disposable, dom, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
interface AclState {
ownerOnlyTableIds: Set<string>;
ownerOnlyStructure: boolean;
}
const MATCH_NON_OWNER = 'user.Access != "owners"';
// tslint:disable:max-classes-per-file no-console
function buildAclState(gristDoc: GristDoc): AclState {
const {ruleSets} = readAclRules(gristDoc.docData, {log: console});
console.log("FOUND RULE SETS", ruleSets);
// Types for the rows in the ACL tables we use.
type ResourceRec = SchemaTypes["_grist_ACLResources"] & {id?: number};
type RuleRec = Partial<SchemaTypes["_grist_ACLRules"]> & {id?: number, resourceRec?: ResourceRec};
const ownerOnlyTableIds = new Set<string>();
let ownerOnlyStructure = false;
for (const ruleSet of ruleSets) {
if (ruleSet.tableId === '*' && ruleSet.colIds === '*') {
if (ruleSet.body.find(p => p.aclFormula === MATCH_NON_OWNER && p.permissionsText === '-S')) {
ownerOnlyStructure = true;
}
} else if (ruleSet.tableId !== '*' && ruleSet.colIds === '*') {
if (ruleSet.body.find(p => p.aclFormula === MATCH_NON_OWNER && p.permissionsText === 'none')) {
ownerOnlyTableIds.add(ruleSet.tableId);
}
}
}
return {ownerOnlyTableIds, ownerOnlyStructure};
}
type UseCB = <T>(obs: BaseObservable<T>) => T;
/**
* Top-most container managing state and dom-building for the ACL rule UI.
*/
export class AccessRules extends Disposable {
// Whether anything has changed, i.e. whether to show a "Save" button.
public isAnythingChanged: Computed<boolean>;
// NOTE: For the time being, rules correspond one to one with resources.
private _initialState: AclState = buildAclState(this._gristDoc);
private _allTableIds: ObsArray<string> = createObsArray(this, this._gristDoc.docModel.allTableIds);
// Parsed rules obtained from DocData during last call to update(). Used for isAnythingChanged.
private _ruleCollection = new ACLRuleCollection();
// Array of all per-table rules.
private _tableRules = this.autoDispose(obsArray<TableRules>());
// The default rule set for the document (for "*:*").
private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
// Array of all tableIds in the document (for adding new per-table rules).
private _allTableIds = createObsArray(this, this._gristDoc.docModel.allTableIds);
private _ownerOnlyTableIds = this.autoDispose(obsArray([...this._initialState.ownerOnlyTableIds]));
private _ownerOnlyStructure = Observable.create<boolean>(this, this._initialState.ownerOnlyStructure);
private _currentState = Computed.create<AclState>(this, (use) => ({
ownerOnlyTableIds: new Set(use(this._ownerOnlyTableIds)),
ownerOnlyStructure: use(this._ownerOnlyStructure),
}));
// Array of all UserAttribute rules.
private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());
constructor(private _gristDoc: GristDoc) {
super();
this.isAnythingChanged = Computed.create(this, (use) =>
!isEqual(use(this._currentState), this._initialState));
this.isAnythingChanged = Computed.create(this, (use) => {
const defRuleSet = use(this._docDefaultRuleSet);
const tableRules = use(this._tableRules);
const userAttr = use(this._userAttrRules);
return (defRuleSet && use(defRuleSet.isChanged)) ||
// If any table was changed or added, some t.isChanged will be set. If there were only
// removals, then tableRules.length will have changed.
tableRules.length !== this._ruleCollection.getAllTableIds().length ||
tableRules.some(t => use(t.isChanged)) ||
userAttr.length !== this._ruleCollection.getUserAttributeRules().size ||
userAttr.some(u => use(u.isChanged));
});
this.update().catch(reportError);
}
/**
* Replace internal state from the rules in DocData.
*/
public async update() {
const rules = this._ruleCollection;
await rules.update(this._gristDoc.docData, {log: console});
this._tableRules.set(
rules.getAllTableIds().map(tableId => TableRules.create(this._tableRules,
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
);
DefaultObsRuleSet.create(this._docDefaultRuleSet, null, undefined, rules.getDocDefaultRuleSet());
this._userAttrRules.set(
Array.from(rules.getUserAttributeRules().values(), userAttr =>
ObsUserAttributeRule.create(this._userAttrRules, this, userAttr))
);
}
/**
* Collect the internal state into records and sync them to the document.
*/
public async save(): Promise<void> {
if (!this.isAnythingChanged.get()) { return; }
// If anything has changed, we re-fetch the state from the current docModel (it may have been
// changed by other users), and apply changes, if any, relative to that.
const latestState = buildAclState(this._gristDoc);
const currentState = this._currentState.get();
// Note that if anything has changed, we apply changes relative to the current state of the
// ACL tables (they may have changed by other users). So our changes will win.
// TODO: There is a race condition if two people save different rules at the same time, since
// it's a two-step operation (syncing resources and rules).
const docData = this._gristDoc.docData;
const resourcesTable = docData.getTable('_grist_ACLResources')!;
const rulesTable = docData.getTable('_grist_ACLRules')!;
await this._gristDoc.docData.bundleActions('Update Access Rules', async () => {
// If ownerOnlyStructure flag changed, add or remove the relevant resource record.
const defaultResource = resourcesTable.findMatchingRowId({tableId: '*', colIds: '*'}) ||
await resourcesTable.sendTableAction(['AddRecord', null, {tableId: '*', colIds: '*'}]);
const ruleObj = {resource: defaultResource, aclFormula: MATCH_NON_OWNER, permissionsText: '-S'};
const ruleRowId = rulesTable.findMatchingRowId(ruleObj);
if (currentState.ownerOnlyStructure && !ruleRowId) {
await rulesTable.sendTableAction(['AddRecord', null, ruleObj]);
} else if (!currentState.ownerOnlyStructure && ruleRowId) {
await rulesTable.sendTableAction(['RemoveRecord', ruleRowId]);
await docData.bundleActions(null, async () => {
// Add/remove resources to have just the ones we need.
const newResources: RowRecord[] = flatten(
[{tableId: '*', colIds: '*'}], ...this._tableRules.get().map(t => t.getResources()))
.map(r => ({id: -1, ...r}));
const newResourceMap = await syncRecords(resourcesTable, newResources, serializeResource);
// For syncing rules, we'll go by rowId that we store with each RulePart and with the RuleSet.
// New rules will get temporary negative rowIds.
let nextId: number = -1;
const newRules: RowRecord[] = [];
for (const rule of this.getRules()) {
// We use id of 0 internally to mark built-in rules. Skip those.
if (rule.id === 0) {
continue;
}
// Look up the rowId for the resource.
const resourceKey = serializeResource(rule.resourceRec as RowRecord);
const resourceRowId = newResourceMap.get(resourceKey);
if (!resourceRowId) {
throw new Error(`Resource missing in resource map: ${resourceKey}`);
}
newRules.push({
id: rule.id || (nextId--),
resource: resourceRowId,
aclFormula: rule.aclFormula!,
permissionsText: rule.permissionsText!,
rulePos: rule.rulePos || null,
});
}
// Handle tables added to ownerOnlyTableIds.
const tablesAdded = setDifference(currentState.ownerOnlyTableIds, latestState.ownerOnlyTableIds);
for (const tableId of tablesAdded) {
const resource = resourcesTable.findMatchingRowId({tableId, colIds: '*'}) ||
await resourcesTable.sendTableAction(['AddRecord', null, {tableId, colIds: '*'}]);
await rulesTable.sendTableAction(
['AddRecord', null, {resource, aclFormula: MATCH_NON_OWNER, permissionsText: 'none'}]);
// UserAttribute rules are listed in the same rulesTable.
const defaultResourceRowId = newResourceMap.get(serializeResource({id: -1, tableId: '*', colIds: '*'}));
if (!defaultResourceRowId) {
throw new Error('Default resource missing in resource map');
}
// Handle table removed from ownerOnlyTableIds.
const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds);
for (const tableId of tablesRemoved) {
const resource = resourcesTable.findMatchingRowId({tableId, colIds: '*'});
if (resource) {
const rowId = rulesTable.findMatchingRowId({resource, aclFormula: MATCH_NON_OWNER, permissionsText: 'none'});
if (rowId) {
await rulesTable.sendTableAction(['RemoveRecord', rowId]);
for (const userAttr of this._userAttrRules.get()) {
const rule = userAttr.getRule();
newRules.push({
id: rule.id || (nextId--),
resource: defaultResourceRowId,
rulePos: rule.rulePos || null,
userAttributes: rule.userAttributes,
});
}
// We need to fill in rulePos values. We'll add them in the order the rules are listed (since
// this.getRules() returns them in a suitable order), keeping rulePos unchanged when possible.
let lastGoodRulePos = 0;
let lastGoodIndex = -1;
for (let i = 0; i < newRules.length; i++) {
const pos = newRules[i].rulePos as number;
if (pos && pos > lastGoodRulePos) {
const step = (pos - lastGoodRulePos) / (i - lastGoodIndex);
for (let k = lastGoodIndex + 1; k < i; k++) {
newRules[k].rulePos = step * (k - lastGoodIndex);
}
lastGoodRulePos = pos;
lastGoodIndex = i;
}
}
// Fill in the rulePos values for the remaining rules.
for (let k = lastGoodIndex + 1; k < newRules.length; k++) {
newRules[k].rulePos = ++lastGoodRulePos;
}
// Finally we can sync the records.
await syncRecords(rulesTable, newRules);
});
// Re-populate the state from DocData once the records are synced.
await this.update();
}
public buildDom() {
return [
cssAddTableRow(
primaryButton(icon('Plus'), 'Add Table Rules',
bigBasicButton('Saved', {disabled: true}, dom.hide(this.isAnythingChanged)),
bigPrimaryButton('Save', dom.show(this.isAnythingChanged),
dom.on('click', () => this.save()),
testId('rules-save'),
),
bigBasicButton('Revert', dom.show(this.isAnythingChanged),
dom.on('click', () => this.update()),
testId('rules-revert'),
),
bigBasicButton('Add Table Rules', {style: 'margin-left: auto'},
menu(() => [
dom.forEach(this._allTableIds, (tableId) =>
// Add the table on a timeout, to avoid disabling the clicked menu item
// synchronously, which prevents the menu from closing on click.
menuItem(() => setTimeout(() => this._ownerOnlyTableIds.push(tableId), 0),
menuItemAsync(() => this._addTableRules(tableId),
tableId,
dom.cls('disabled', (use) => use(this._ownerOnlyTableIds).includes(tableId)),
dom.cls('disabled', (use) => use(this._tableRules).some(t => t.tableId === tableId)),
)
),
]),
),
bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())),
),
shadowScroll(
dom.forEach(this._ownerOnlyTableIds, (tableId) => {
return cssTableRule(
cssTableHeader(
dom('div', 'Rules for ', dom('b', dom.text(tableId))),
cssRemove(icon('Remove'),
dom.on('click', () =>
this._ownerOnlyTableIds.splice(this._ownerOnlyTableIds.get().indexOf(tableId), 1))
),
),
dom.maybe(use => use(this._userAttrRules).length, () =>
cssTableRule(
cssTableHeader('User Attributes'),
cssTableBody(
cssPermissions('All Access'),
cssPrincipals('Owners'),
dom.forEach(this._userAttrRules, (userAttr) => userAttr.buildDom()),
),
);
}),
),
),
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
cssTableRule(
cssTableHeader('Default Rule'),
cssTableHeader('Default Rules'),
cssTableBody(
cssPermissions('Schema Edit'),
cssPrincipals(
select(this._ownerOnlyStructure, [
{label: 'Owners Only', value: true},
{label: 'Owners & Editors', value: false}
]),
)
),
dom.maybe(this._docDefaultRuleSet, ruleSet => ruleSet.buildDom()),
)
)
),
];
}
/**
* Get a list of all rule records, for saving.
*/
public getRules(): RuleRec[] {
return flatten(
...this._tableRules.get().map(t => t.getRules()),
this._docDefaultRuleSet.get()?.getRules('*') || []
);
}
public removeTableRules(tableRules: TableRules) {
removeItem(this._tableRules, tableRules);
}
public removeUserAttributes(userAttr: ObsUserAttributeRule) {
removeItem(this._userAttrRules, userAttr);
}
private _addTableRules(tableId: string) {
if (this._tableRules.get().some(t => t.tableId === tableId)) {
throw new Error(`Trying to add TableRules for existing table ${tableId}`);
}
const defRuleSet: RuleSet = {tableId, colIds: '*', body: []};
this._tableRules.push(TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet));
}
private _addUserAttributes() {
this._userAttrRules.push(ObsUserAttributeRule.create(this._userAttrRules, this));
}
}
// Represents all rules for a table.
class TableRules extends Disposable {
// Whether any table rules changed. Always true if this._colRuleSets is undefined.
public isChanged: Computed<boolean>;
// The column-specific rule sets.
private _columnRuleSets = this.autoDispose(obsArray<ColumnObsRuleSet>());
// Whether there are any column-specific rule sets.
private _haveColumnRules = Computed.create(this, this._columnRuleSets, (use, cols) => cols.length > 0);
// The default rule set (for columns '*'), if one is set.
private _defaultRuleSet = Observable.create<DefaultObsRuleSet|null>(this, null);
constructor(public readonly tableId: string, private _accessRules: AccessRules,
private _colRuleSets?: RuleSet[], private _defRuleSet?: RuleSet) {
super();
this._columnRuleSets.set(this._colRuleSets?.map(rs =>
ColumnObsRuleSet.create(this._columnRuleSets, this, rs, rs.colIds === '*' ? [] : rs.colIds)) || []);
if (!this._colRuleSets) {
// Must be a newly-created TableRules object. Just create a default RuleSet (for tableId:*)
DefaultObsRuleSet.create(this._defaultRuleSet, this, this._haveColumnRules);
} else if (this._defRuleSet) {
DefaultObsRuleSet.create(this._defaultRuleSet, this, this._haveColumnRules, this._defRuleSet);
}
this.isChanged = Computed.create(this, (use) => {
if (!this._colRuleSets) { return true; } // This TableRules object must be newly-added
const columnRuleSets = use(this._columnRuleSets);
const d = use(this._defaultRuleSet);
return (
Boolean(d) !== Boolean(this._defRuleSet) || // Default rule set got added or removed
(d && use(d.isChanged)) || // Or changed
columnRuleSets.length < this._colRuleSets.length || // There was a removal
columnRuleSets.some(rs => use(rs.isChanged)) // There was an addition or a change.
);
});
}
public buildDom() {
return cssTableRule(
cssTableHeader(
dom('span', 'Rules for table ', cssTableName(this.tableId)),
cssIconButton(icon('Dots'), {style: 'margin-left: auto'},
menu(() => [
menuItemAsync(() => this._addColumnRuleSet(), 'Add Column Rule'),
menuItemAsync(() => this._addDefaultRuleSet(), 'Add Default Rule',
dom.cls('disabled', use => Boolean(use(this._defaultRuleSet)))),
menuItemAsync(() => this._accessRules.removeTableRules(this), 'Delete Table Rules'),
]),
testId('rule-table-menu-btn'),
),
testId('rule-table-header'),
),
cssTableBody(
dom.forEach(this._columnRuleSets, ruleSet => ruleSet.buildDom()),
dom.maybe(this._defaultRuleSet, ruleSet => ruleSet.buildDom()),
),
testId('rule-table'),
);
}
/**
* Return the resources (tableId:colIds entities), for saving, checking along the way that they
* are valid.
*/
public getResources(): ResourceRec[] {
// Check that the colIds are valid.
const seen = new Set<string>();
for (const ruleSet of this._columnRuleSets.get()) {
const colIds = ruleSet.getColIdList();
if (colIds.length === 0) {
throw new UserError(`No columns listed in a column rule for table ${this.tableId}`);
}
for (const colId of colIds) {
if (seen.has(colId)) {
throw new UserError(`Column ${colId} appears in multiple rules for table ${this.tableId}`);
}
seen.add(colId);
}
}
return [
...this._columnRuleSets.get().map(rs => ({tableId: this.tableId, colIds: rs.getColIds()})),
{tableId: this.tableId, colIds: '*'},
];
}
/**
* Get rules for this table, for saving.
*/
public getRules(): RuleRec[] {
return flatten(
...this._columnRuleSets.get().map(rs => rs.getRules(this.tableId)),
this._defaultRuleSet.get()?.getRules(this.tableId) || [],
);
}
public removeRuleSet(ruleSet: ObsRuleSet) {
if (ruleSet === this._defaultRuleSet.get()) {
this._defaultRuleSet.set(null);
} else {
removeItem(this._columnRuleSets, ruleSet);
}
if (!this._defaultRuleSet.get() && this._columnRuleSets.get().length === 0) {
this._accessRules.removeTableRules(this);
}
}
private _addColumnRuleSet() {
this._columnRuleSets.push(ColumnObsRuleSet.create(this._columnRuleSets, this, undefined, []));
}
private _addDefaultRuleSet() {
if (!this._defaultRuleSet.get()) {
DefaultObsRuleSet.create(this._defaultRuleSet, this, this._haveColumnRules);
}
}
}
// Represents one RuleSet, for a combination of columns in one table, or the default RuleSet for
// all remaining columns in a table.
abstract class ObsRuleSet extends Disposable {
// Whether rules changed. Always true if this._ruleSet is undefined.
public isChanged: Computed<boolean>;
// Whether the rule set includes any conditions besides the default rule.
public haveConditions: Computed<boolean>;
// List of individual rule parts for this entity. The default permissions may be included as the
// last rule part, with an empty aclFormula.
private _body = this.autoDispose(obsArray<ObsRulePart>());
// ruleSet is omitted for a new ObsRuleSet added by the user.
constructor(private _tableRules: TableRules|null, private _ruleSet?: RuleSet) {
super();
if (this._ruleSet) {
this._body.set(this._ruleSet.body.map(part => ObsRulePart.create(this._body, this, part)));
} else {
// If creating a new RuleSet, start with just a default permission part.
this._body.set([ObsRulePart.create(this._body, this, undefined, true)]);
}
this.isChanged = Computed.create(this, this._body, (use, body) => {
// If anything was changed or added, some part.isChanged will be set. If there were only
// removals, then body.length will have changed.
return (body.length !== (this._ruleSet?.body?.length || 0) ||
body.some(part => use(part.isChanged)));
});
this.haveConditions = Computed.create(this, this._body, (use, body) => body.some(p => !p.isDefault));
}
public getRules(tableId: string): RuleRec[] {
// Return every part in the body, tacking on resourceRec to each rule.
return this._body.get().map(part => ({
...part.getRulePart(),
resourceRec: {tableId, colIds: this.getColIds()}
}));
}
public getColIds(): string {
return '*';
}
public abstract buildDom(): Element;
public removeRulePart(rulePart: ObsRulePart) {
removeItem(this._body, rulePart);
if (this._body.get().length === 0) {
this._tableRules?.removeRuleSet(this);
}
}
public addRulePart(beforeRule: ObsRulePart) {
const i = this._body.get().indexOf(beforeRule);
this._body.splice(i, 0, ObsRulePart.create(this._body, this, undefined, false));
}
/**
* Returns the first built-in rule. It's the only one of the built-in rules to get a "+" next to
* it, since we don't allow inserting new rules in-between built-in rules.
*/
public getFirstBuiltIn(): ObsRulePart|undefined {
return this._body.get().find(p => p.isBuiltIn());
}
/**
* When an empty-conditition RulePart is the only part of a RuleSet, we can say it applies to
* "Everyone".
*/
public isSoleCondition(use: UseCB, part: ObsRulePart): boolean {
const body = use(this._body);
return body.length === 1 && body[0] === part;
}
/**
* When an empty-conditition RulePart is last in a RuleSet, we say it applies to "Everyone Else".
*/
public isLastCondition(use: UseCB, part: ObsRulePart): boolean {
const body = use(this._body);
return body[body.length - 1] === part;
}
protected buildRuleBody() {
return cssRuleSetBody(
dom.forEach(this._body, part => part.buildDom()),
);
}
}
class ColumnObsRuleSet extends ObsRuleSet {
private _colIds = Observable.create<string[]>(this, this._initialColIds);
private _colIdStr = Computed.create(this, (use) => use(this._colIds).join(", "));
constructor(tableRules: TableRules, ruleSet: RuleSet|undefined, private _initialColIds: string[]) {
super(tableRules, ruleSet);
const baseIsChanged = this.isChanged;
this.isChanged = Computed.create(this, (use) =>
!isEqual(use(this._colIds), this._initialColIds) || use(baseIsChanged));
}
public buildDom() {
const saveColIds = async (colIdStr: string) => {
this._colIds.set(colIdStr.split(',').map(val => val.trim()).filter(Boolean));
};
return cssRuleSet(
cssResource('Columns', textInput(this._colIdStr, saveColIds),
testId('rule-resource')
),
this.buildRuleBody(),
testId('rule-set'),
);
}
public getColIdList(): string[] {
return this._colIds.get();
}
public getColIds(): string {
return this._colIds.get().join(",");
}
}
class DefaultObsRuleSet extends ObsRuleSet {
constructor(tableRules: TableRules|null, private _haveColumnRules?: Observable<boolean>, ruleSet?: RuleSet) {
super(tableRules, ruleSet);
}
public buildDom() {
return cssRuleSet(
cssResource(dom.text(use => this._haveColumnRules && use(this._haveColumnRules) ?
'Remaining Columns' : 'All Columns'),
testId('rule-resource')
),
this.buildRuleBody(),
testId('rule-set'),
);
}
}
class ObsUserAttributeRule extends Disposable {
public isChanged: Computed<boolean>;
private _name = Observable.create<string>(this, this._userAttr?.name || '');
private _tableId = Observable.create<string>(this, this._userAttr?.tableId || '');
private _lookupColId = Observable.create<string>(this, this._userAttr?.lookupColId || '');
private _charId = Observable.create<string>(this, this._userAttr?.charId || '');
constructor(private _accessRules: AccessRules, private _userAttr?: UserAttributeRule) {
super();
this.isChanged = Computed.create(this, use => (
use(this._name) !== this._userAttr?.name ||
use(this._tableId) !== this._userAttr?.tableId ||
use(this._lookupColId) !== this._userAttr?.lookupColId ||
use(this._charId) !== this._userAttr?.charId
));
}
public buildDom() {
return cssUserAttribute(
cssConditionInput(this._name, async (val) => this._name.set(val),
{placeholder: 'New attribute name'}),
cssConditionInput(this._tableId, async (val) => this._tableId.set(val),
{placeholder: 'Table ID'}),
cssConditionInput(this._lookupColId, async (val) => this._lookupColId.set(val),
{placeholder: 'Column to look up'}),
cssConditionInput(this._charId, async (val) => this._charId.set(val),
{placeholder: 'User attribute to look up'}),
cssIconButton(icon('Remove'), {style: 'margin-left: 4px'},
dom.on('click', () => this._accessRules.removeUserAttributes(this)))
);
}
public getRule() {
const spec = {
name: this._name.get(),
tableId: this._tableId.get(),
lookupColId: this._lookupColId.get(),
charId: this._charId.get(),
};
for (const [prop, value] of Object.entries(spec)) {
if (!value) {
throw new UserError(`Invalid user attribute rule: ${prop} must be set`);
}
}
return {
id: this._userAttr?.origRecord?.id,
rulePos: this._userAttr?.origRecord?.rulePos as number|undefined,
userAttributes: JSON.stringify(spec),
};
}
}
// Represents one line of a RuleSet, a combination of an aclFormula and permissions to apply to
// requests that match it.
class ObsRulePart extends Disposable {
// Whether rules changed. Always true if this._rulePart is undefined.
public isChanged: Computed<boolean>;
// Formula to show in the "advanced" UI.
private _aclFormula = Observable.create<string>(this, this._rulePart?.aclFormula || "");
// The permission bits.
private _permissions = Observable.create<PartialPermissionSet>(
this, this._rulePart?.permissions || emptyPermissionSet());
private _permissionsText = Computed.create(this, this._permissions, (use, p) => permissionSetToText(p));
// rulePart is omitted for a new ObsRulePart added by the user.
constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart,
public readonly isDefault: boolean = (_rulePart?.aclFormula === '')) {
super();
this.isChanged = Computed.create(this, (use) => {
return (use(this._aclFormula) !== this._rulePart?.aclFormula ||
!isEqual(use(this._permissions), this._rulePart?.permissions));
});
}
public getRulePart(): RuleRec {
// Use id of 0 to distinguish built-in rules from newly added rule, which will have id of undefined.
const id = this.isBuiltIn() ? 0 : this._rulePart?.origRecord?.id;
return {
id,
aclFormula: this._aclFormula.get(),
permissionsText: permissionSetToText(this._permissions.get()),
rulePos: this._rulePart?.origRecord?.rulePos as number|undefined,
};
}
public buildDom() {
return cssRulePart(
(this._isNonFirstBuiltIn() ?
cssIconSpace({style: 'margin-right: 4px'}) :
cssIconButton(icon('Plus'), {style: 'margin-right: 4px'},
dom.on('click', () => this._ruleSet.addRulePart(this)),
testId('rule-add'),
)
),
cssConditionInput(
this._aclFormula, async (text) => this._aclFormula.set(text),
dom.prop('disabled', this.isBuiltIn()),
dom.prop('placeholder', (use) => {
return (
this._ruleSet.isSoleCondition(use, this) ? 'Everyone' :
this._ruleSet.isLastCondition(use, this) ? 'Everyone Else' :
'Enter Condition'
);
}),
testId('rule-acl-formula'),
),
cssPermissionsInput(
this._permissionsText, async (p) => this._permissions.set(parsePermissions(p)),
dom.prop('disabled', this.isBuiltIn()),
testId('rule-permissions')
),
(this.isBuiltIn() ?
cssIconSpace({style: 'margin-left: 4px'}) :
cssIconButton(icon('Remove'), {style: 'margin-left: 4px'},
dom.on('click', () => this._ruleSet.removeRulePart(this)),
testId('rule-remove'),
)
),
testId('rule-part'),
);
}
public isBuiltIn(): boolean {
return this._rulePart ? !this._rulePart.origRecord?.id : false;
}
private _isNonFirstBuiltIn(): boolean {
return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;
}
}
/**
* Produce and apply UserActions to create/update/remove records, to replace data in tableData
* with newRecords. Records are matched on uniqueId(record), which defaults to returning record.id
* (unique negative IDs may be used for new records). The returned Map maps uniqueId(record) to
* rowId for all existing and newly added records.
*
* TODO This is a general-purpose function, and should live in a separate module.
*/
async function syncRecords(tableData: TableData, newRecords: RowRecord[],
uniqueId: (r: RowRecord) => string = (r => String(r.id))
): Promise<Map<string, number>> {
const oldRecords = tableData.getRecords();
const oldRecordMap = new Map<string, RowRecord>(oldRecords.map(r => [uniqueId(r), r]));
const newRecordMap = new Map<string, RowRecord>(newRecords.map(r => [uniqueId(r), r]));
const removedRecords: RowRecord[] = oldRecords.filter(r => !newRecordMap.has(uniqueId(r)));
const addedRecords: RowRecord[] = newRecords.filter(r => !oldRecordMap.has(uniqueId(r)));
// Array of [before, after] pairs for changed records.
const updatedRecords: Array<[RowRecord, RowRecord]> = oldRecords.map((r): ([RowRecord, RowRecord]|null) => {
const newRec = newRecordMap.get(uniqueId(r));
const updated = newRec && {...r, ...newRec, id: r.id};
return updated && !isEqual(updated, r) ? [r, updated] : null;
}).filter(isObject);
console.log("syncRecords: removing [%s], adding [%s], updating [%s]",
removedRecords.map(uniqueId).join(", "),
addedRecords.map(uniqueId).join(", "),
updatedRecords.map(([r]) => uniqueId(r)).join(", "));
const userActions: UserAction[] = [];
if (removedRecords.length > 0) {
userActions.push(['BulkRemoveRecord', removedRecords.map(r => r.id)]);
}
if (updatedRecords.length > 0) {
userActions.push(['BulkUpdateRecord', updatedRecords.map(([r]) => r.id), getColChanges(updatedRecords)]);
}
let addActionIndex: number = -1;
if (addedRecords.length > 0) {
addActionIndex = userActions.length;
userActions.push(['BulkAddRecord', addedRecords.map(r => null), getColValues(addedRecords)]);
}
const rowIdMap = new Map<string, number>();
oldRecords.forEach((r) => rowIdMap.set(uniqueId(r), r.id));
if (userActions.length > 0) {
const results = await tableData.sendTableActions(userActions);
const newRowIds = results[addActionIndex];
addedRecords.forEach((r, i) => rowIdMap.set(uniqueId(r), newRowIds[i]));
}
return rowIdMap;
}
/**
* Convert a list of rows into an object with columns of values, used for
* BulkAddRecord/BulkUpdateRecord actions.
*/
function getColValues(records: RowRecord[]): BulkColValues {
const colIdSet = new Set<string>();
for (const r of records) {
for (const c of Object.keys(r)) {
if (c !== 'id') {
colIdSet.add(c);
}
}
}
const result: BulkColValues = {};
for (const colId of colIdSet) {
result[colId] = records.map(r => r[colId]);
}
return result;
}
/**
* Convert a list of [before, after] rows into an object of changes, skipping columns which
* haven't changed.
*/
function getColChanges(pairs: Array<[RowRecord, RowRecord]>): BulkColValues {
const colIdSet = new Set<string>();
for (const [before, after] of pairs) {
for (const c of Object.keys(after)) {
if (c !== 'id' && !isEqual(before[c], after[c])) {
colIdSet.add(c);
}
}
}
const result: BulkColValues = {};
for (const colId of colIdSet) {
result[colId] = pairs.map(([before, after]) => after[colId]);
}
return result;
}
function serializeResource(rec: RowRecord): string {
return JSON.stringify([rec.tableId, rec.colIds]);
}
function flatten<T>(...args: T[][]): T[] {
return ([] as T[]).concat(...args);
}
function removeItem<T>(observableArray: MutableObsArray<T>, item: T): boolean {
const i = observableArray.get().indexOf(item);
if (i >= 0) {
observableArray.splice(i, 1);
return true;
}
return false;
}
const cssAddTableRow = styled('div', `
margin: 0 64px 16px 64px;
margin: 16px 64px 0 64px;
display: flex;
justify-content: flex-end;
gap: 16px;
`);
const cssTableRule = styled('div', `
margin: 16px 64px;
border: 1px solid ${colors.darkGrey};
border-radius: 4px;
padding: 8px 16px 16px 16px;
margin: 24px 64px;
`);
const cssTableBody = styled('div', `
border: 2px solid ${colors.slate};
border-radius: 8px;
`);
const cssTableHeader = styled('div', `
display: flex;
align-items: center;
margin-bottom: 8px;
font-weight: bold;
color: ${colors.slate};
`);
const cssTableBody = styled('div', `
const cssTableName = styled('span', `
color: ${colors.dark};
`);
const cssRuleSet = styled('div', `
display: flex;
align-items: center;
border-bottom: 2px solid ${colors.slate};
&:last-child {
border-bottom: none;
}
`);
const cssPermissions = styled('div', `
const cssResource = styled('div', `
flex: 1;
white-space: nowrap;
color: ${colors.lightGreen};
display: flex;
flex-direction: column;
border-right: 2px solid ${colors.slate};
padding: 8px;
min-width: 0;
`);
const cssPrincipals = styled('div', `
const cssRuleSetBody = styled('div', `
flex: 4;
display: flex;
flex-direction: column;
padding: 8px;
min-width: 0;
`);
const cssRulePart = styled('div', `
display: flex;
align-items: center;
margin: 4px 0;
&-default {
margin-top: auto;
}
`);
const cssConditionInput = styled(textInput, `
display: flex;
min-width: 0;
flex: 1;
color: ${colors.lightGreen};
&[disabled] {
background-color: ${colors.mediumGreyOpaque};
color: ${colors.dark};
}
`);
const cssPermissionsInput = styled(cssConditionInput, `
margin-left: 8px;
width: 64px;
flex: none;
`);
const cssRemove = styled('div', `
const cssIconSpace = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
`);
const cssIconButton = styled(cssIconSpace, `
padding: 4px;
border-radius: 3px;
line-height: 0px;
cursor: default;
--icon-color: ${colors.slate};
&:hover {
@ -201,3 +856,10 @@ const cssRemove = styled('div', `
--icon-color: ${colors.slate};
}
`);
const cssUserAttribute = styled('div', `
display: flex;
align-items: center;
gap: 16px;
margin: 16px 8px;
`);

@ -6,22 +6,28 @@ import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/clie
import { cssLinkText, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon';
import { colors } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons';
import { commonUrls } from 'app/common/gristUrls';
import { Disposable, dom, makeTestId, Observable, styled } from "grainjs";
const testId = makeTestId('test-tools-');
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
const isEfcr = (gristDoc.app.topAppModel.productFlavor === 'efcr');
const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI);
return cssTools(
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
cssSectionHeader("TOOLS"),
isEfcr ? cssPageEntry(
cssPageLink(cssPageIcon('FieldReference'), cssLinkText('eFC-Connect'),
{href: commonUrls.efcrConnect, target: '_blank'}),
) : null,
(aclUIEnabled ?
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
cssPageLink(cssPageIcon('EyeShow'),
cssLinkText('Access Rules'),
urlState().setLinkUrl({docPage: 'acl'})
),
testId('access-rules'),
) :
null
),
cssPageEntry(
cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'),
dom.on('click', () => gristDoc.showTool('docHistory')))

@ -21,15 +21,15 @@ import {urlState} from 'app/client/models/gristUrlState';
import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel';
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel';
import {AccessRules} from 'app/client/ui/AccessRules';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
import {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals';
import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
export interface IUserManagerOptions {
permissionData: Promise<PermissionData>;
@ -56,9 +56,24 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
const modelObs: Observable<UserManagerModel|null> = observable(null);
const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI);
const gristDoc = aclUIEnabled ? options.docPageModel?.gristDoc.get() : null;
const accessRules = gristDoc ? AccessRules.create(null, gristDoc) : null;
const accessRulesOpen = observable(false);
async function onConfirm(ctl: IModalControl) {
const model = modelObs.get();
if (model) {
// Save changes to the server, reporting any errors to the app.
try {
if (model.isAnythingChanged.get()) {
await model.save(userApi, options.resourceId);
}
await options.onSave?.();
ctl.close();
} catch (err) {
reportError(err);
}
} else {
ctl.close();
}
}
// Get the model and assign it to the observable. Report errors to the app.
getModel(options)
@ -70,69 +85,37 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
cssModalTitle(
{ style: 'margin: 40px 64px 0 64px;' },
dom.domComputed(accessRulesOpen, rules =>
rules ?
['Access Rules'] :
[
`Invite people to ${renderType(options.resourceType)}`,
(options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
]
),
`Invite people to ${renderType(options.resourceType)}`,
(options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
testId('um-header')
),
cssModalBody(
dom.autoDispose(accessRules),
cssUserManagerBody(
// TODO: Show a loading indicator before the model is loaded.
dom.maybe(modelObs, model => new UserManager(model, options.linkToCopy).buildDom()),
dom.hide(accessRulesOpen),
),
cssUserManagerBody(
accessRules?.buildDom(),
dom.show(accessRulesOpen),
),
),
cssModalButtons(
{ style: 'margin: 32px 64px; display: flex;' },
bigPrimaryButton('Confirm',
dom.boolAttr('disabled', (use) => (
(!use(modelObs) || !use(use(modelObs)!.isAnythingChanged)) &&
(!accessRules || !use(accessRules.isAnythingChanged))
)),
dom.on('click', async () => {
const model = modelObs.get();
if (model) {
// Save changes to the server, reporting any errors to the app.
try {
if (model.isAnythingChanged.get()) {
await model.save(userApi, options.resourceId);
}
await accessRules?.save();
await options.onSave?.();
ctl.close();
} catch (err) {
reportError(err);
}
} else {
ctl.close();
}
}),
dom.boolAttr('disabled', (use) => !use(modelObs) || !use(use(modelObs)!.isAnythingChanged)),
dom.on('click', () => onConfirm(ctl)),
testId('um-confirm')
),
bigBasicButton('Cancel',
dom.on('click', () => ctl.close()),
testId('um-cancel')
),
(accessRules ?
bigBasicButton({style: 'margin-left: auto'},
dom.domComputed(accessRulesOpen, rules => rules ?
[cssBigIcon('Expand', cssBigIcon.cls('-reflect')), 'Back to Users'] :
['Access Rules', cssBigIcon('Expand')]
),
dom.on('click', () => accessRulesOpen.set(!accessRulesOpen.get())),
) :
null
(aclUIEnabled ?
cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
dom.text(use => (use(modelObs) && use(use(modelObs)!.isAnythingChanged)) ? 'Save & ' : ''),
'Open Access Rules',
dom.on('click', (ev) => {
ev.preventDefault();
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
}),
) : null
),
testId('um-buttons'),
)
@ -622,13 +605,9 @@ const cssUserImagePlus = styled(cssUserImage, `
}
`);
const cssBigIcon = styled(icon, `
height: 24px;
width: 24px;
margin: -8px 0 -4px 0;
&-reflect {
transform: scaleX(-1);
}
const cssAccessLink = styled(cssLink, `
align-self: center;
margin-left: auto;
`);
// Render the name "organization" as "team site" in UI

@ -235,6 +235,16 @@ export const menuItem = styled(weasel.menuItem, menuItemStyle);
export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);
/**
* A version of menuItem which runs the action on next tick, allowing the menu to close even when
* the action causes the disabling of the element being clicked.
* TODO disabling the element should not prevent the menu from closing; once fixed in weasel, this
* can be removed.
*/
export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
return menuItem(() => setTimeout(action, 0), ...args);
};
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) {
return menuItem(
cmd.run,

@ -0,0 +1,224 @@
import { parsePermissions } from 'app/common/ACLPermissions';
import { ILogger } from 'app/common/BaseAPI';
import { RowRecord } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import { AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule } from 'app/common/GranularAccessClause';
import { getSetMapValue } from 'app/common/gutil';
import sortBy = require('lodash/sortBy');
const defaultMatchFunc: AclMatchFunc = () => true;
// This is the hard-coded default RuleSet that's added to any user-created default rule.
const DEFAULT_RULE_SET: RuleSet = {
tableId: '*',
colIds: '*',
body: [{
aclFormula: "user.Access in ['editors', 'owners']",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "user.Access in ['viewers']",
matchFunc: (input) => ['viewers'].includes(String(input.user.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
aclFormula: "",
matchFunc: defaultMatchFunc,
permissions: parsePermissions('none'),
permissionsText: 'none',
}],
};
export class ACLRuleCollection {
// In the absence of rules, some checks are skipped. For now this is important to maintain all
// existing behavior. TODO should make sure checking access against default rules is equivalent
// and efficient.
private _haveRules = false;
// Map of tableId to list of column RuleSets (those with colIds other than '*')
private _columnRuleSets = new Map<string, RuleSet[]>();
// Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId).
private _tableColumnMap = new Map<string, RuleSet>();
// Map of tableId to the single default RuleSet for the table (colIds of '*')
private _tableRuleSets = new Map<string, RuleSet>();
// The default RuleSet (tableId '*', colIds '*')
private _defaultRuleSet: RuleSet = DEFAULT_RULE_SET;
// List of all tableIds mentioned in rules.
private _tableIds: string[] = [];
// Maps name to the corresponding UserAttributeRule.
private _userAttributeRules = new Map<string, UserAttributeRule>();
// Whether there are ANY user-defined rules.
public haveRules(): boolean {
return this._haveRules;
}
// Return the RuleSet for "tableId:colId", or undefined if there isn't one for this column.
public getColumnRuleSet(tableId: string, colId: string): RuleSet|undefined {
return this._tableColumnMap.get(`${tableId}:${colId}`);
}
// Return all RuleSets for "tableId:<any colId>", not including "tableId:*".
public getAllColumnRuleSets(tableId: string): RuleSet[] {
return this._columnRuleSets.get(tableId) || [];
}
// Return the RuleSet for "tableId:*".
public getTableDefaultRuleSet(tableId: string): RuleSet|undefined {
return this._tableRuleSets.get(tableId);
}
// Return the RuleSet for "*:*".
public getDocDefaultRuleSet(): RuleSet {
return this._defaultRuleSet;
}
// Return the list of all tableId mentions in ACL rules.
public getAllTableIds(): string[] {
return this._tableIds;
}
// Returns a Map of user attribute name to the corresponding UserAttributeRule.
public getUserAttributeRules(): Map<string, UserAttributeRule> {
return this._userAttributeRules;
}
/**
* Update granular access from DocData.
*/
public async update(docData: DocData, options: ReadAclOptions) {
const {ruleSets, userAttributes} = readAclRules(docData, options);
// Build a map of user characteristics rules.
const userAttributeMap = new Map<string, UserAttributeRule>();
for (const userAttr of userAttributes) {
userAttributeMap.set(userAttr.name, userAttr);
}
// Build maps of ACL rules.
const colRuleSets = new Map<string, RuleSet[]>();
const tableColMap = new Map<string, RuleSet>();
const tableRuleSets = new Map<string, RuleSet>();
const tableIds = new Set<string>();
let defaultRuleSet: RuleSet = DEFAULT_RULE_SET;
this._haveRules = (ruleSets.length > 0);
for (const ruleSet of ruleSets) {
if (ruleSet.tableId === '*') {
if (ruleSet.colIds === '*') {
defaultRuleSet = {
...ruleSet,
body: [...ruleSet.body, ...DEFAULT_RULE_SET.body],
};
} else {
// tableId of '*' cannot list particular columns.
throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`);
}
} else if (ruleSet.colIds === '*') {
tableIds.add(ruleSet.tableId);
if (tableRuleSets.has(ruleSet.tableId)) {
throw new Error(`Invalid duplicate default rule for ${ruleSet.tableId}`);
}
tableRuleSets.set(ruleSet.tableId, ruleSet);
} else {
tableIds.add(ruleSet.tableId);
getSetMapValue(colRuleSets, ruleSet.tableId, () => []).push(ruleSet);
for (const colId of ruleSet.colIds) {
tableColMap.set(`${ruleSet.tableId}:${colId}`, ruleSet);
}
}
}
// Update GranularAccess state.
this._columnRuleSets = colRuleSets;
this._tableColumnMap = tableColMap;
this._tableRuleSets = tableRuleSets;
this._defaultRuleSet = defaultRuleSet;
this._tableIds = [...tableIds];
this._userAttributeRules = userAttributeMap;
}
}
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
}
export interface ReadAclResults {
ruleSets: RuleSet[];
userAttributes: UserAttributeRule[];
}
/**
* Parse all ACL rules in the document from DocData into a list of RuleSets and of
* UserAttributeRules. This is used by both client-side code and server-side.
*/
function readAclRules(docData: DocData, {log, compile}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getTable('_grist_ACLResources')!;
const rulesTable = docData.getTable('_grist_ACLRules')!;
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
// Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, RowRecord[]>();
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 as number);
if (!resourceRec) {
log.error(`ACLRule ${rules[0].id} ignored; refers to an invalid ACLResource ${resourceId}`);
continue;
}
if (!resourceRec.tableId || !resourceRec.colIds) {
// This should only be the case for the old-style default rule/resource, which we
// intentionally ignore and skip.
continue;
}
const tableId = resourceRec.tableId as string;
const colIds = resourceRec.colIds === '*' ? '*' : (resourceRec.colIds as string).split(',');
const body: RulePart[] = [];
for (const rule of rules) {
if (rule.userAttributes) {
if (tableId !== '*' || colIds !== '*') {
log.warn(`ACLRule ${rule.id} ignored; user attributes must be on the default resource`);
continue;
}
const parsed = JSON.parse(String(rule.userAttributes));
// TODO: could perhaps use ts-interface-checker here.
if (!(parsed && typeof parsed === 'object' &&
[parsed.name, parsed.tableId, parsed.lookupColId, parsed.charId]
.every(p => p && typeof p === 'string'))) {
log.warn(`User attribute rule ${rule.id} is invalid`);
continue;
}
parsed.origRecord = rule;
userAttributes.push(parsed as UserAttributeRule);
} else if (body.length > 0 && !body[body.length - 1].aclFormula) {
log.warn(`ACLRule ${rule.id} ignored because listed after default rule`);
} else if (rule.aclFormula && !rule.aclFormulaParsed) {
log.warn(`ACLRule ${rule.id} ignored because missing its parsed formula`);
} else {
body.push({
origRecord: rule,
aclFormula: String(rule.aclFormula),
matchFunc: rule.aclFormula ? compile?.(JSON.parse(String(rule.aclFormulaParsed))) : defaultMatchFunc,
permissions: parsePermissions(String(rule.permissionsText)),
permissionsText: String(rule.permissionsText),
});
}
}
const ruleSet: RuleSet = {tableId, colIds, body};
ruleSets.push(ruleSet);
}
return {ruleSets, userAttributes};
}

@ -1,18 +1,16 @@
import { emptyPermissionSet, parsePermissions, PartialPermissionSet } from 'app/common/ACLPermissions';
import { ILogger } from 'app/common/BaseAPI';
import { PartialPermissionSet } from 'app/common/ACLPermissions';
import { CellValue, RowRecord } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import { getSetMapValue } from 'app/common/gutil';
import sortBy = require('lodash/sortBy');
export interface RuleSet {
tableId: '*' | string;
colIds: '*' | string[];
// The default permissions for this resource, if set, are represented by a RulePart with
// aclFormula of "", which must be the last element of body.
body: RulePart[];
defaultPermissions: PartialPermissionSet;
}
export interface RulePart {
origRecord?: RowRecord; // Original record used to create this RulePart.
aclFormula: string;
permissions: PartialPermissionSet;
permissionsText: string; // The text version of PermissionSet, as stored.
@ -50,90 +48,9 @@ export type AclMatchFunc = (input: AclMatchInput) => boolean;
export type ParsedAclFormula = [string, ...Array<ParsedAclFormula|CellValue>];
export interface UserAttributeRule {
origRecord?: RowRecord; // Original record used to create this UserAttributeRule.
name: string; // Should be unique among UserAttributeRules.
tableId: string; // Table in which to look up an existing attribute.
lookupColId: string; // Column in tableId in which to do the lookup.
charId: string; // Attribute to look up, possibly a path. E.g. 'Email' or 'office.city'.
}
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
}
export interface ReadAclResults {
ruleSets: RuleSet[];
userAttributes: UserAttributeRule[];
}
/**
* Parse all ACL rules in the document from DocData into a list of RuleSets and of
* UserAttributeRules. This is used by both client-side code and server-side.
*/
export function readAclRules(docData: DocData, {log, compile}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getTable('_grist_ACLResources')!;
const rulesTable = docData.getTable('_grist_ACLRules')!;
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
// Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, RowRecord[]>();
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 as number);
if (!resourceRec) {
log.error(`ACLRule ${rules[0].id} ignored; refers to an invalid ACLResource ${resourceId}`);
continue;
}
if (!resourceRec.tableId || !resourceRec.colIds) {
// This should only be the case for the old-style default rule/resource, which we
// intentionally ignore and skip.
continue;
}
const tableId = resourceRec.tableId as string;
const colIds = resourceRec.colIds === '*' ? '*' : (resourceRec.colIds as string).split(',');
let defaultPermissions: PartialPermissionSet|undefined;
const body: RulePart[] = [];
for (const rule of rules) {
if (rule.userAttributes) {
if (tableId !== '*' || colIds !== '*') {
log.warn(`ACLRule ${rule.id} ignored; user attributes must be on the default resource`);
continue;
}
const parsed = JSON.parse(String(rule.userAttributes));
// TODO: could perhaps use ts-interface-checker here.
if (!(parsed && typeof parsed === 'object' &&
[parsed.name, parsed.tableId, parsed.lookupColId, parsed.charId]
.every(p => p && typeof p === 'string'))) {
throw new Error(`Invalid user attribute rule: ${parsed}`);
}
userAttributes.push(parsed as UserAttributeRule);
} else if (rule.aclFormula === '') {
defaultPermissions = parsePermissions(String(rule.permissionsText));
} else if (defaultPermissions) {
log.warn(`ACLRule ${rule.id} ignored because listed after default rule`);
} else if (!rule.aclFormulaParsed) {
log.warn(`ACLRule ${rule.id} ignored because missing its parsed formula`);
} else {
body.push({
aclFormula: String(rule.aclFormula),
matchFunc: compile?.(JSON.parse(String(rule.aclFormulaParsed))),
permissions: parsePermissions(String(rule.permissionsText)),
permissionsText: String(rule.permissionsText),
});
}
}
if (!defaultPermissions) {
// Empty permissions allow falling through to the doc-default resource.
defaultPermissions = emptyPermissionSet();
}
const ruleSet: RuleSet = {tableId, colIds, body, defaultPermissions};
ruleSets.push(ruleSet);
}
return {ruleSets, userAttributes};
}

@ -7,7 +7,7 @@ import identity = require('lodash/identity');
import pickBy = require('lodash/pickBy');
import {StringUnion} from './StringUnion';
export type IDocPage = number | 'new' | 'code';
export type IDocPage = number | 'new' | 'code' | 'acl';
// What page to show in the user's home area. Defaults to 'workspace' if a workspace is set, and
// to 'all' otherwise.
@ -291,8 +291,8 @@ export function useNewUI(newui: boolean|undefined) {
* parseDocPage is a noop if p is 'new' or 'code', otherwise parse to integer
*/
function parseDocPage(p: string) {
if (['new', 'code'].includes(p)) {
return p as 'new'|'code';
if (['new', 'code', 'acl'].includes(p)) {
return p as 'new'|'code'|'acl';
}
return parseInt(p, 10);
}

@ -789,3 +789,13 @@ export async function isLongerThan(promise: Promise<any>, timeoutMsec: number):
export function isAffirmative(parameter: any): boolean {
return ['1', 'on', 'true', 'yes'].includes(String(parameter).toLowerCase());
}
/**
* Returns whether a value is neither null nor undefined, with a type guard for the return type.
*
* This is particularly useful for filtering, e.g. if `array` includes values of type
* T|null|undefined, then TypeScript can tell that `array.filter(isObject)` has the type T[].
*/
export function isObject<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}

@ -1,22 +1,23 @@
import { MixedPermissionSet, PartialPermissionSet, TablePermissionSet } from 'app/common/ACLPermissions';
import { makePartialPermissions, mergePartialPermissions, mergePermissions } from 'app/common/ACLPermissions';
import { emptyPermissionSet, parsePermissions, toMixed } from 'app/common/ACLPermissions';
import { emptyPermissionSet, toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { ActionGroup } from 'app/common/ActionGroup';
import { createEmptyActionSummary } from 'app/common/ActionSummary';
import { Query } from 'app/common/ActiveDocAPI';
import { AsyncCreate } from 'app/common/AsyncCreate';
import { BulkAddRecord, BulkColValues, BulkRemoveRecord, CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions';
import { TableDataAction, UserAction } from 'app/common/DocActions';
import { BulkAddRecord, BulkColValues, BulkRemoveRecord, CellValue, ColValues, DocAction } from 'app/common/DocActions';
import { getTableId, isSchemaAction, TableDataAction, UserAction } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import { ErrorWithCode } from 'app/common/ErrorWithCode';
import { AclMatchInput, InfoView } from 'app/common/GranularAccessClause';
import { readAclRules, RuleSet, UserAttributeRule, UserInfo } from 'app/common/GranularAccessClause';
import { getSetMapValue } from 'app/common/gutil';
import { RuleSet, UserAttributeRule, UserInfo } from 'app/common/GranularAccessClause';
import { getSetMapValue, isObject } from 'app/common/gutil';
import { canView } from 'app/common/roles';
import { compileAclFormula } from 'app/server/lib/ACLFormula';
import { getDocSessionAccess, getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession';
import { getRowIdsFromDocAction, getRelatedRows } from 'app/server/lib/RowAccess';
import * as log from 'app/server/lib/log';
import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
import cloneDeep = require('lodash/cloneDeep');
import get = require('lodash/get');
import pullAt = require('lodash/pullAt');
@ -59,24 +60,6 @@ const SURPRISING_ACTIONS = new Set([
// Actions we'll allow unconditionally for now.
const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']);
// This is the hard-coded default RuleSet that's added to any user-created default rule.
const DEFAULT_RULE_SET: RuleSet = {
tableId: '*',
colIds: '*',
body: [{
aclFormula: "user.Role in ['editors', 'owners']",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "user.Role in ['viewers']",
matchFunc: (input) => ['viewers'].includes(String(input.user.Access)),
permissions: parsePermissions('+R'),
permissionsText: 'none',
}],
defaultPermissions: parsePermissions('none'),
};
/**
*
* Manage granular access to a document. This allows nuances other than the coarse
@ -85,28 +68,8 @@ const DEFAULT_RULE_SET: RuleSet = {
*
*/
export class GranularAccess {
// In the absence of rules, some checks are skipped. For now this is important to maintain all
// existing behavior. TODO should make sure checking access against default rules is equivalent
// and efficient.
private _haveRules = false;
// Map of tableId to list of column RuleSets (those with colIds other than '*')
private _columnRuleSets = new Map<string, RuleSet[]>();
// Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId).
private _tableColumnMap = new Map<string, RuleSet>();
// Map of tableId to the single default RuleSet for the table (colIds of '*')
private _tableRuleSets = new Map<string, RuleSet>();
// The default RuleSet (tableId '*', colIds '*')
private _defaultRuleSet: RuleSet = DEFAULT_RULE_SET;
// List of all tableIds mentioned in rules.
private _tableIds: string[] = [];
// Maps name to the corresponding UserAttributeRule.
private _userAttributeRules = new Map<string, UserAttributeRule>();
// The collection of all rules, with helpful accessors.
private _ruleCollection = new ACLRuleCollection();
// Cache any tables that we need to look-up for access control decisions.
// This is an unoptimized implementation that is adequate if the tables
@ -125,82 +88,12 @@ export class GranularAccess {
public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise<TableDataAction>) {
}
// Return the RuleSet for "tableId:colId", or undefined if there isn't one for this column.
public getColumnRuleSet(tableId: string, colId: string): RuleSet|undefined {
return this._tableColumnMap.get(`${tableId}:${colId}`);
}
// Return all RuleSets for "tableId:<any colId>", not including "tableId:*".
public getAllColumnRuleSets(tableId: string): RuleSet[] {
return this._columnRuleSets.get(tableId) || [];
}
// Return the RuleSet for "tableId:*".
public getTableDefaultRuleSet(tableId: string): RuleSet|undefined {
return this._tableRuleSets.get(tableId);
}
// Return the RuleSet for "*:*".
public getDocDefaultRuleSet(): RuleSet {
return this._defaultRuleSet;
}
// Return the list of all tableId mentions in ACL rules.
public getAllTableIds(): string[] {
return this._tableIds;
}
/**
* Update granular access from DocData.
*/
public async update() {
const {ruleSets, userAttributes} = readAclRules(this._docData, {log, compile: compileAclFormula});
// Build a map of user characteristics rules.
const userAttributeMap = new Map<string, UserAttributeRule>();
for (const userAttr of userAttributes) {
userAttributeMap.set(userAttr.name, userAttr);
}
// Build maps of ACL rules.
const colRuleSets = new Map<string, RuleSet[]>();
const tableColMap = new Map<string, RuleSet>();
const tableRuleSets = new Map<string, RuleSet>();
let defaultRuleSet: RuleSet = DEFAULT_RULE_SET;
this._haveRules = (ruleSets.length > 0);
for (const ruleSet of ruleSets) {
if (ruleSet.tableId === '*') {
if (ruleSet.colIds === '*') {
defaultRuleSet = {
...ruleSet,
body: [...ruleSet.body, ...DEFAULT_RULE_SET.body],
defaultPermissions: DEFAULT_RULE_SET.defaultPermissions,
};
} else {
// tableId of '*' cannot list particular columns.
throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`);
}
} else if (ruleSet.colIds === '*') {
if (tableRuleSets.has(ruleSet.tableId)) {
throw new Error(`Invalid duplicate default rule for ${ruleSet.tableId}`);
}
tableRuleSets.set(ruleSet.tableId, ruleSet);
} else {
getSetMapValue(colRuleSets, ruleSet.tableId, () => []).push(ruleSet);
for (const colId of ruleSet.colIds) {
tableColMap.set(`${ruleSet.tableId}:${colId}`, ruleSet);
}
}
}
await this._ruleCollection.update(this._docData, {log, compile: compileAclFormula});
// Update GranularAccess state.
this._columnRuleSets = colRuleSets;
this._tableColumnMap = tableColMap;
this._tableRuleSets = tableRuleSets;
this._defaultRuleSet = defaultRuleSet;
this._tableIds = [...new Set([...colRuleSets.keys(), ...tableRuleSets.keys()])];
this._userAttributeRules = userAttributeMap;
// Also clear the per-docSession cache of rule evaluations.
this._permissionInfoMap = new WeakMap();
// TODO: optimize this.
@ -232,7 +125,7 @@ export class GranularAccess {
* document mutation).
*/
public async beforeBroadcast(docActions: DocAction[], undo: DocAction[]) {
if (!this._haveRules) { return; }
if (!this._ruleCollection.haveRules()) { return false; }
// Prepare to compute row snapshots if it turns out we need them.
// If we never need them, they will never be computed.
@ -386,7 +279,7 @@ export class GranularAccess {
* access is simple and without nuance.
*/
public hasNuancedAccess(docSession: OptDocSession): boolean {
if (!this._haveRules) { return false; }
if (!this._ruleCollection.haveRules()) { return false; }
return !this.hasFullAccess(docSession);
}
@ -445,14 +338,16 @@ export class GranularAccess {
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
const censoredColumnCodes: Set<string> = new Set();
const permInfo = this._getAccess(docSession);
for (const tableId of this.getAllTableIds()) {
for (const tableId of this._ruleCollection.getAllTableIds()) {
const tableAccess = permInfo.getTableAccess(tableId);
let tableRef: number|undefined = 0;
if (tableAccess.read === 'deny') {
tableRef = this._docData.getTable('_grist_Tables')?.findRow('tableId', tableId);
if (tableRef) { censoredTables.add(tableRef); }
}
for (const ruleSet of this.getAllColumnRuleSets(tableId)) {
// TODO If some columns are allowed and the rest (*) are denied, we need to be able to
// censor all columns outside a set.
for (const ruleSet of this._ruleCollection.getAllColumnRuleSets(tableId)) {
if (Array.isArray(ruleSet.colIds)) {
for (const colId of ruleSet.colIds) {
if (permInfo.getColumnAccess(tableId, colId).read === 'deny') {
@ -696,7 +591,7 @@ export class GranularAccess {
for (let idx = 0; idx < rowIds.length; idx++) {
rowCursor.index = getDataIndex(idx);
const rowPermInfo = new PermissionInfo(this, input);
const rowPermInfo = new PermissionInfo(this._ruleCollection, input);
// getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess.
const rowAccess = rowPermInfo.getTableAccess(tableId);
if (rowAccess.read === 'deny') {
@ -727,13 +622,13 @@ export class GranularAccess {
const rowCursor = new RecordView(data, 0);
const input: AclMatchInput = {user: this._getUser(docSession), rec: rowCursor};
const [, tableId, rowIds,] = data;
const [, tableId, rowIds] = data;
const toRemove: number[] = [];
for (let idx = 0; idx < rowIds.length; idx++) {
rowCursor.index = idx;
if (!ids.has(rowIds[idx])) { continue; }
const rowPermInfo = new PermissionInfo(this, input);
const rowPermInfo = new PermissionInfo(this._ruleCollection, input);
// getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess.
const rowAccess = rowPermInfo.getTableAccess(tableId);
if (rowAccess.read === 'deny') {
@ -801,7 +696,7 @@ export class GranularAccess {
*/
private async _updateCharacteristicTables() {
this._characteristicTables.clear();
for (const userChar of this._userAttributeRules.values()) {
for (const userChar of this._ruleCollection.getUserAttributeRules().values()) {
await this._updateCharacteristicTable(userChar);
}
}
@ -839,7 +734,7 @@ export class GranularAccess {
// TODO The intent of caching is to avoid duplicating rule evaluations while processing a
// single request. Caching based on docSession is riskier since those persist across requests.
return getSetMapValue(this._permissionInfoMap as Map<OptDocSession, PermissionInfo>, docSession,
() => new PermissionInfo(this, {user: this._getUser(docSession)}));
() => new PermissionInfo(this._ruleCollection, {user: this._getUser(docSession)}));
}
/**
@ -855,7 +750,7 @@ export class GranularAccess {
user.Email = fullUser?.email || null;
user.Name = fullUser?.name || null;
for (const clause of this._userAttributeRules.values()) {
for (const clause of this._ruleCollection.getUserAttributeRules().values()) {
if (clause.name in user) {
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);
continue;
@ -935,7 +830,6 @@ function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermission
}
}
}
pset = mergePartialPermissions(pset, ruleSet.defaultPermissions);
return pset;
}
@ -948,7 +842,7 @@ class PermissionInfo {
// Construct a PermissionInfo for a particular input, which is a combination of user and
// optionally a record.
constructor(private _acls: GranularAccess, private _input: AclMatchInput) {}
constructor(private _acls: ACLRuleCollection, private _input: AclMatchInput) {}
// Get permissions for "tableId:colId", defaulting to "tableId:*" and "*:*" as needed.
// If 'mixed' is returned, different rows may have different permissions. It should never return
@ -1049,7 +943,3 @@ interface CharacteristicTable {
rowNums: Map<string, number>;
data: TableDataAction;
}
function isObject<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}

@ -57,7 +57,8 @@ export function getRelatedRows(docActions: DocAction[]): ReadonlyArray<readonly
* (even if the action is not a bulk action).
*/
export function getRowIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord |
BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | TableDataAction) {
BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData |
TableDataAction) {
const ids = docActions[2];
return (typeof ids === 'number') ? [ids] : ids;
}

Loading…
Cancel
Save