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