@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
text/typescript
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,
}),
});
};