UNPKG

@resin/pinejs

Version:

Pine.js is a sophisticated rules-driven API engine that enables you to define rules in a structured subset of English. Those rules are used in order for Pine.js to generate a database schema and the associated [OData](http://www.odata.org/) API. This make

1,712 lines (1,587 loc) • 45.9 kB
import type { AbstractSqlModel, AbstractSqlType, AliasNode, Relationship, RelationshipMapping, SelectNode, } from '@resin/abstract-sql-compiler'; import type * as Express from 'express'; import type { ODataBinds, ODataQuery, SupportedMethod, } from '@balena/odata-parser'; import type { ApiKey, HookReq, User } from '../sbvr-api/sbvr-utils'; import type { AnyObject } from './common-types'; import { Definition, OData2AbstractSQL, odataNameToSqlName, ResourceFunction, sqlNameToODataName, } from '@resin/odata-to-abstract-sql'; import * as ODataParser from '@balena/odata-parser'; import * as Bluebird from 'bluebird'; import * as _ from 'lodash'; import * as memoize from 'memoizee'; import * as randomstring from 'randomstring'; import * as env from '../config-loader/env'; import * as sbvrUtils from '../sbvr-api/sbvr-utils'; import { BadRequestError, PermissionError, PermissionParsingError, } from './errors'; import { memoizedParseOdata, metadataEndpoints, ODataRequest, } from './uri-parser'; import memoizeWeak = require('memoizee/weak'); // tslint:disable-next-line:no-var-requires const userModel: string = require('./user.sbvr'); const DEFAULT_ACTOR_BIND = '@__ACTOR_ID'; const DEFAULT_ACTOR_BIND_REGEX = new RegExp( _.escapeRegExp(DEFAULT_ACTOR_BIND), 'g', ); export { PermissionError, PermissionParsingError }; export interface PermissionReq { user?: User; apiKey?: ApiKey; } export const root: PermissionReq = { user: { id: 0, actor: 0, permissions: ['resource.all'], }, }; export const rootRead: PermissionReq = { user: { id: 0, actor: 0, permissions: ['resource.get'], }, }; interface NestedCheckOr<T> { or: NestedCheckArray<T>; } interface NestedCheckAnd<T> { and: NestedCheckArray<T>; } interface NestedCheckArray<T> extends Array<NestedCheck<T>> {} type NestedCheck<T> = | NestedCheckOr<T> | NestedCheckAnd<T> | NestedCheckArray<T> | T; type PermissionCheck = NestedCheck<string>; type MappedType<I, O> = O extends NestedCheck<infer T> ? Exclude<Exclude<I, string> | T, boolean> : Exclude<Exclude<I, string> | O, boolean>; type MappedNestedCheck< T extends NestedCheck<I>, I, O > = T extends NestedCheckOr<I> ? NestedCheckOr<MappedType<I, O>> : T extends NestedCheckAnd<I> ? NestedCheckAnd<MappedType<I, O>> : T extends NestedCheckArray<I> ? NestedCheckArray<MappedType<I, O>> : Exclude<I, string> | O; const methodPermissions: { [method in Exclude<SupportedMethod, 'OPTIONS'>]: PermissionCheck; } & { OPTIONS?: PermissionCheck } = { GET: { or: ['get', 'read'], }, PUT: { or: [ 'set', { and: ['create', 'update'], }, ], }, POST: { or: ['set', 'create'], }, PATCH: { or: ['set', 'update'], }, MERGE: { or: ['set', 'update'], }, DELETE: 'delete', }; const $parsePermissions = memoize( (filter: string) => { const { tree, binds } = ODataParser.parse(filter, { startRule: 'ProcessRule', rule: 'FilterByExpression', }); return { tree, extraBinds: binds, }; }, { primitive: true, max: env.cache.parsePermissions.max, }, ); const rewriteBinds = ( { tree, extraBinds }: { tree: ODataQuery; extraBinds: ODataBinds }, odataBinds: ODataBinds, ): ODataQuery => { // Add the extra binds we parsed onto our existing list of binds vars. const bindsLength = odataBinds.length; odataBinds.push(...extraBinds); // Clone the tree so the cached version can't be mutated and at the same time fix the bind numbers return _.cloneDeepWith(tree, (value) => { if (value != null) { const bind = value.bind; if (Number.isInteger(bind)) { return { bind: value.bind + bindsLength }; } } }); }; const parsePermissions = ( filter: string, odataBinds: ODataBinds, ): ODataQuery => { const odata = $parsePermissions(filter); return rewriteBinds(odata, odataBinds); }; // Traverses all values in `check`, actions for the following data types: // string: Calls `stringCallback` and uses the value returned instead // boolean: Used as-is // array: Treated as an AND of all elements // object: Must have only one key of either `AND` or `OR`, with an array value that will be treated according to the key. const isAnd = <T>(x: any): x is NestedCheckAnd<T> => _.isObject(x) && 'and' in x; const isOr = <T>(x: any): x is NestedCheckOr<T> => typeof x === 'object' && 'or' in x; export function nestedCheck<I, O>( check: string, stringCallback: (s: string) => O, ): O; export function nestedCheck<I, O>( check: boolean, stringCallback: (s: string) => O, ): boolean; export function nestedCheck<I, O>( check: NestedCheck<I>, stringCallback: (s: string) => O, ): Exclude<I, string> | O | MappedNestedCheck<typeof check, I, O>; export function nestedCheck<I, O>( check: NestedCheck<I>, stringCallback: (s: string) => O, ): boolean | Exclude<I, string> | O | MappedNestedCheck<typeof check, I, O> { if (typeof check === 'string') { return stringCallback(check); } if (typeof check === 'boolean') { return check; } if (Array.isArray(check)) { let results: any[] = []; for (const subcheck of check) { const result = nestedCheck(subcheck, stringCallback); if (typeof result === 'boolean') { if (result === false) { return false; } } else if (isAnd(result)) { results = results.concat(result.and); } else { results.push(result); } } if (results.length === 1) { return results[0]; } if (results.length > 1) { return { and: _.uniq(results), }; } return true; } if (typeof check === 'object') { const checkTypes = Object.keys(check); if (checkTypes.length > 1) { throw new Error('More than one check type: ' + checkTypes); } const checkType = checkTypes[0]; switch (checkType.toUpperCase()) { case 'AND': const and = (check as NestedCheckAnd<I>)[checkType as 'and']; return nestedCheck(and, stringCallback); case 'OR': const or = (check as NestedCheckOr<I>)[checkType as 'or']; let results: any[] = []; for (const subcheck of or) { const result = nestedCheck(subcheck, stringCallback); if (typeof result === 'boolean') { if (result === true) { return true; } } else if (isOr(result)) { results = results.concat(result.or); } else { results.push(result); } } if (results.length === 1) { return results[0]; } if (results.length > 1) { return { or: _.uniq(results), }; } return false; default: throw new Error('Cannot parse required checking logic: ' + checkType); } } throw new Error('Cannot parse required checks: ' + check); } interface CollapsedFilter<T> extends Array<string | T | CollapsedFilter<T>> { 0: string; } const collapsePermissionFilters = <T>( v: NestedCheck<{ filter: T }>, ): T | CollapsedFilter<T> => { if (Array.isArray(v)) { return collapsePermissionFilters({ or: v }); } if (typeof v === 'object') { if ('filter' in v) { return v.filter; } if ('and' in v) { return ['and', ...v.and.map(collapsePermissionFilters)]; } if ('or' in v) { return ['or', ...v.or.map(collapsePermissionFilters)]; } throw new Error( 'Permission filter objects must have `filter` or `and` or `or` keys', ); } return v; }; const namespaceRelationships = ( relationships: Relationship, alias: string, ): void => { _.forEach(relationships, (relationship: Relationship, key) => { if (key === '$') { return; } let mapping = relationship.$; if (mapping != null && mapping.length === 2) { mapping = _.cloneDeep(mapping); // we do check the length above, but typescript thinks the second // element could be undefined mapping[1]![0] = `${mapping[1]![0]}$${alias}`; relationships[`${key}$${alias}`] = { $: mapping, }; } namespaceRelationships(relationship, alias); }); }; type PermissionLookup = _.Dictionary<true | string[]>; const getPermissionsLookup = memoize( (permissions: string[]): PermissionLookup => { const permissionsLookup: PermissionLookup = {}; for (const permission of permissions) { const [target, condition] = permission.split('?'); if (condition == null) { // We have unconditional permission permissionsLookup[target] = true; } else if (permissionsLookup[target] !== true) { if (permissionsLookup[target] == null) { permissionsLookup[target] = []; } (permissionsLookup[target] as Exclude< PermissionLookup[typeof target], true >).push(condition); } } return permissionsLookup; }, { primitive: true, max: env.cache.permissionsLookup.max, }, ); const $checkPermissions = ( permissionsLookup: PermissionLookup, actionList: PermissionCheck, vocabulary?: string, resourceName?: string, ): boolean | NestedCheck<string> => { const checkObject: PermissionCheck = { or: ['all', actionList], }; return nestedCheck(checkObject, (permissionCheck): | boolean | string | NestedCheckOr<string> => { const resourcePermission = permissionsLookup['resource.' + permissionCheck]; let vocabularyPermission: string[] | undefined; let vocabularyResourcePermission: string[] | undefined; if (resourcePermission === true) { return true; } if (vocabulary != null) { const maybeVocabularyPermission = permissionsLookup[vocabulary + '.' + permissionCheck]; if (maybeVocabularyPermission === true) { return true; } vocabularyPermission = maybeVocabularyPermission; if (resourceName != null) { const maybeVocabularyResourcePermission = permissionsLookup[ vocabulary + '.' + resourceName + '.' + permissionCheck ]; if (maybeVocabularyResourcePermission === true) { return true; } vocabularyResourcePermission = maybeVocabularyResourcePermission; } } // Get the unique permission set, ignoring undefined sets. const conditionalPermissions = _.union( resourcePermission, vocabularyPermission, vocabularyResourcePermission, ); if (conditionalPermissions.length === 1) { return conditionalPermissions[0]; } if (conditionalPermissions.length > 1) { return { or: conditionalPermissions, }; } return false; }); }; const convertToLambda = (filter: AnyObject, identifier: string) => { // We need to inject all occurences of properties for the new lambda function // for example if there is an `or` operator, where different properties of // the target resource are used, we need to change all occurences of these. // We do this in a recursive way to also cover all nested cases like or [ and, and ] const replaceObject = (object: AnyObject) => { if (typeof object === 'string') { return; } if (Array.isArray(object)) { object.forEach((element) => { replaceObject(element); }); } if (object.hasOwnProperty('name')) { object.property = { ...object }; object.name = identifier; delete object.lambda; } }; replaceObject(filter); }; const rewriteSubPermissionBindings = (filter: AnyObject, counter: number) => { const rewrite = (object: AnyObject) => { if (object == null) { return; } if (typeof object.bind === 'number') { object.bind = counter + object.bind; } if (Array.isArray(object) || _.isObject(object)) { _.forEach(object, (v) => { rewrite(v); }); } }; rewrite(filter); }; const buildODataPermission = ( permissionsLookup: PermissionLookup, actionList: PermissionCheck, vocabulary: string, resourceName: string, odata: { tree: ODataParser.ODataQuery; binds: ODataParser.ODataBinds; }, ) => { const conditionalPerms = $checkPermissions( permissionsLookup, actionList, vocabulary, resourceName, ); if (conditionalPerms === false) { // We reuse a constant permission error here as it will be cached, and // using a single error instance can drastically reduce the memory used throw constrainedPermissionError; } if (conditionalPerms === true) { // If we have full access then no need to provide a constrained definition return false; } const permissionFilters = nestedCheck(conditionalPerms, (permissionCheck) => { try { // We use an object with filter key to avoid collapsing our filters later. return { filter: parsePermissions(permissionCheck, odata.binds), }; } catch (e) { console.warn( 'Failed to parse conditional permissions: ', permissionCheck, ); throw new PermissionParsingError(e); } }); const collapsedPermissionFilters = collapsePermissionFilters( permissionFilters, ); return collapsedPermissionFilters; }; const constrainedPermissionError = new PermissionError(); const generateConstrainedAbstractSql = ( permissionsLookup: PermissionLookup, actionList: PermissionCheck, vocabulary: string, resourceName: string, ) => { const abstractSQLModel = sbvrUtils.getAbstractSqlModel({ vocabulary, }); const odata = memoizedParseOdata(`/${resourceName}`); const collapsedPermissionFilters = buildODataPermission( permissionsLookup, actionList, vocabulary, resourceName, odata, ); _.set(odata, ['tree', 'options', '$filter'], collapsedPermissionFilters); const lambdaAlias = randomstring.generate(20); let inc = 0; // We need to trace the processed resources, to be able to break // permissions circles. const canAccessTrace: string[] = [resourceName]; const canAccessFunction: ResourceFunction = function (property: AnyObject) { // remove method property so that we won't loop back here again at this point delete property.method; if (!this.defaultResource) { throw new Error(`No resource selected in AST.`); } const targetResource = this.NavigateResources( this.defaultResource, property.name, ); const targetResourceName = sqlNameToODataName(targetResource.resource.name); if (canAccessTrace.includes(targetResourceName)) { // we don't want to allow permission loops for now, therefore we are // throwing the exception here. If we ever want to allow permission // loops return a false AST statement here (like true eq false), to // not recursivley follow query branches in a deep first search. throw new PermissionError( `Permissions for ${resourceName} form a circle by the following path: ${canAccessTrace.join( ' -> ', )} -> ${targetResourceName}`, ); } const parentOdata = memoizedParseOdata(`/${targetResourceName}`); const collapsedParentPermissionFilters = buildODataPermission( permissionsLookup, actionList, vocabulary, targetResourceName, parentOdata, ); if (collapsedParentPermissionFilters === false) { // We reuse a constant permission error here as it will be cached, and // using a single error instance can drastically reduce the memory used throw constrainedPermissionError; } const lambdaId = `${lambdaAlias}+${inc}`; inc = inc + 1; rewriteSubPermissionBindings( collapsedParentPermissionFilters, this.bindVarsLength + this.extraBindVars.length, ); convertToLambda(collapsedParentPermissionFilters, lambdaId); property.lambda = { method: 'any', identifier: lambdaId, expression: collapsedParentPermissionFilters, }; this.extraBindVars.push(...parentOdata.binds); canAccessTrace.push(targetResourceName); try { return this.Property(property); } finally { canAccessTrace.pop(); } }; const odata2AbstractSQL = new OData2AbstractSQL(abstractSQLModel, { canAccess: canAccessFunction, }); const { tree, extraBindVars } = odata2AbstractSQL.match( odata.tree, 'GET', [], odata.binds.length, ); odata.binds.push(...extraBindVars); const odataBinds = odata.binds; const abstractSqlQuery = [...tree]; // Remove aliases from the top level select const selectIndex = abstractSqlQuery.findIndex((v) => v[0] === 'Select'); const select = (abstractSqlQuery[selectIndex] = [ ...abstractSqlQuery[selectIndex], ] as SelectNode); select[1] = select[1].map( (selectField): AbstractSqlType => { if (selectField[0] === 'Alias') { const maybeField = (selectField as AliasNode<any>)[1]; const fieldType = maybeField[0]; if (fieldType === 'ReferencedField' || fieldType === 'Field') { return maybeField; } return [ 'Alias', maybeField, odataNameToSqlName((selectField as AliasNode<any>)[2]), ]; } if (selectField.length === 2 && Array.isArray(selectField[0])) { return selectField[0]; } return selectField; }, ); return { extraBinds: odataBinds, abstractSqlQuery }; }; // Call the function once and either return the same result or throw the same error on subsequent calls const onceGetter = (obj: AnyObject, propName: string, fn: () => any) => { // We have `nullableFn` to keep fn required but still allow us to clear the fn reference // after we have called fn let nullableFn: undefined | typeof fn = fn; let thrownErr: Error | undefined; Object.defineProperty(obj, propName, { enumerable: true, configurable: true, get() { if (thrownErr != null) { throw thrownErr; } try { const result = nullableFn!(); // We need the delete first as the current property is read-only // and the delete removes that restriction delete this[propName]; return (this[propName] = result); } catch (e) { thrownErr = e; throw thrownErr; } finally { nullableFn = undefined; } }, }); }; const deepFreezeExceptDefinition = (obj: AnyObject) => { Object.freeze(obj); Object.getOwnPropertyNames(obj).forEach((prop) => { // We skip the definition because we know it's a property we've defined that will throw an error in some cases if ( prop !== 'definition' && obj.hasOwnProperty(prop) && obj[prop] !== null && !['object', 'function'].includes(typeof obj[prop]) ) { deepFreezeExceptDefinition(obj); } }); }; const createBypassDefinition = (definition: Definition) => _.cloneDeepWith(definition, (abstractSql) => { if ( Array.isArray(abstractSql) && abstractSql[0] === 'Resource' && !abstractSql[1].endsWith('$bypass') ) { return ['Resource', `${abstractSql[1]}$bypass`]; } }); const getAlias = (name: string) => { // TODO-MAJOR: Change $bypass to $permissionbypass or similar if (name.endsWith('$bypass')) { return 'bypass'; } const [, permissionsJSON] = name.split('permissions'); if (!permissionsJSON) { return; } return `permissions${permissionsJSON}`; }; const rewriteRelationship = memoizeWeak( ( value: Relationship, name: string, abstractSqlModel: AbstractSqlModel, permissionsLookup: PermissionLookup, vocabulary: string, ) => { let escapedName = sqlNameToODataName(name); if (abstractSqlModel.tables[name]) { escapedName = sqlNameToODataName(abstractSqlModel.tables[name].name); } const originalAbstractSQLModel = sbvrUtils.getAbstractSqlModel({ vocabulary, }); const rewrite = (object: Relationship | RelationshipMapping) => { if ('$' in object && Array.isArray(object.$)) { // object is in the form of // { "$": ["actor", ["actor", "id"]] } or { "$": ["device type"] } // we are only interested in the first case, since this is a relationship // to a different resource const mapping = object.$; if ( mapping.length === 2 && Array.isArray(mapping[1]) && mapping[1].length === 2 && typeof mapping[1][0] === 'string' ) { // now have ensured that mapping looks like ["actor", ["actor", "id"]] // this relations ship means that: // mapping[0] is the local field // mapping[1] is the reference to the other resource, that joins this resource // mapping[1][0] is the name of the other resource (actor in the example) // mapping[1][1] is the name of the field on the other resource // // this therefore defines that the local field `actor` needs // to match the `id` of the `actor` resources for the join const possibleTargetResourceName = mapping[1][0]; // Skip this if we already shortcut this connection if (possibleTargetResourceName.endsWith('$bypass')) { return; } const targetResourceEscaped = sqlNameToODataName( abstractSqlModel.tables[possibleTargetResourceName]?.name ?? possibleTargetResourceName, ); // This is either a translated or bypassed resource we don't // mess with these if (targetResourceEscaped.includes('$')) { return; } let foundCanAccessLink = false; try { const odata = memoizedParseOdata(`/${targetResourceEscaped}`); const collapsedPermissionFilters = buildODataPermission( permissionsLookup, methodPermissions.GET, vocabulary, targetResourceEscaped, odata, ); _.set( odata, ['tree', 'options', '$filter'], collapsedPermissionFilters, ); const canAccessFunction: ResourceFunction = function ( property: AnyObject, ) { // remove method property so that we won't loop back here again at this point delete property.method; if (!this.defaultResource) { throw new Error(`No resource selected in AST.`); } const targetResourceAST = this.NavigateResources( this.defaultResource, property.name, ); const targetResourceName = sqlNameToODataName( targetResourceAST.resource.name, ); const currentResourceName = sqlNameToODataName( this.defaultResource.name, ); if ( currentResourceName === targetResourceEscaped && targetResourceName === escapedName ) { foundCanAccessLink = true; } // return a true expression to not select the relationship, which might be virtual // this should be a boolean expression, but needs to be a subquery in case it // is wrapped in an `or` or `and` return ['Equals', ['Boolean', true], ['Boolean', true]]; }; // We need execute the abstract SQL compiler to traverse // through the permissions for that resource, using a // special canAccess callback. const odata2AbstractSQL = new OData2AbstractSQL( originalAbstractSQLModel, { canAccess: canAccessFunction, }, ); try { odata2AbstractSQL.match( odata.tree, 'GET', [], odata.binds.length, ); } catch (e) { throw new ODataParser.SyntaxError(e); } if (foundCanAccessLink) { // store the resource name as it was with a $bypass // suffix in this relationship, this means that the // query generator will use the plain resource instead // of the filtered resource. mapping[1][0] = `${possibleTargetResourceName}$bypass`; } } catch (e) { if (e === constrainedPermissionError) { // ignore return; } // TODO: We should investigate in detail why this error // occurse. It might be able to get rid of this. if (e instanceof ODataParser.SyntaxError) { // ignore return; } throw e; } } } if (Array.isArray(object) || _.isObject(object)) { _.forEach(object, (v) => { // we want to recurse into the relationship path, but // in case we hit a plain string, we don't need to bother // checking it. This can happen since plain terms also have // relationships to sbvr-types. if (typeof v !== 'string') { rewrite(v as Relationship | RelationshipMapping); } }); } }; rewrite(value); }, ); const rewriteRelationships = ( abstractSqlModel: AbstractSqlModel, relationships: { [resourceName: string]: Relationship; }, permissionsLookup: PermissionLookup, vocabulary: string, ) => { const newRelationships = _.cloneDeep(relationships); _.forOwn(newRelationships, (value, name) => rewriteRelationship( value, name, abstractSqlModel, permissionsLookup, vocabulary, ), ); return newRelationships; }; const stringifiedGetPermissions = JSON.stringify(methodPermissions.GET); const getBoundConstrainedMemoizer = memoizeWeak( (abstractSqlModel: AbstractSqlModel) => memoizeWeak( (permissionsLookup: PermissionLookup, vocabulary: string) => { const constrainedAbstractSqlModel = _.cloneDeep(abstractSqlModel); const origSynonyms = Object.keys(constrainedAbstractSqlModel.synonyms); constrainedAbstractSqlModel.synonyms = new Proxy( constrainedAbstractSqlModel.synonyms, { get: (synonyms, permissionSynonym: string) => { if (synonyms[permissionSynonym]) { return synonyms[permissionSynonym]; } const alias = getAlias(permissionSynonym); if (!alias) { return; } origSynonyms.forEach((canonicalForm, synonym) => { synonyms[`${synonym}$${alias}`] = `${canonicalForm}$${alias}`; }); return synonyms[permissionSynonym]; }, }, ); const origRelationships = Object.keys( constrainedAbstractSqlModel.relationships, ); _.forEach(constrainedAbstractSqlModel.tables, (table, resourceName) => { const bypassResourceName = `${resourceName}$bypass`; constrainedAbstractSqlModel.tables[bypassResourceName] = { ...table, }; constrainedAbstractSqlModel.tables[ bypassResourceName ].resourceName = bypassResourceName; if (table.definition) { // If the table is definition based then just make the bypass version match but pointing to the equivalent bypassed resources constrainedAbstractSqlModel.tables[ bypassResourceName ].definition = createBypassDefinition(table.definition); } else { // Otherwise constrain the non-bypass table onceGetter( table, 'definition', () => // For $filter on eg a DELETE you need read permissions on the sub-resources, // you only need delete permissions on the resource being deleted constrainedAbstractSqlModel.tables[ `${resourceName}$permissions${stringifiedGetPermissions}` ].definition, ); } }); constrainedAbstractSqlModel.tables = new Proxy( constrainedAbstractSqlModel.tables, { get: (tables, permissionResourceName: string) => { if (tables[permissionResourceName]) { return tables[permissionResourceName]; } const [ resourceName, permissionsJSON, ] = permissionResourceName.split('$permissions'); if (!permissionsJSON) { return; } const permissions = JSON.parse(permissionsJSON); const table = tables[`${resourceName}$bypass`]; const permissionsTable = (tables[permissionResourceName] = { ...table, }); permissionsTable.resourceName = permissionResourceName; onceGetter(permissionsTable, 'definition', () => // For $filter on eg a DELETE you need read permissions on the sub-resources, // you only need delete permissions on the resource being deleted generateConstrainedAbstractSql( permissionsLookup, permissions, vocabulary, sqlNameToODataName(permissionsTable.name), ), ); return permissionsTable; }, }, ); // rewrite the relationships of the constraint model // we check if given the current permissions we can direct // expands and filters to unconstraint resources constrainedAbstractSqlModel.relationships = rewriteRelationships( constrainedAbstractSqlModel, constrainedAbstractSqlModel.relationships, permissionsLookup, vocabulary, ); constrainedAbstractSqlModel.relationships = new Proxy( constrainedAbstractSqlModel.relationships, { get: (relationships, permissionResourceName: string) => { if (relationships[permissionResourceName]) { return relationships[permissionResourceName]; } const alias = getAlias(permissionResourceName); if (!alias) { return; } for (const relationship of origRelationships) { relationships[`${relationship}$${alias}`] = relationships[relationship]; namespaceRelationships(relationships[relationship], alias); } return relationships[permissionResourceName]; }, }, ); deepFreezeExceptDefinition(constrainedAbstractSqlModel); return constrainedAbstractSqlModel; }, { primitive: true, }, ), ); const memoizedGetConstrainedModel = ( abstractSqlModel: AbstractSqlModel, permissionsLookup: PermissionLookup, vocabulary: string, ) => getBoundConstrainedMemoizer(abstractSqlModel)(permissionsLookup, vocabulary); const getCheckPasswordQuery = _.once(() => sbvrUtils.api.Auth.prepare<{ username: string }>({ resource: 'user', passthrough: { req: rootRead, }, options: { $select: ['id', 'actor', 'password'], $filter: { username: { '@': 'username' }, }, }, }), ); export const checkPassword = Bluebird.method( async ( username: string, password: string, ): Promise<{ id: number; actor: number; username: string; permissions: string[]; }> => { const [user] = (await getCheckPasswordQuery()({ username, })) as AnyObject[]; if (user == null) { throw new Error('User not found'); } const hash = user.password; const userId = user.id; const actorId = user.actor; const res = await sbvrUtils.sbvrTypes.Hashed.compare(password, hash); if (!res) { throw new Error('Passwords do not match'); } const permissions = await getUserPermissions(userId); return { id: userId, actor: actorId, username, permissions, }; }, ); const getUserPermissionsQuery = _.once(() => sbvrUtils.api.Auth.prepare<{ userId: number }>({ resource: 'permission', passthrough: { req: rootRead, }, options: { $select: 'name', $filter: { $or: { is_of__user: { $any: { $alias: 'uhp', $expr: { uhp: { user: { '@': 'userId' } }, $or: [ { uhp: { expiry_date: null }, }, { uhp: { expiry_date: { $gt: { $now: null } }, }, }, ], }, }, }, is_of__role: { $any: { $alias: 'rhp', $expr: { rhp: { role: { $any: { $alias: 'r', $expr: { r: { is_of__user: { $any: { $alias: 'uhr', $expr: { uhr: { user: { '@': 'userId' } }, $or: [ { uhr: { expiry_date: null }, }, { uhr: { expiry_date: { $gt: { $now: null } }, }, }, ], }, }, }, }, }, }, }, }, }, }, }, }, }, // We orderby to increase the hit rate for the `_checkPermissions` memoisation $orderby: { name: 'asc', }, }, }), ); export const getUserPermissions = Bluebird.method( async (userId: number): Promise<string[]> => { if (typeof userId === 'string') { userId = parseInt(userId, 10); } if (!Number.isFinite(userId)) { throw new Error(`User ID has to be numeric, got: ${typeof userId}`); } try { const permissions = (await getUserPermissionsQuery()({ userId, })) as Array<{ name: string }>; return permissions.map((permission) => permission.name); } catch (err) { sbvrUtils.api.Auth.logger.error('Error loading user permissions', err); throw err; } }, ); const getApiKeyPermissionsQuery = _.once(() => sbvrUtils.api.Auth.prepare<{ apiKey: string }>({ resource: 'permission', passthrough: { req: rootRead, }, options: { $select: 'name', $filter: { $or: { is_of__api_key: { $any: { $alias: 'khp', $expr: { khp: { api_key: { $any: { $alias: 'k', $expr: { k: { key: { '@': 'apiKey' } }, }, }, }, }, }, }, }, is_of__role: { $any: { $alias: 'rhp', $expr: { rhp: { role: { $any: { $alias: 'r', $expr: { r: { is_of__api_key: { $any: { $alias: 'khr', $expr: { khr: { api_key: { $any: { $alias: 'k', $expr: { k: { key: { '@': 'apiKey' } }, }, }, }, }, }, }, }, }, }, }, }, }, }, }, }, }, }, // We orderby to increase the hit rate for the `_checkPermissions` memoisation $orderby: { name: 'asc', }, }, }), ); const $getApiKeyPermissions = memoize( async (apiKey: string) => { try { const permissions = (await getApiKeyPermissionsQuery()({ apiKey, })) as Array<{ name: string }>; return permissions.map((permission) => permission.name); } catch (err) { sbvrUtils.api.Auth.logger.error('Error loading api key permissions', err); throw err; } }, { primitive: true, promise: true, max: env.cache.apiKeys.max, maxAge: env.cache.apiKeys.maxAge, }, ); export const getApiKeyPermissions = Bluebird.method( (apiKey: string): Promise<string[]> => { if (typeof apiKey !== 'string') { throw new Error('API key has to be a string, got: ' + typeof apiKey); } return $getApiKeyPermissions(apiKey); }, ); const getApiKeyActorIdQuery = _.once(() => sbvrUtils.api.Auth.prepare<{ apiKey: string }>({ resource: 'api_key', passthrough: { req: rootRead, }, options: { $select: 'is_of__actor', $filter: { key: { '@': 'apiKey' }, }, }, }), ); const apiActorPermissionError = new PermissionError(); const getApiKeyActorId = memoize( async (apiKey: string) => { const apiKeys = (await getApiKeyActorIdQuery()({ apiKey, })) as AnyObject[]; if (apiKeys.length === 0) { // We reuse a constant permission error here as it will be cached, and // using a single error instance can drastically reduce the memory used throw apiActorPermissionError; } const apiKeyActorID = apiKeys[0].is_of__actor.__id; if (apiKeyActorID == null) { throw new Error('API key is not linked to a actor?!'); } return apiKeyActorID as number; }, { primitive: true, promise: true, maxAge: env.cache.apiKeys.maxAge, }, ); const checkApiKey = Bluebird.method( async (req: PermissionReq, apiKey: string) => { if (apiKey == null || req.apiKey != null) { return; } let permissions: string[]; try { permissions = await getApiKeyPermissions(apiKey); } catch (err) { console.warn('Error with API key:', err); // Ignore errors getting the api key and just use an empty permissions object. permissions = []; } let actor; if (permissions.length > 0) { actor = await getApiKeyActorId(apiKey); } req.apiKey = { key: apiKey, permissions, }; if (actor != null) { req.apiKey.actor = actor; } }, ); export const customAuthorizationMiddleware = (expectedScheme = 'Bearer') => { expectedScheme = expectedScheme.toLowerCase(); return Bluebird.method( async ( req: Express.Request, _res?: Express.Response, next?: Express.NextFunction, ): Promise<void> => { try { const auth = req.header('Authorization'); if (!auth) { return; } const parts = auth.split(' '); if (parts.length !== 2) { return; } const [scheme, apiKey] = parts; if (scheme.toLowerCase() !== expectedScheme) { return; } await checkApiKey(req, apiKey); } finally { next?.(); } }, ); }; // A default bearer middleware for convenience export const authorizationMiddleware = customAuthorizationMiddleware(); export const customApiKeyMiddleware = (paramName = 'apikey') => { if (paramName == null) { paramName = 'apikey'; } return Bluebird.method( async ( req: HookReq | Express.Request, _res?: Express.Response, next?: Express.NextFunction, ): Promise<void> => { try { const apiKey = req.params[paramName] != null ? req.params[paramName] : req.body[paramName] != null ? req.body[paramName] : req.query[paramName]; await checkApiKey(req, apiKey); } finally { next?.(); } }, ); }; // A default api key middleware for convenience export const apiKeyMiddleware = customApiKeyMiddleware(); export const checkPermissions = Bluebird.method( async ( req: PermissionReq, actionList: PermissionCheck, resourceName?: string, vocabulary?: string, ) => { const permissionsLookup = await getReqPermissions(req); return $checkPermissions( permissionsLookup, actionList, vocabulary, resourceName, ); }, ); export const checkPermissionsMiddleware = ( action: PermissionCheck, ): Express.RequestHandler => Bluebird.method((async (req, res, next) => { try { const allowed = await checkPermissions(req, action); switch (allowed) { case false: res.sendStatus(401); return; case true: next(); return; default: throw new Error( 'checkPermissionsMiddleware returned a conditional permission', ); } } catch (err) { sbvrUtils.api.Auth.logger.error( 'Error checking permissions', err, err.stack, ); res.sendStatus(503); } }) as Express.RequestHandler); const getGuestPermissions = memoize( async () => { // Get guest user const result = (await sbvrUtils.api.Auth.get({ resource: 'user', passthrough: { req: rootRead, }, options: { $select: 'id', $filter: { username: 'guest', }, }, })) as Array<{ id: number }>; if (result.length === 0) { throw new Error('No guest user'); } return _.uniq(await getUserPermissions(result[0].id)); }, { promise: true }, ); const getReqPermissions = async ( req: PermissionReq, odataBinds: ODataBinds = [], ) => { const [guestPermissions] = await Promise.all([ getGuestPermissions(), (async () => { // TODO: Remove this extra actor ID lookup making actor non-optional and updating open-balena-api. if ( req.apiKey != null && req.apiKey.actor == null && req.apiKey.permissions != null && req.apiKey.permissions.length > 0 ) { const actorId = await getApiKeyActorId(req.apiKey.key); req.apiKey!.actor = actorId; } })(), ]); if (guestPermissions.some((p) => DEFAULT_ACTOR_BIND_REGEX.test(p))) { throw new Error('Guest permissions cannot reference actors'); } let permissions = guestPermissions; let actorIndex = 0; const addActorPermissions = (actorId: number, actorPermissions: string[]) => { let actorBind = DEFAULT_ACTOR_BIND; if (actorIndex > 0) { actorBind += actorIndex; actorPermissions = actorPermissions.map((actorPermission) => actorPermission.replace(DEFAULT_ACTOR_BIND_REGEX, actorBind), ); } odataBinds[actorBind] = ['Real', actorId]; actorIndex++; permissions = permissions.concat(actorPermissions); }; if (req.user != null && req.user.permissions != null) { addActorPermissions(req.user.actor, req.user.permissions); } else if (req.apiKey != null && req.apiKey.permissions != null) { addActorPermissions(req.apiKey.actor!, req.apiKey.permissions); } permissions = _.uniq(permissions); return getPermissionsLookup(permissions); }; export const addPermissions = Bluebird.method( async ( req: PermissionReq, request: ODataRequest & { permissionType?: PermissionCheck }, ): Promise<void> => { const { vocabulary, resourceName, odataQuery, odataBinds } = request; let { method } = request; let abstractSqlModel = sbvrUtils.getAbstractSqlModel(request); method = method.toUpperCase() as SupportedMethod; const isMetadataEndpoint = metadataEndpoints.includes(resourceName) || method === 'OPTIONS'; let permissionType: PermissionCheck; if (request.permissionType != null) { permissionType = request.permissionType; } else if (isMetadataEndpoint) { permissionType = 'model'; } else { const methodPermission = methodPermissions[method]; if (methodPermission != null) { permissionType = methodPermission; } else { console.warn('Unknown method for permissions type check: ', method); permissionType = 'all'; } } // This bypasses in the root cases, needed for fetching guest permissions to work, it can almost certainly be done better though let permissions = req.user == null ? [] : req.user.permissions || []; permissions = permissions.concat( req.apiKey == null ? [] : req.apiKey.permissions || [], ); if ( permissions.length > 0 && $checkPermissions( getPermissionsLookup(permissions), permissionType, vocabulary, ) === true ) { // We have unconditional permission to access the vocab so there's no need to intercept anything return; } const permissionsLookup = await getReqPermissions(req, odataBinds); // Update the request's abstract sql model to use the constrained version request.abstractSqlModel = abstractSqlModel = memoizedGetConstrainedModel( abstractSqlModel, permissionsLookup, vocabulary, ); if (!_.isEqual(permissionType, methodPermissions.GET)) { const sqlName = sbvrUtils.resolveSynonym(request); odataQuery.resource = `${sqlName}$permissions${JSON.stringify( permissionType, )}`; } }, ); export const config = { models: [ { apiRoot: 'Auth', modelText: userModel, customServerCode: exports, migrations: { '11.0.0-modified-at': ` ALTER TABLE "actor" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "api key" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "api key-has-permission" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "api key-has-role" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "permission" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "role" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "user-has-role" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; ALTER TABLE "user-has-permission" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; `, '11.0.1-modified-at': ` ALTER TABLE "role-has-permission" ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; `, }, }, ] as sbvrUtils.ExecutableModel[], }; export const setup = () => { sbvrUtils.addPureHook('all', 'all', 'all', { PREPARSE: ({ req }) => apiKeyMiddleware(req), POSTPARSE: ({ req, request, }: { req: HookReq; request: ODataRequest & { permissionType?: PermissionCheck }; }) => { // If the abstract sql query is already generated then adding permissions will do nothing if (request.abstractSqlQuery != null) { return; } if ( request.method === 'POST' && request.odataQuery.property != null && request.odataQuery.property.resource === 'canAccess' ) { if (request.odataQuery.key == null) { throw new BadRequestError(); } const { action, method } = request.values; if ((method == null) === (action == null)) { // Exactly one of method or action are allowed throw new BadRequestError(); } if (method != null) { const permissions = methodPermissions[method as SupportedMethod]; if (permissions == null) { throw new BadRequestError(); } request.permissionType = permissions; } else { request.permissionType = action; } const abstractSqlModel = sbvrUtils.getAbstractSqlModel(request); request.resourceName = request.resourceName.slice( 0, -'#canAccess'.length, ); const resourceName = sbvrUtils.resolveSynonym(request); const resourceTable = abstractSqlModel.tables[resourceName]; if (resourceTable == null) { throw new Error('Unknown resource: ' + request.resourceName); } const idField = resourceTable.idField; request.odataQuery.options = { $select: { properties: [{ name: idField }] }, $top: 1, }; request.odataQuery.resource = request.resourceName; delete request.odataQuery.property; request.method = 'GET'; request.custom.isAction = 'canAccess'; } return addPermissions(req, request); }, PRERESPOND: ({ request, data }) => { if (request.custom.isAction === 'canAccess' && _.isEmpty(data)) { // If the caller does not have any permissions to access the // resource pine will throw a PermissionError. To have the // same behavior for the case that the user has permissions // to access the resource, but not this instance we also // throw a PermissionError if the result is empty. throw new PermissionError(); } }, }); sbvrUtils.addPureHook('POST', 'Auth', 'user', { POSTPARSE: async ({ request, api }) => { const result = (await api.post({ resource: 'actor', options: { returnResource: false }, })) as AnyObject; request.values.actor = result.id; }, }); sbvrUtils.addPureHook('DELETE', 'Auth', 'user', { POSTRUN: ({ request, api }) => api.delete({ resource: 'actor', id: request.values.actor, }), }); };