gristlabs_grist-core/app/client/ui/AccessRules.ts

204 lines
7.3 KiB
TypeScript
Raw Normal View History

/**
* UI for managing granular ACLs.
*/
import {GristDoc} from 'app/client/components/GristDoc';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {primaryButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars';
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 isEqual = require('lodash/isEqual');
interface AclState {
ownerOnlyTableIds: Set<string>;
ownerOnlyStructure: boolean;
}
const MATCH_NON_OWNER = 'user.Access != "owners"';
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')) {
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
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);
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
}
}
}
return {ownerOnlyTableIds, ownerOnlyStructure};
}
export class AccessRules extends Disposable {
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);
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),
}));
constructor(private _gristDoc: GristDoc) {
super();
this.isAnythingChanged = Computed.create(this, (use) =>
!isEqual(use(this._currentState), this._initialState));
}
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();
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]);
}
// 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'}]);
}
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
// 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]);
}
}
}
});
}
public buildDom() {
return [
cssAddTableRow(
primaryButton(icon('Plus'), 'Add Table Rules',
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),
tableId,
dom.cls('disabled', (use) => use(this._ownerOnlyTableIds).includes(tableId)),
)
),
]),
),
),
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))
),
),
cssTableBody(
cssPermissions('All Access'),
cssPrincipals('Owners'),
),
);
}),
cssTableRule(
cssTableHeader('Default Rule'),
cssTableBody(
cssPermissions('Schema Edit'),
cssPrincipals(
select(this._ownerOnlyStructure, [
{label: 'Owners Only', value: true},
{label: 'Owners & Editors', value: false}
]),
)
),
),
),
];
}
}
const cssAddTableRow = styled('div', `
margin: 0 64px 16px 64px;
display: flex;
justify-content: flex-end;
`);
const cssTableRule = styled('div', `
margin: 16px 64px;
border: 1px solid ${colors.darkGrey};
border-radius: 4px;
padding: 8px 16px 16px 16px;
`);
const cssTableHeader = styled('div', `
display: flex;
align-items: center;
margin-bottom: 8px;
`);
const cssTableBody = styled('div', `
display: flex;
align-items: center;
`);
const cssPermissions = styled('div', `
flex: 1;
white-space: nowrap;
color: ${colors.lightGreen};
`);
const cssPrincipals = styled('div', `
flex: 1;
color: ${colors.lightGreen};
`);
const cssRemove = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
border-radius: 3px;
cursor: default;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.darkGrey};
--icon-color: ${colors.slate};
}
`);