(core) Implement new representation of ACL rules.

Summary:
- Added fields to _grist_ACLRules for the new Granular ACL representation
- Include a corresponding migration.

- Added ACLPermissions module with merging PermissionSets and converting to/from string.
- Implemented parsing of ACL formulas and compiling them into JS functions.
- Add automatic parsing of ACL formulas when ACLRules are added or updated.
- Convert GranularAccess to load and interpret new-style rules.
- Convert ACL UI to load and save new-style rules.

For now, no attempt to do anything better on the server or UI side, only to
reproduce previous behavior.

Test Plan: Added unittests for new files; fixed those for existing files.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2664
This commit is contained in:
Dmitry S
2020-11-17 16:49:32 -05:00
parent c042935c58
commit bc3a472324
12 changed files with 1131 additions and 482 deletions

View File

@@ -0,0 +1,163 @@
/**
* Internal and DB representation of permission bits. These may be set to on, off, or omitted.
*
* In DB, permission sets are represented as strings of the form '[+<bits>][-<bits>]' where <bits>
* is a string of C,R,U,D,S characters, each appearing at most once; or the special values 'all'
* or 'none'. Note that empty string is also valid, and corresponds to the PermissionSet {}.
*/
// tslint:disable:no-namespace
import fromPairs = require('lodash/fromPairs');
import mapValues = require('lodash/mapValues');
// A PermissionValue is the result of evaluating rules. It provides a definitive answer.
export type PermissionValue = "allow" | "deny";
// A MixedPermissionValue is the result of evaluating rules without a record. If some rules
// require a record, and some records may be allowed and some denied, the result is "mixed".
export type MixedPermissionValue = PermissionValue | "mixed";
// Similar to MixedPermissionValue, but if permission for a table depend on columns and NOT on
// rows, the result is "mixedColumns" rather than "mixed", which allows some optimizations.
export type TablePermissionValue = MixedPermissionValue | "mixedColumns";
// PartialPermissionValue is only used transiently while evaluating rules without a record.
export type PartialPermissionValue = PermissionValue | "allowSome" | "denySome" | "mixed" | "";
/**
* Internal representation of a set of permission bits.
*/
export interface PermissionSet<T = PermissionValue> {
read: T;
create: T;
update: T;
delete: T;
schemaEdit: T;
}
// Some shorter type aliases.
export type PartialPermissionSet = PermissionSet<PartialPermissionValue>;
export type MixedPermissionSet = PermissionSet<MixedPermissionValue>;
export type TablePermissionSet = PermissionSet<TablePermissionValue>;
const PERMISSION_BITS: {[letter: string]: keyof PermissionSet} = {
R: 'read',
C: 'create',
U: 'update',
D: 'delete',
S: 'schemaEdit',
};
const ALL_PERMISSION_BITS = "CRUDS";
export const ALL_PERMISSION_PROPS: Array<keyof PermissionSet> =
Array.from(ALL_PERMISSION_BITS, ch => PERMISSION_BITS[ch]);
const ALIASES: {[key: string]: string} = {
all: '+CRUDS',
none: '-CRUDS',
};
const REVERSE_ALIASES = fromPairs(Object.entries(ALIASES).map(([alias, value]) => [value, alias]));
// Comes in useful for initializing unset PermissionSets.
export function emptyPermissionSet(): PartialPermissionSet {
return {read: "", create: "", update: "", delete: "", schemaEdit: ""};
}
/**
* Convert a short string representation to internal.
*/
export function parsePermissions(permissionsText: string): PartialPermissionSet {
if (ALIASES.hasOwnProperty(permissionsText)) {
permissionsText = ALIASES[permissionsText];
}
const pset: PartialPermissionSet = emptyPermissionSet();
let value: PartialPermissionValue = "";
for (const ch of permissionsText) {
if (ch === '+') {
value = "allow";
} else if (ch === '-') {
value = "deny";
} else if (!PERMISSION_BITS.hasOwnProperty(ch) || value === "") {
throw new Error(`Invalid permissions specification ${JSON.stringify(permissionsText)}`);
} else {
const prop = PERMISSION_BITS[ch];
pset[prop] = value;
}
}
return pset;
}
/**
* Convert an internal representation of permission bits to a short string. Note that there should
* be no values other then "allow" and "deny", since anything else will NOT be included.
*/
export function permissionSetToText(permissionSet: Partial<PartialPermissionSet>): string {
let add = "";
let remove = "";
for (const ch of ALL_PERMISSION_BITS) {
const prop: keyof PermissionSet = PERMISSION_BITS[ch];
const value = permissionSet[prop];
if (value === "allow") {
add += ch;
} else if (value === "deny") {
remove += ch;
}
}
const perm = (add ? "+" + add : "") + (remove ? "-" + remove : "");
return REVERSE_ALIASES[perm] || perm;
}
/**
* Replace allow/deny with allowSome/denySome to indicate dependence on rows.
*/
export function makePartialPermissions(pset: PartialPermissionSet): PartialPermissionSet {
return mapValues(pset, val => (val === "allow" ? "allowSome" : (val === "deny" ? "denySome" : val)));
}
/**
* Combine PartialPermissions. Earlier rules win. Note that allowAll|denyAll|mixed are final
* results (further permissions can't change them), but allowSome|denySome may be changed by
* further rules into either allowAll|denyAll or mixed.
*
* Note that this logic satisfies associative property: (a + b) + c == a + (b + c).
*/
function combinePartialPermission(a: PartialPermissionValue, b: PartialPermissionValue): PartialPermissionValue {
if (!a) { return b; }
if (!b) { return a; }
// If the first is uncertain, the second may keep it unchanged, or make certain, or finalize as mixed.
if (a === 'allowSome') { return (b === 'allowSome' || b === 'allow') ? b : 'mixed'; }
if (a === 'denySome') { return (b === 'denySome' || b === 'deny') ? b : 'mixed'; }
// If the first is certain, it's not affected by the second.
return a;
}
/**
* Combine PartialPermissionSets.
*/
export function mergePartialPermissions(a: PartialPermissionSet, b: PartialPermissionSet): PartialPermissionSet {
return mergePermissions([a, b], ([_a, _b]) => combinePartialPermission(_a, _b));
}
/**
* Merge a list of PermissionSets by combining individual bits.
*/
export function mergePermissions<T, U>(psets: Array<PermissionSet<T>>, combine: (bits: T[]) => U
): PermissionSet<U> {
const result: Partial<PermissionSet<U>> = {};
for (const prop of ALL_PERMISSION_PROPS) {
result[prop] = combine(psets.map(p => p[prop]));
}
return result as PermissionSet<U>;
}
/**
* Convert a PartialPermissionSet to MixedPermissionSet by replacing any remaining uncertain bits
* with 'denyAll'. When rules are properly combined it should never be needed because the
* hard-coded fallback rules should finalize all bits.
*/
export function toMixed(pset: PartialPermissionSet): MixedPermissionSet {
return mergePermissions([pset], ([bit]) => (bit === 'allow' || bit === 'mixed' ? bit : 'deny'));
}

