From e14488bcc8e98ea3f89728435866fb4016db51a6 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Wed, 24 Mar 2021 23:00:58 -0400 Subject: [PATCH] (core) Add support for special ACL rules, for viewing rules and downloading documents. Summary: - Use special ACLResources of the form "*SPECIAL:" to represent special document-wide rules. - Include default rules that give Read access to these resources to Owners only. - Add UI with a checkbox to give access to everyone instead. - Allow expanding the UI for advanced configuration. - These rules don't actually have any behavior yet. Test Plan: WIP Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2764 --- app/client/aclui/AccessRules.ts | 209 +++++++++++++++++++++++++++---- app/common/ACLRuleCollection.ts | 56 +++++++++ app/server/lib/GranularAccess.ts | 7 +- 3 files changed, 247 insertions(+), 25 deletions(-) diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index 1c694690..5183d0e5 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -11,21 +11,24 @@ import {reportError, UserError} from 'app/client/models/errors'; import {TableData} from 'app/client/models/TableData'; import {shadowScroll} from 'app/client/ui/shadowScroll'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {squareCheckbox} from 'app/client/ui2018/checkbox'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {textInput} from 'app/client/ui2018/editableLabel'; import {cssIconButton, icon} from 'app/client/ui2018/icons'; import {IOptionFull, menu, menuItemAsync} from 'app/client/ui2018/menus'; -import {emptyPermissionSet, MixedPermissionValue} from 'app/common/ACLPermissions'; -import {PartialPermissionSet, permissionSetToText, summarizePermissions, summarizePermissionSet} from 'app/common/ACLPermissions'; -import {ACLRuleCollection} from 'app/common/ACLRuleCollection'; -import { ApiError } from 'app/common/ApiError'; +import {emptyPermissionSet, MixedPermissionValue, PartialPermissionSet} from 'app/common/ACLPermissions'; +import {parsePermissions, permissionSetToText} from 'app/common/ACLPermissions'; +import {summarizePermissions, summarizePermissionSet} from 'app/common/ACLPermissions'; +import {ACLRuleCollection, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection'; +import {ApiError} from 'app/common/ApiError'; import {BulkColValues, RowRecord, UserAction} from 'app/common/DocActions'; -import {FormulaProperties, getFormulaProperties, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; +import {FormulaProperties, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; +import {getFormulaProperties} from 'app/common/GranularAccessClause'; import {isHiddenCol} from 'app/common/gristTypes'; import {isObject} from 'app/common/gutil'; import {SchemaTypes} from 'app/common/schema'; import {BaseObservable, Computed, Disposable, MutableObsArray, obsArray, Observable} from 'grainjs'; -import {dom, DomElementArg, styled} from 'grainjs'; +import {dom, DomElementArg, IDisposableOwner, styled} from 'grainjs'; import isEqual = require('lodash/isEqual'); // tslint:disable:max-classes-per-file no-console @@ -67,6 +70,9 @@ export class AccessRules extends Disposable { // The default rule set for the document (for "*:*"). private _docDefaultRuleSet = Observable.create(this, null); + // Special document-level rules, for resources of the form ("*SPECIAL:"). + private _specialRules = Observable.create(this, null); + // Array of all UserAttribute rules. private _userAttrRules = this.autoDispose(obsArray()); @@ -90,6 +96,7 @@ export class AccessRules extends Disposable { this._ruleStatus = Computed.create(this, (use) => { const defRuleSet = use(this._docDefaultRuleSet); const tableRules = use(this._tableRules); + const specialRules = use(this._specialRules); const userAttr = use(this._userAttrRules); return Math.max( defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged, @@ -99,6 +106,7 @@ export class AccessRules extends Disposable { getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size), ...tableRules.map(t => use(t.ruleStatus)), ...userAttr.map(u => use(u.ruleStatus)), + specialRules ? use(specialRules.ruleStatus) : RuleStatus.Unchanged, ); }); @@ -163,9 +171,16 @@ export class AccessRules extends Disposable { ]); this._tableRules.set( - rules.getAllTableIds().map(tableId => TableRules.create(this._tableRules, + rules.getAllTableIds() + .filter(tableId => (tableId !== SPECIAL_RULES_TABLE_ID)) + .map(tableId => TableRules.create(this._tableRules, tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId))) ); + + SpecialRules.create(this._specialRules, SPECIAL_RULES_TABLE_ID, this, + rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID), + rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)); + DefaultObsRuleSet.create(this._docDefaultRuleSet, this, null, undefined, rules.getDocDefaultRuleSet()); this._userAttrRules.set( Array.from(rules.getUserAttributeRules().values(), userAttr => @@ -188,7 +203,9 @@ export class AccessRules extends Disposable { // Add/remove resources to have just the ones we need. const newResources: RowRecord[] = flatten( - [{tableId: '*', colIds: '*'}], ...this._tableRules.get().map(t => t.getResources())) + [{tableId: '*', colIds: '*'}], + this._specialRules.get()?.getResources() || [], + ...this._tableRules.get().map(t => t.getResources())) .map(r => ({id: -1, ...r})); // Prepare userActions and a mapping of serializedResource to rowIds. @@ -346,7 +363,8 @@ export class AccessRules extends Disposable { dom.maybe(this._docDefaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()), ), testId('rule-table'), - ) + ), + dom.maybe(this._specialRules, tableRules => tableRules.buildDom()), ), ); } @@ -357,6 +375,7 @@ export class AccessRules extends Disposable { public getRules(): RuleRec[] { return flatten( ...this._tableRules.get().map(t => t.getRules()), + this._specialRules.get()?.getRules() || [], this._docDefaultRuleSet.get()?.getRules('*') || [] ); } @@ -379,7 +398,7 @@ export class AccessRules extends Disposable { // Check if the given tableId, and optionally a list of colIds, are present in this document. // Returns '' if valid, or an error string if not. Exempt colIds will not trigger an error. public checkTableColumns(tableId: string, colIds?: string[], exemptColIds?: string[]): string { - if (!tableId) { return ''; } + if (!tableId || tableId === SPECIAL_RULES_TABLE_ID) { return ''; } const tableColIds = this._aclResources[tableId]; if (!tableColIds) { return `Invalid table: ${tableId}`; } if (colIds) { @@ -415,7 +434,7 @@ class TableRules extends Disposable { public ruleStatus: Computed; // The column-specific rule sets. - private _columnRuleSets = this.autoDispose(obsArray()); + protected _columnRuleSets = this.autoDispose(obsArray()); // Whether there are any column-specific rule sets. private _haveColumnRules = Computed.create(this, this._columnRuleSets, (use, cols) => cols.length > 0); @@ -427,7 +446,7 @@ class TableRules extends Disposable { private _colRuleSets?: RuleSet[], private _defRuleSet?: RuleSet) { super(); this._columnRuleSets.set(this._colRuleSets?.map(rs => - ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, rs, + this._createColumnObsRuleSet(this._columnRuleSets, this._accessRules, this, rs, rs.colIds === '*' ? [] : rs.colIds)) || []); if (!this._colRuleSets) { @@ -479,14 +498,24 @@ class TableRules extends Disposable { ) ), ), - dom.forEach(this._columnRuleSets, ruleSet => ruleSet.buildRuleSetDom()), - dom.maybe(this._defaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()), + this.buildColumnRuleSets(), ), - dom.forEach(this._columnRuleSets, c => cssConditionError(dom.text(c.formulaError))), + this.buildErrors(), testId('rule-table'), ); } + public buildColumnRuleSets() { + return [ + dom.forEach(this._columnRuleSets, ruleSet => ruleSet.buildRuleSetDom()), + dom.maybe(this._defaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()), + ]; + } + + public buildErrors() { + return dom.forEach(this._columnRuleSets, c => cssConditionError(dom.text(c.formulaError))); + } + /** * Return the resources (tableId:colIds entities), for saving, checking along the way that they * are valid. @@ -557,6 +586,13 @@ class TableRules extends Disposable { } } + protected _createColumnObsRuleSet( + owner: IDisposableOwner, accessRules: AccessRules, tableRules: TableRules, + ruleSet: RuleSet|undefined, initialColIds: string[], + ): ColumnObsRuleSet { + return ColumnObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds); + } + private _addColumnRuleSet() { this._columnRuleSets.push(ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, [])); } @@ -568,6 +604,30 @@ class TableRules extends Disposable { } } +class SpecialRules extends TableRules { + public buildDom() { + return cssSection( + cssSectionHeading('Special Rules', testId('rule-table-header')), + this.buildColumnRuleSets(), + this.buildErrors(), + testId('rule-table'), + ); + } + + public getResources(): ResourceRec[] { + return this._columnRuleSets.get() + .filter(rs => !rs.hasOnlyBuiltInRules()) + .map(rs => ({tableId: this.tableId, colIds: rs.getColIds()})); + } + + protected _createColumnObsRuleSet( + owner: IDisposableOwner, accessRules: AccessRules, tableRules: TableRules, + ruleSet: RuleSet|undefined, initialColIds: string[], + ): ColumnObsRuleSet { + return SpecialObsRuleSet.create(owner, accessRules, tableRules, ruleSet, initialColIds); + } +} + // 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 { @@ -576,7 +636,7 @@ abstract class ObsRuleSet extends Disposable { // 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()); + protected readonly _body = this.autoDispose(obsArray()); // ruleSet is omitted for a new ObsRuleSet added by the user. constructor(public accessRules: AccessRules, protected _tableRules: TableRules|null, private _ruleSet?: RuleSet) { @@ -656,7 +716,7 @@ abstract class ObsRuleSet extends Disposable { } /** - * When an empty-conditition RulePart is the only part of a RuleSet, we can say it applies to + * When an empty-condition RulePart is the only part of a RuleSet, we can say it applies to * "Everyone". */ public isSoleCondition(use: UseCB, part: ObsRulePart): boolean { @@ -665,7 +725,7 @@ abstract class ObsRuleSet extends Disposable { } /** - * When an empty-conditition RulePart is last in a RuleSet, we say it applies to "Everyone Else". + * When an empty-condition 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); @@ -698,6 +758,10 @@ abstract class ObsRuleSet extends Disposable { public hasColumns() { return false; } + + public hasOnlyBuiltInRules() { + return this._body.get().every(rule => rule.isBuiltIn()); + } } class ColumnObsRuleSet extends ObsRuleSet { @@ -724,7 +788,7 @@ class ColumnObsRuleSet extends ObsRuleSet { }); } - public buildResourceDom() { + public buildResourceDom(): DomElementArg { return aclColumnList(this._colIds, this.getValidColIds()); } @@ -761,6 +825,90 @@ class DefaultObsRuleSet extends ObsRuleSet { } } +function getSpecialRuleDescription(type: string): string { + switch (type) { + case 'AccessRules': + return 'Allow everyone to view Access Rules'; + case 'FullCopies': + return 'Allow everyone to download and copy the full document even if they have incomplete access to it'; + default: return type; + } +} + +function getSpecialRuleName(type: string): string { + switch (type) { + case 'AccessRules': return 'Permission to view Access Rules'; + case 'FullCopies': return 'Permission to download and copy the document in full'; + default: return type; + } +} + +class SpecialObsRuleSet extends ColumnObsRuleSet { + public buildRuleSetDom() { + const isNonStandard: Observable = Computed.create(null, this._body, (use, body) => + !body.every(rule => rule.isBuiltIn() || rule.matches(use, 'True', '+R'))); + + const allowEveryone: Observable = Computed.create(null, this._body, + (use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltIn())) + .onWrite(val => this._allowEveryone(val)); + + const isExpanded = Observable.create(null, isNonStandard.get()); + + return dom('div', + dom.autoDispose(isExpanded), + dom.autoDispose(allowEveryone), + cssRuleDescription( + cssIconButton(icon('Expand'), + dom.style('transform', (use) => use(isExpanded) ? 'rotate(90deg)' : ''), + dom.on('click', () => isExpanded.set(!isExpanded.get())), + testId('rule-special-expand'), + ), + squareCheckbox(allowEveryone, + dom.prop('disabled', isNonStandard), + testId('rule-special-checkbox'), + ), + getSpecialRuleDescription(this.getColIds()), + ), + dom.maybe(isExpanded, () => + cssTableRounded( + {style: 'margin-left: 56px'}, + cssTableHeaderRow( + cssCellIcon(), + cssCell4(cssColHeaderCell(getSpecialRuleName(this.getColIds()))), + cssCell1(cssColHeaderCell('Permissions')), + cssCellIcon(), + ), + cssTableRow( + cssRuleBody.cls(''), + dom.forEach(this._body, part => part.buildRulePartDom(true)), + ), + testId('rule-set'), + ) + ), + testId('rule-special'), + testId(`rule-special-${this.getColIds()}`), // Make accessible in tests as, e.g. rule-special-FullCopies + ); + } + + public getAvailableBits(): PermissionKey[] { + return ['read']; + } + + private _allowEveryone(value: boolean) { + const builtInRules = this._body.get().filter(r => r.isBuiltIn()); + if (value === true) { + const rulePart: RulePart = { + aclFormula: 'True', + permissionsText: '+R', + permissions: parsePermissions('+R'), + }; + this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]); + } else if (value === false) { + this._body.set(builtInRules); + } + } +} + class ObsUserAttributeRule extends Disposable { public ruleStatus: Computed; @@ -906,9 +1054,13 @@ class ObsRulePart extends Disposable { // Error message if any validation failed. private _error: Computed; - // rulePart is omitted for a new ObsRulePart added by the user. - constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart) { + // rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to + // treat the rule as new and only use the rulePart for its initialization. + constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart, isNew = false) { super(); + if (_rulePart && isNew) { + this._rulePart = undefined; + } this._error = Computed.create(this, (use) => { return use(this._formulaError) || ( !this._ruleSet.isLastCondition(use, this) && @@ -939,6 +1091,11 @@ class ObsRulePart extends Disposable { }; } + public matches(use: UseCB, aclFormula: string, permissionsText: string): boolean { + return (use(this._aclFormula) === aclFormula && + permissionSetToText(use(this._permissions)) === permissionsText); + } + /** * Check if RulePart may only add permissions, only remove permissions, or may do either. * A rule that neither adds nor removes permissions is treated as mixed for simplicity, @@ -967,7 +1124,7 @@ class ObsRulePart extends Disposable { } } - public buildRulePartDom() { + public buildRulePartDom(wide: boolean = false) { return cssColumnGroup( cssCellIcon( (this._isNonFirstBuiltIn() ? @@ -979,6 +1136,7 @@ class ObsRulePart extends Disposable { ), ), cssCell2( + wide ? cssCell4.cls('') : null, aclFormulaEditor({ initialValue: this._aclFormula.get(), readOnly: this.isBuiltIn(), @@ -1293,6 +1451,13 @@ const cssRuleBody = styled('div', ` margin: 4px 0; `); +const cssRuleDescription = styled('div', ` + display: flex; + align-items: center; + margin: 16px 0 8px 0; + gap: 8px; +`); + const cssCellContent = styled('div', ` margin: 4px 8px; `); diff --git a/app/common/ACLRuleCollection.ts b/app/common/ACLRuleCollection.ts index 41d7a91b..d72da5a1 100644 --- a/app/common/ACLRuleCollection.ts +++ b/app/common/ACLRuleCollection.ts @@ -8,6 +8,8 @@ import sortBy = require('lodash/sortBy'); const defaultMatchFunc: AclMatchFunc = () => true; +export const SPECIAL_RULES_TABLE_ID = '*SPECIAL'; + // This is the hard-coded default RuleSet that's added to any user-created default rule. const DEFAULT_RULE_SET: RuleSet = { tableId: '*', @@ -30,6 +32,39 @@ const DEFAULT_RULE_SET: RuleSet = { }], }; +const SPECIAL_RULE_SETS: Record = { + AccessRules: { + tableId: SPECIAL_RULES_TABLE_ID, + colIds: ['AccessRules'], + body: [{ + aclFormula: "user.Access in [OWNER]", + matchFunc: (input) => ['owners'].includes(String(input.user.Access)), + permissions: parsePermissions('+R'), + permissionsText: '+R', + }, { + aclFormula: "", + matchFunc: defaultMatchFunc, + permissions: parsePermissions('none'), + permissionsText: 'none', + }], + }, + FullCopies: { + tableId: SPECIAL_RULES_TABLE_ID, + colIds: ['FullCopies'], + body: [{ + aclFormula: "user.Access in [OWNER]", + matchFunc: (input) => ['owners'].includes(String(input.user.Access)), + permissions: parsePermissions('+R'), + permissionsText: '+R', + }, { + aclFormula: "", + matchFunc: defaultMatchFunc, + permissions: parsePermissions('none'), + permissionsText: 'none', + }], + } +}; + // If the user-created rules become dysfunctional, we can swap in this emergency set. // It grants full access to owners, and no access to anyone else. const EMERGENCY_RULE_SET: RuleSet = { @@ -59,6 +94,7 @@ export class ACLRuleCollection { private _haveRules = false; // Map of tableId to list of column RuleSets (those with colIds other than '*') + // Includes also SPECIAL_RULES_TABLE_ID. private _columnRuleSets = new Map(); // Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId). @@ -130,6 +166,24 @@ export class ACLRuleCollection { const tableIds = new Set(); let defaultRuleSet: RuleSet = DEFAULT_RULE_SET; + // Collect special rules, combining them with corresponding defaults. + const specialRuleSets = new Map(Object.entries(SPECIAL_RULE_SETS)); + for (const ruleSet of ruleSets) { + if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) { + const specialType = String(ruleSet.colIds); + const specialDefault = specialRuleSets.get(specialType); + if (!specialDefault) { + throw new Error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`); + } + specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]}); + } + } + + // Insert the special rule sets into colRuleSets. + for (const ruleSet of specialRuleSets.values()) { + getSetMapValue(colRuleSets, SPECIAL_RULES_TABLE_ID, () => []).push(ruleSet); + } + this._haveRules = (ruleSets.length > 0); for (const ruleSet of ruleSets) { if (ruleSet.tableId === '*') { @@ -142,6 +196,8 @@ export class ACLRuleCollection { // tableId of '*' cannot list particular columns. throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`); } + } else if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) { + // Skip, since we handled these separately earlier. } else if (ruleSet.colIds === '*') { tableIds.add(ruleSet.tableId); if (tableRuleSets.has(ruleSet.tableId)) { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index f08bb122..3d20e6c3 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -21,7 +21,8 @@ import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { DocClients } from 'app/server/lib/DocClients'; import { getDocSessionAccess, getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession'; import * as log from 'app/server/lib/log'; -import { IPermissionInfo, PermissionInfo, PermissionSetWithContext, TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo'; +import { IPermissionInfo, PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo'; +import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo'; import { integerParam } from 'app/server/lib/requestUtils'; import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess'; import cloneDeep = require('lodash/cloneDeep'); @@ -135,7 +136,7 @@ export class GranularAccess implements GranularAccessForBundle { // affected rows for the relevant table before and after each DocAction. It // may contain some unaffected rows as well. Other metadata is included if // needed. - private _steps: Promise>|null = null; + private _steps: Promise|null = null; // Access control is done sequentially, bundle by bundle. This is the current bundle. private _activeBundle: { docSession: OptDocSession, @@ -173,7 +174,7 @@ export class GranularAccess implements GranularAccessForBundle { * Update granular access from DocData. */ public async update() { - this._ruler.update(this._docData); + await this._ruler.update(this._docData); // Also clear the per-docSession cache of user attributes. this._userAttributesMap = new WeakMap();