UNPKG

@sap/xsodata

Version:

Expose data from a HANA database as OData V2 service with help of .xsodata files.

573 lines (497 loc) 19.8 kB
'use strict'; const async = require('async'); const ForbiddenError = require('../utils/errors/http/forbidden.js'); const utils = require('../utils/utils.js'); const securityContext = require('../security/securityContext'); const uriTypes = require('../uri/uriType'); /** * The authorization processor. Depending on the existence of scopes, it validates the authorization * for accessing resources on all the corresponding levels: service-root, entity-set(s) and CRUD * method(s). * If authorized, the framework's async callback is called without an error. Otherwise, it is called * with a corresponding authorization error regarding top https://tools.ietf.org/html/rfc7235. * * @param context {Object} The OData context of the framework. * @param asyncDone {function} The async callback of the framework. */ exports.processAuthorization = function (context, asyncDone) { let strategies; let builtStrategies; let hasScopes; let token; const contextUriType = context.uriTree.uriType; const innerContext = { parentContext: context, isAuthorized: false, scopes: [], }; context.logger.info('authorizationProcessor', 'processAuthorization()'); try { // check if the model of context has scopes hasScopes = exports.contextHasScopes(context); context.logger.debug( 'authorizationProcessor', ' xsodata config has scopes: ' + hasScopes ); // If there is no scopes available at all, we allow request by default if (hasScopes === false) { return asyncDone(null, context); } if (contextUriType === undefined || contextUriType === null) { context.uriTree.uriType = uriTypes.determineUriType(context); } // if in batch request then use the parent context, within batch request not authorization header SHOULD be used if (context.batchContext && context.batchContext.parentContext) { token = securityContext.getAuthToken( context.batchContext.parentContext ); } else { token = securityContext.getAuthToken(context); } context.logger.debug('authorizationProcessor', ' token: ' + token); builtStrategies = exports.buildStrategies(token, context); } catch (err) { // Call exit for logging purposes if (context.callRegisteredStep) { return context.callRegisteredStep(10, err, context, () => { return asyncDone(err, context); }); } else { return asyncDone(err, context); } } strategies = [ function (cb) { return cb(null, innerContext); }, ].concat(builtStrategies); async.waterfall(strategies, function (err, innerContext) { let retError = err; // set isAuthorized if (err) { context.isAuthorized = false; } if (innerContext.isAuthorized !== true) { retError = new ForbiddenError('Forbidden request', context); context.isAuthorized = false; } // forward error to exit if (retError) { if (context.callRegisteredStep) { return context.callRegisteredStep(10, retError, context, () => { // TESTED return asyncDone(retError, context); }); } } return asyncDone(retError, context); }); }; /** * Checks whether scopes are given in the corresponding XSOData service definition. This is used by * the authorization processor to validate the need for authorization checks. If no scopes are * defined at all authorization will be skipped. * * @param context {Object} The OData context of the framework. * @returns {boolean}. */ exports.contextHasScopes = function (context) { context.logger.debug('authorizationProcessor', 'contextHasScopes()'); context.logger.debug( 'authorizationProcessor', 'context.getScopes(): ' + JSON.stringify(context.getScopes()) ); return context.getScopes() ? true : false; }; /** * The strategies builder method. It builds the needed strategies for the authorization checks * based on the request requirements. The collection of strategies is returned to the authorization * processor method, where they run within an async waterfall. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Array<Function>} Collection of required authorization-check strategies. */ exports.buildStrategies = function (token, context) { let strategies = [], uType = context.uriTree.uriType; context.logger.debug('authorizationProcessor', 'buildStrategies()'); context.logger.debug('authorizationProcessor', ' uriType: ' + uType); // requests to service root or $metadata should be always allowed if ( uType === uriTypes.URI0 || uType === uriTypes.URI8 || uType === uriTypes.URI9 ) { strategies = strategies.concat( exports.buildAlwaysAllowStrategy(token, context) ); return strategies; } // all requests have to check scopes on service-root level strategies = strategies.concat( exports.buildServiceRootStrategy(token, context) ); if ( uType === uriTypes.URI1 || uType === uriTypes.URI2 || uType === uriTypes.URI3 || uType === uriTypes.URI4 || uType === uriTypes.URI5A || uType === uriTypes.URI5B || uType === uriTypes.URI15 || uType === uriTypes.URI16 || uType === uriTypes.URI17 ) { strategies = strategies.concat( exports.buildDbSegmentLastStrategy(token, context) ); if ( context.oData.systemQueryParameters && context.oData.systemQueryParameters.expand ) { strategies = strategies.concat( exports.build$expandStrategy(token, context) ); } } if (uType === uriTypes.URI6A || uType === uriTypes.URI6B) { strategies = strategies.concat( exports.buildMultipleDbSegmentsStrategy(token, context) ); if ( context.oData.systemQueryParameters && context.oData.systemQueryParameters.expand ) { strategies = strategies.concat( exports.build$expandStrategy(token, context) ); } } if (uType === uriTypes.URI7A || uType === uriTypes.URI7B) { strategies = strategies.concat( exports.build$linksStrategy(token, context) ); // because $links supports multiple navProps, we have to prove the scopes of the middle Segs if ( context.oData.dbSegment.nextDBSegment !== context.oData.dbSegmentLast ) { strategies = strategies.concat( exports.buildMultipleDbSegmentsStrategy(token, context) ); } } if ( context.uriTree.queryParameters && context.uriTree.queryParameters['sap-ds-debug'] ) { strategies = strategies.concat( exports.buildDebugStrategy(token, context) ); } strategies = strategies.concat( exports.buildSecurityContextStrategy(token, context) ); return strategies; }; /** * Builds the strategy that authorizes the request without any checks. This is used by the * strategies builder method to skip authorization checks for certain request types. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.buildAlwaysAllowStrategy = function buildAlwaysAllowStrategy( token, context ) { context.logger.debug( 'authorizationProcessor', 'buildAlwaysAllowStrategy()' ); return function (innerContext, done) { innerContext.isAuthorized = true; done(null, innerContext); }; }; /** * Builds the strategy that collects scopes from the service-root level. * Scopes are collected in dependency of http request method. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.buildServiceRootStrategy = function buildServiceRootStrategy( token, context ) { context.logger.debug( 'authorizationProcessor', 'buildServiceRootStrategy()' ); return function (innerContext, done) { const scopes = innerContext.parentContext.getScopes(), method = innerContext.parentContext.request.method, isLinks = innerContext.parentContext.oData.isLinks, collectedScopes = exports.collectScopes(method, scopes, isLinks); innerContext.scopes = innerContext.scopes.concat(collectedScopes); return done(null, innerContext); }; }; /** * Builds the strategy that collects scopes from the entity-set level of a single * concerned DB-segment (dbSegmentLast). Scopes are collected in dependency of http request method. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.buildDbSegmentLastStrategy = function buildDbSegmentLastStrategy( token, context ) { context.logger.debug( 'authorizationProcessor', 'buildDbSegmentLastStrategy()' ); return function (innerContext, done) { const scopes = innerContext.parentContext.oData.dbSegmentLast.entityType.getScopes(), method = innerContext.parentContext.request.method, collectedScopes = exports.collectScopes(method, scopes); innerContext.scopes = innerContext.scopes.concat(collectedScopes); done(null, innerContext); }; }; /** * Builds the strategy that loops and collects scopes from the entity-set level * of consecutive DB-segments. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.buildMultipleDbSegmentsStrategy = function (token, context) { context.logger.debug( 'authorizationProcessor', 'buildMultipleDbSegmentsStrategy()' ); return function (innerContext, done) { let currentSegment, scopes, collectedScopes; const method = innerContext.parentContext.request.method; currentSegment = innerContext.parentContext.oData.dbSegment; while (currentSegment) { scopes = currentSegment.entityType.getScopes(); collectedScopes = exports.collectScopes(method, scopes); innerContext.scopes = innerContext.scopes.concat(collectedScopes); currentSegment = currentSegment.nextDBSegment; } done(null, innerContext); }; }; /** * Builds the strategy that collects scopes from the entity-set level of the two * concerned DB-segments of $links: toBeUpdated and keySource. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.build$linksStrategy = function build$linksStrategy(token, context) { context.logger.debug('authorizationProcessor', 'build$linksStrategy()'); return function (innerContext, done) { const method = innerContext.parentContext.request.method, toBeUpdated = innerContext.parentContext.oData.links.toBeUpdated, keySource = innerContext.parentContext.oData.links.keySource, toBeUpdatedScopes = toBeUpdated.entityType.getScopes(), keySourceScopes = keySource.entityType.getScopes(), m2n = innerContext.parentContext.oData.links.m2n; innerContext.scopes = innerContext.scopes.concat( collectLinkScopes(method, toBeUpdatedScopes, keySourceScopes, m2n) ); return done(null, innerContext); }; }; function collectLinkScopes(method, toBeUpdatedScopes, keySourceScopes, m2n) { const req = getLinkRequirements(method, m2n); let acc = []; acc = addScopes(acc, toBeUpdatedScopes, req.toBeUpdated); acc = addScopes(acc, keySourceScopes, req.keySource); return acc; } function getLinkRequirements(method, m2n) { const base = { GET: { toBeUpdated: ['read'], keySource: ['read'] }, POST: { toBeUpdated: ['create'], keySource: ['read'] }, PUT: { toBeUpdated: ['update'], keySource: ['read'] }, DELETE: { toBeUpdated: ['delete'], keySource: ['read'] }, }; let req = base[method] || { toBeUpdated: [], keySource: [] }; if (m2n === true) { const extra = { POST: { toBeUpdated: ['read'], keySource: ['create'] }, PUT: { toBeUpdated: ['read'], keySource: ['update'] }, DELETE: { toBeUpdated: ['read'], keySource: ['delete'] }, }; const add = extra[method]; if (add) { req = { toBeUpdated: req.toBeUpdated.concat(add.toBeUpdated), keySource: req.keySource.concat(add.keySource), }; } } return req; } function addScopes(acc, scopeObj, keys) { if (!keys || keys.length === 0) { return acc; } for (const key of keys) { if (scopeObj && scopeObj[key]) { acc = acc.concat(scopeObj[key]); } } return acc; } /** * Builds the strategy that collects scopes from the entity-set level of all * the concerned DB-segments of $expand. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.build$expandStrategy = function (token, context) { context.logger.debug('authorizationProcessor', 'build$expandStrategy()'); return function (innerContext, done) { const dbSeg = innerContext.parentContext.oData.dbSegmentLast; innerContext.scopes = innerContext.scopes.concat( getScopesFromExpandTree(dbSeg._ExpandedNavigationsDBSeg) ); done(null, innerContext); }; /** * Loops over an expand tree and recursively collects scopes from all composing entity sets. * * @param expTree {Object} The expand tree of a DB-segment. e.g. for the following request * -> Employees('3')?$expand=ne_Room,ne_Room/nr_Building * the expand tree will look like: * dbSegmentLast._ExpandedNavigationsDBSeg: { * ne_Room: { * _ExpandedNavigationsDBSeg: { * nr_Building: {} * } * } * } * @returns {Array<String>} Collection of scopes. */ function getScopesFromExpandTree(expTree) { let expandScopes = [], expandTreeHasProperties, elem, elemScopes; Object.keys(expTree).forEach(function (key) { elem = expTree[key]; elemScopes = elem.entityType.getScopes(); if (elemScopes && elemScopes.read) { expandScopes = expandScopes.concat(elemScopes.read); } expandTreeHasProperties = utils.hasProperties( elem._ExpandedNavigationsDBSeg ); if (expandTreeHasProperties) { expandScopes = expandScopes.concat( getScopesFromExpandTree(elem._ExpandedNavigationsDBSeg) ); } }); return expandScopes; } }; /** * Builds the strategy that collects the debug scope from the service-root level. * If no debug scope is found (when the debug view is called), a 403 forbidden error is directly * returned. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.buildDebugStrategy = function buildDebugStrategy(token, context) { context.logger.debug('authorizationProcessor', 'buildDebugStrategy()'); return function (innerContext, done) { const scopes = innerContext.parentContext.getScopes(); if (scopes && scopes.debug) { innerContext.scopes = innerContext.scopes.concat(scopes.debug); return done(null, innerContext); } else { return done( new ForbiddenError('Forbidden request', context), innerContext ); } }; }; /** * Builds the security strategy that passes all collected scopes from previous strategies to the * security context in order to be checked against the given token. This strategy should be last * strategy of the strategies processing chain. * * @param token {string} Authorization token extracted from the HTTP header. * @param context {Object} The OData context of the framework. * @returns {Function} Task to run in the async waterfall of the authorization processor. */ exports.buildSecurityContextStrategy = function buildSecurityContextStrategy( token, context ) { context.logger.debug( 'authorizationProcessor', 'buildSecurityContextStrategy()' ); return function (innerContext, done) { const scopes = innerContext.scopes; securityContext.checkScopes( context, token, scopes, function (err, isAuthorized) { innerContext.isAuthorized = isAuthorized; return done(err, innerContext); } ); }; }; /** * Collect scopes regarding to http method. This method collects read scopes for GET requests, * update scopes for PUT request, create scopes for POST request and delete scopes for DELETE * requests. If request is $links request this method also collects read scope * * @param method {String} The http method * @param scopes {Object} The current scope object abbreviated from xsodata scopes * @param isLinks {Boolean} If true read scope will be collected anyway * @returns {Array} Returns the collection of scopes like ["scope1", "scope2"] */ exports.collectScopes = function collectScopes(method, scopes, isLinks) { let collection = []; // The "read" scope is always needed for $links regardless of HTTP method, because when you // want to update/create/delete from an entitySet, you always have to READ the key from the // source entitySet if (method === 'GET' || isLinks === true) { if (scopes && scopes.read) { collection = collection.concat(scopes.read); } } if (method === 'POST' && scopes && scopes.create) { collection = collection.concat(scopes.create); } else if (method === 'PUT' && scopes && scopes.update) { collection = collection.concat(scopes.update); } else if (method === 'DELETE' && scopes && scopes.delete) { collection = collection.concat(scopes.delete); } return collection; };