mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user