mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
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;
|
||||
}
|
||||
// tslint:disable:max-classes-per-file no-console
|
||||
|
||||
const MATCH_NON_OWNER = 'user.Access != "owners"';
|
||||
// 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};
|
||||
|
||||
function buildAclState(gristDoc: GristDoc): AclState {
|
||||
const {ruleSets} = readAclRules(gristDoc.docData, {log: console});
|
||||
console.log("FOUND RULE SETS", ruleSets);
|
||||
|
||||
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();
|
||||
|
||||
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 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);
|
||||
|
||||
// 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()),
|
||||
),
|
||||
);
|
||||
}),
|
||||
cssTableRule(
|
||||
cssTableHeader('Default Rule'),
|
||||
cssTableBody(
|
||||
cssPermissions('Schema Edit'),
|
||||
cssPrincipals(
|
||||
select(this._ownerOnlyStructure, [
|
||||
{label: 'Owners Only', value: true},
|
||||
{label: 'Owners & Editors', value: false}
|
||||
]),
|
||||
)
|
||||
),
|
||||
),
|
||||
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
|
||||
cssTableRule(
|
||||
cssTableHeader('Default Rules'),
|
||||
cssTableBody(
|
||||
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;
|
||||
border-bottom: 2px solid ${colors.slate};
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssResource = styled('div', `
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 2px solid ${colors.slate};
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
`);
|
||||
|
||||
|
||||
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 cssPermissions = styled('div', `
|
||||
const cssConditionInput = styled(textInput, `
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
color: ${colors.lightGreen};
|
||||
|
||||
&[disabled] {
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
color: ${colors.dark};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPrincipals = styled('div', `
|
||||
flex: 1;
|
||||
color: ${colors.lightGreen};
|
||||
`);
|
||||
|
||||
const cssRemove = styled('div', `
|
||||
const cssPermissionsInput = styled(cssConditionInput, `
|
||||
margin-left: 8px;
|
||||
width: 64px;
|
||||
flex: none;
|
||||
`);
|
||||
|
||||
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,
|
||||
|
224
app/common/ACLRuleCollection.ts
Normal file
224
app/common/ACLRuleCollection.ts
Normal file
@ -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});
|
||||
await this._ruleCollection.update(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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…
Reference in New Issue
Block a user