/** * 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 '[+][-]' where * 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 { read: T; create: T; update: T; delete: T; schemaEdit: T; } // Some shorter type aliases. export type PartialPermissionSet = PermissionSet; export type MixedPermissionSet = PermissionSet; export type TablePermissionSet = PermissionSet; // One of the strings 'read', 'update', etc. export type PermissionKey = keyof PermissionSet; const PERMISSION_BITS: {[letter: string]: PermissionKey} = { R: 'read', C: 'create', U: 'update', D: 'delete', S: 'schemaEdit', }; const ALL_PERMISSION_BITS = "CRUDS"; export const ALL_PERMISSION_PROPS: Array = 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])); export const AVAILABLE_BITS_TABLES: PermissionKey[] = ['read', 'update', 'create', 'delete']; export const AVAILABLE_BITS_COLUMNS: PermissionKey[] = ['read', 'update']; // 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): 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)); } /** * Returns permissions trimmed to include only the available bits, and empty for any other bits. */ export function trimPermissions( permissions: PartialPermissionSet, availableBits: PermissionKey[] ): PartialPermissionSet { const trimmed = emptyPermissionSet(); for (const bit of availableBits) { trimmed[bit] = permissions[bit]; } return trimmed; } /** * Merge a list of PermissionSets by combining individual bits. */ export function mergePermissions(psets: Array>, combine: (bits: T[]) => U ): PermissionSet { const result: Partial> = {}; for (const prop of ALL_PERMISSION_PROPS) { result[prop] = combine(psets.map(p => p[prop])); } return result as PermissionSet; } /** * 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')); } /** * Check if PermissionSet may only add permissions, only remove permissions, or may do either. * A rule that neither adds nor removes permissions is treated as mixed. */ export function summarizePermissionSet(pset: PartialPermissionSet): MixedPermissionValue { let sign = ''; for (const key of Object.keys(pset) as Array) { const pWithSome = pset[key]; // "Some" postfix is not significant for summarization. const p = pWithSome === 'allowSome' ? 'allow' : (pWithSome === 'denySome' ? 'deny' : pWithSome); if (!p || p === sign) { continue; } if (!sign) { sign = p; continue; } sign = 'mixed'; } return (sign === 'allow' || sign === 'deny') ? sign : 'mixed'; } /** * Summarize whether a set of permissions are all 'allow', all 'deny', or other ('mixed'). */ export function summarizePermissions(perms: MixedPermissionValue[]): MixedPermissionValue { if (perms.length === 0) { return 'mixed'; } const perm = perms[0]; return perms.some(p => p !== perm) ? 'mixed' : perm; } function isEmpty(permissions: PartialPermissionSet): boolean { return Object.values(permissions).every(v => v === ""); } /** * Divide up a PartialPermissionSet into two: one containing only the 'schemaEdit' permission bit, * and the other containing everything else. Empty parts will be returned as undefined, except * when both are empty, in which case nonSchemaEdit will be returned as an empty permission set. */ export function splitSchemaEditPermissionSet(permissions: PartialPermissionSet): {schemaEdit?: PartialPermissionSet, nonSchemaEdit?: PartialPermissionSet} { const schemaEdit = {...emptyPermissionSet(), schemaEdit: permissions.schemaEdit}; const nonSchemaEdit: PartialPermissionSet = {...permissions, schemaEdit: ""}; return { schemaEdit: !isEmpty(schemaEdit) ? schemaEdit : undefined, nonSchemaEdit: !isEmpty(nonSchemaEdit) || isEmpty(schemaEdit) ? nonSchemaEdit : undefined, }; }