View File

@@ -1,130 +1,139 @@
import { safeJsonParse } from 'app/common/gutil';
import { CellValue } from 'app/plugin/GristData';
import { emptyPermissionSet, parsePermissions, PartialPermissionSet } from 'app/common/ACLPermissions';
import { ILogger } from 'app/common/BaseAPI';
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[];
body: RulePart[];
defaultPermissions: PartialPermissionSet;
}
export interface RulePart {
aclFormula: string;
permissions: PartialPermissionSet;
permissionsText: string; // The text version of PermissionSet, as stored.
// Compiled version of aclFormula.
matchFunc?: AclMatchFunc;
}
// Light wrapper around characteristics or records.
export interface InfoView {
get(key: string): CellValue;
toJSON(): {[key: string]: any};
}
// Represents user info, which may include properties which are themselves RowRecords.
export type UserInfo = Record<string, CellValue|InfoView>;
/**
* All possible access clauses. In future the clauses will become more generalized.
* The consequences of clauses are currently combined in a naive and ad-hoc way,
* this will need systematizing.
* Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean.
*/
export type GranularAccessClause =
GranularAccessDocClause |
GranularAccessTableClause |
GranularAccessRowClause |
GranularAccessColumnClause |
GranularAccessCharacteristicsClause;
/**
* A clause that forbids anyone but owners from modifying the document structure.
*/
export interface GranularAccessDocClause {
kind: 'doc';
match: MatchSpec;
export interface AclMatchInput {
user: UserInfo;
rec?: InfoView;
newRec?: InfoView;
}
/**
* A clause to control access to a specific table.
* The actual boolean function that can evaluate a request. The result of compiling ParsedAclFormula.
*/
export interface GranularAccessTableClause {
kind: 'table';
tableId: string;
match: MatchSpec;
export type AclMatchFunc = (input: AclMatchInput) => boolean;
/**
* Representation of a parsed ACL formula.
*/
export type ParsedAclFormula = [string, ...Array<ParsedAclFormula|CellValue>];
export interface 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[];
}
/**
* A clause to control access to rows within a specific table.
* If "scope" is provided, this rule is simply ignored if the scope does not match
* the user.
* 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 interface GranularAccessRowClause {
kind: 'row';
tableId: string;
match: MatchSpec;
scope?: MatchSpec;
}
export function readAclRules(docData: DocData, {log, compile}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getTable('_grist_ACLResources')!;
const rulesTable = docData.getTable('_grist_ACLRules')!;
/**
* A clause to control access to columns within a specific table.
*/
export interface GranularAccessColumnClause {
kind: 'column';
tableId: string;
colIds: string[];
match: MatchSpec;
onMatch?: AccessPermissionDelta; // permissions to apply if match succeeds
onFail?: AccessPermissionDelta; // permissions to apply if match fails
}
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
/**
* A clause to make more information about the user/request available for access
* control decisions.
* - charId specifies a property of the user (e.g. Access/Email/UserID/Name, or a
* property added by another clause) to use as a key.
* - We look for a matching record in the specified table, comparing the specified
* column with the charId property. Outcome is currently unspecified if there are
* multiple matches.
* - Compare using lower case for now (because of Email). Could generalize in future.
* - All fields from a matching record are added to the variables available for MatchSpecs.
*/
export interface GranularAccessCharacteristicsClause {
kind: 'character';
tableId: string;
charId: string; // characteristic to look up
lookupColId: string; // column in which to look it up
}
/**
* A sketch of permissions, intended as a placeholder.
*/
export type AccessPermission = 'read' | 'update' | 'create' | 'delete';
export type AccessPermissions = 'all' | AccessPermission[];
export interface AccessPermissionDelta {
allow?: AccessPermissions; // permit the named operations
allowOnly?: AccessPermissions; // permit the named operations, and forbid others
forbid?: AccessPermissions; // forbid the named operations
}
// Type for expressing matches.
export type MatchSpec = ConstMatchSpec | TruthyMatchSpec | PairMatchSpec | NotMatchSpec;
// Invert a match.
export interface NotMatchSpec {
kind: 'not';
match: MatchSpec;
}
// Compare property of user with a constant.
export interface ConstMatchSpec {
kind: 'const';
charId: string;
value: CellValue;
}
// Check if a table column is truthy.
export interface TruthyMatchSpec {
kind: 'truthy';
colId: string;
}
// Check if a property of user matches a table column.
export interface PairMatchSpec {
kind: 'pair';
charId: string;
colId: string;
}
// Convert a clause to a string. Trivial, but fluid currently.
export function serializeClause(clause: GranularAccessClause) {
return '~acl ' + JSON.stringify(clause);
}
export function decodeClause(code: string): GranularAccessClause|null {
// TODO: be strict about format. But it isn't super-clear what to do with
// a document if access control gets corrupted. Maybe go into an emergency
// mode where only owners have access, and they have unrestricted access?
// Also, format should be plain JSON once no longer stored in a random
// reused column.
if (code.startsWith('~acl ')) {
return safeJsonParse(code.slice(5), null);
// 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);
}
return null;
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};
}

View File

@@ -1,4 +1,4 @@
/*** THIS FILE IS AUTO-GENERATED BY sandbox/gen_js_schema.py ***/
/*** THIS FILE IS AUTO-GENERATED BY core/sandbox/gen_js_schema.py ***/
// tslint:disable:object-literal-key-quotes
export const schema = {
@@ -146,6 +146,10 @@ export const schema = {
principals : "Text",
aclFormula : "Text",
aclColumn : "Ref:_grist_Tables_column",
aclFormulaParsed : "Text",
permissionsText : "Text",
rulePos : "PositionNumber",
userAttributes : "Text",
},
"_grist_ACLResources": {
@@ -313,6 +317,10 @@ export interface SchemaTypes {
principals: string;
aclFormula: string;
aclColumn: number;
aclFormulaParsed: string;
permissionsText: string;
rulePos: number;
userAttributes: string;
};
"_grist_ACLResources": {