UNPKG

@restorecommerce/acs-client

Version:

Access Control Service Client

425 lines 16.1 kB
import { AuthZAction, } from './interfaces.js'; import { createChannel, createClient } from '@restorecommerce/grpc-client'; import { cfg, entities, updateConfig, urns } from '../config.js'; import logger, { setLogger, getLogger } from '../logger.js'; import { flushCache, getOrFill } from './cache.js'; import { Events, registerProtoMeta } from '@restorecommerce/kafka-client'; import { formatResourceType, mapResourceURNObligationProperties } from '../utils.js'; import { AccessControlServiceDefinition } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/access_control.js'; import { Response_Decision } from '@restorecommerce/rc-grpc-clients/dist/generated/io/restorecommerce/access_control.js'; import { protoMetadata as ruleMeta } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/rule.js'; import { protoMetadata as policyMeta } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/policy_set.js'; import { protoMetadata as policySetMeta } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/policy_set.js'; import { isEmptyish, map } from "remeda"; registerProtoMeta(ruleMeta, policyMeta, policySetMeta); export let authZ; export let unauthZ; export const createActionTarget = (action) => { if (Array.isArray(action)) { const actionList = []; for (let eachAction of action) { eachAction = eachAction.valueOf().toLowerCase(); actionList.push({ id: urns.actionID, value: urns.action + `:${eachAction}` }); } return actionList; } else { return [{ id: urns.actionID, value: urns.action + `:${action.valueOf().toLowerCase()}`, attributes: [] }]; } }; export const createSubjectTarget = (subject) => { if (subject.unauthenticated) { return [{ id: urns.unauthenticated_user, value: 'true', attributes: [] }]; } const flattened = [ { id: urns.subjectID, value: subject.id, attributes: [] } ]; if (subject.scope) { flattened.push({ id: urns.roleScopingInstance, value: subject.scope, attributes: [] }); } return flattened; }; export const createResourceTarget = (resource, action) => { const flattened = []; resource.forEach((resourceObj) => { if (action != AuthZAction.EXECUTE) { const resourcenameNameSpace = resourceObj.resource; const resourceInstance = resourceObj.id; const resourceProperty = resourceObj.property; let resourceNameSpace, resourceName; const index = resourcenameNameSpace?.indexOf('.'); if (index > -1) { resourceNameSpace = resourcenameNameSpace.slice(0, index); // resource name from `.` till end, when no end index is specified for // slice api it returns till end of string resourceName = resourcenameNameSpace.slice(index + 1); } else { resourceName = resourcenameNameSpace; } // entity - urn:restorecommerce:acs:names:model:entity const entityName = entities[resourceName] ?? `${urns.model}:${formatResourceType(resourceName, resourceNameSpace)}`; flattened.push({ id: urns.entity, value: entityName, attributes: [] }); // resource-id - urn:oasis:names:tc:xacml:1.0:resource:resource-id if (typeof resourceInstance === 'string') { flattened.push({ id: urns.resourceID, value: resourceInstance, attributes: [] }); } else if (resourceInstance && Array.isArray(resourceInstance) && resourceInstance.length > 0) { resourceInstance.forEach((instance) => { flattened.push({ id: urns.resourceID, value: instance, attributes: [] }); }); } // property - urn:restorecommerce:acs:names:model:property if (Array.isArray(resourceProperty) && resourceProperty.length > 0) { resourceProperty.forEach((property) => { flattened.push({ id: urns.property, value: `${entityName}#${property}`, attributes: [] }); }); } } else { flattened.push({ id: urns.operation, value: resourceObj.resource, attributes: [] }); } }); return flattened; }; export class UnAuthZ { acs; /** * * @param acs Access Control Service definition (gRPC) */ constructor(acs) { this.acs = acs; } encode(object) { if (object) { if (Array.isArray(object)) { return map(object, this.encode.bind(this)); } else { return { value: Buffer.from(JSON.stringify(object)) }; } } } async isAllowed(request, ctx, useCache) { const authZRequest = { target: { actions: createActionTarget(request.target.actions), subjects: createSubjectTarget(request.target.subjects), resources: createResourceTarget(request.target.resources, request.target.actions) }, context: { subject: this.encode(request.target.subjects), resources: this.encode(ctx.resources) } }; let response; try { const isAllowed = await getOrFill(authZRequest, async (_) => { return await this.acs.isAllowed(authZRequest); }, useCache, 'UnAuthZ:isAllowed'); response = { decision: isAllowed.decision, obligations: mapResourceURNObligationProperties(isAllowed.obligations), operation_status: isAllowed.operation_status }; } catch (err) { logger?.error('Error invoking access-control-srv isAllowed operation', { code: err.code, message: err.message, stack: err.stack }); if (!err.code) { err.code = 500; } response = { decision: Response_Decision.DENY, operation_status: { code: err.code, message: err.message } }; } if (isEmptyish(response)) { logger?.error('Unexpected empty response from ACS'); } return response; } async whatIsAllowed(request, ctx, useCache) { const authZRequest = { target: { actions: createActionTarget(request.target.actions), subjects: createSubjectTarget(request.target.subjects), resources: createResourceTarget(request.target.resources, request.target.actions) }, context: { subject: this.encode(request.target.subjects), resources: this.encode(ctx.resources) } }; let response; try { const whatIsAllowed = await getOrFill(authZRequest, async (req) => { return await this.acs.whatIsAllowed(authZRequest); }, useCache, 'UnAuthZ:whatIsAllowed'); response = { ...whatIsAllowed, obligations: mapResourceURNObligationProperties(whatIsAllowed.obligations) }; // TODO Decision? } catch (err) { logger?.error('Error invoking access-control-srv whatIsAllowed operation', { code: err.code, message: err.message, stack: err.stack }); if (!err.code) { err.code = 500; } response = { decision: Response_Decision.DENY, operation_status: { code: err.code, message: err.message } }; } if (isEmptyish(response)) { logger?.error('Unexpected empty response from ACS'); } return response; } } /** * General authorizer. Marshalls data and requests access to the Access Control Service (ACS). */ export class ACSAuthZ { acs; /** * * @param acs Access Control Service definition (gRPC) */ constructor(acs, ids) { this.acs = acs; } /** * Perform request to access-control-srv * @param request - authZRequest containing subject, resources and action * @param useCache * @returns {DecisionResponse} */ async isAllowed(request, ctx, useCache) { const authZRequest = this.prepareRequest(request); authZRequest.context = { subject: {}, resources: [], security: this.encode(request.context.security) }; const subject = { token: request.target.subjects.token }; let cachePrefix = 'ACSAuthZ'; if (request.target.subjects.id !== undefined) { cachePrefix = request.target.subjects.id + ':' + cachePrefix; } authZRequest.context.subject = this.encode(subject); authZRequest.context.resources = this.encode(ctx.resources); // for isAllowed we use the subject, action and resource fields .i.e. reqeust Target // since the context resources contains the values which would change for each // resource being created and should not be used in key when generating hash const cacheKey = { target: authZRequest.target }; let response; try { const isAllowed = await getOrFill(cacheKey, async (req) => { return await this.acs.isAllowed(authZRequest); }, useCache, cachePrefix + ':isAllowed'); response = { decision: isAllowed?.decision, obligations: mapResourceURNObligationProperties(isAllowed?.obligations), operation_status: isAllowed?.operation_status }; } catch (err) { logger?.error('Error invoking access-control-srv isAllowed operation', { code: err.code, message: err.message, stack: err.stack }); if (!err.code) { err.code = 500; } response = { decision: Response_Decision.DENY, operation_status: { code: err.code, message: err.message } }; } if (isEmptyish(response)) { logger?.error('Unexpected empty response from ACS'); } return response; } /** * Perform request to access-control-srv * @param request - authZRequest containing subject, resource and action * @returns {PolicySetRQ} * @param resource */ async whatIsAllowed(request, ctx, useCache) { const authZRequest = this.prepareRequest(request); authZRequest.context = { subject: {}, resources: [], security: this.encode(request.context.security) }; const subject = { token: request.target.subjects.token }; let cachePrefix = 'ACSAuthZ'; if (request.target.subjects.id !== undefined) { cachePrefix = request.target.subjects.id + ':' + cachePrefix; } authZRequest.context.subject = this.encode(subject); authZRequest.context.resources = this.encode(ctx.resources); let response; try { const whatIsAllowed = await getOrFill(authZRequest, async (req) => { return await this.acs.whatIsAllowed(authZRequest); }, useCache, cachePrefix + ':whatIsAllowed'); response = { ...whatIsAllowed, obligations: mapResourceURNObligationProperties(whatIsAllowed.obligations) }; // TODO Decision? } catch (err) { const { code, message, details, stack } = err; logger?.error('Error invoking access-control-srv whatIsAllowed operation', { code, message, details, stack }); response = { decision: Response_Decision.DENY, operation_status: { code: Number.isInteger(code) ? code : 500, message: message } }; } if (isEmptyish(response)) { logger?.error('Unexpected empty response from ACS'); } return response; } encode(object) { if (object) { if (Array.isArray(object)) { return map(object, this.encode.bind(this)); } else { return { value: Buffer.from(JSON.stringify(object)) }; } } } prepareRequest(request) { const { subjects, resources, actions } = request.target; const authZRequest = { target: { actions: createActionTarget(actions), subjects: createSubjectTarget(subjects), }, }; authZRequest.target.resources = createResourceTarget(resources, actions); return authZRequest; } } const acsEvents = [ 'policy_setCreated', 'policy_setModified', 'policy_setDeleted', 'policyCreated', 'policyModified', 'policyDeleted', 'ruleCreated', 'ruleModified', 'ruleDeleted', ]; const eventListener = async (msg, context, config, eventName) => { if (acsEvents.indexOf(eventName) > -1) { // no prefix provided, flush complete cache logger?.info(`Received event ${eventName} and hence evicting ACS cache`); await flushCache(); } }; export const initAuthZ = async (config, logger) => { if (!authZ) { if (config) { updateConfig(config); } if (logger) { setLogger(logger); } else { logger = getLogger(); } // gRPC interface for access-control-srv if (cfg.get('authorization:enabled')) { const kafkaCfg = cfg.get('authorization:events:kafka') ?? cfg.get('events:kafka'); const acsName = cfg.get('authorization:service') ?? 'acs-srv'; const grpcACSConfig = cfg.get('authorization:client')?.[acsName] ?? cfg.get('client')?.[acsName]; const acsClient = createClient({ ...grpcACSConfig, logger }, AccessControlServiceDefinition, createChannel(grpcACSConfig.address)); authZ = new ACSAuthZ(acsClient); unauthZ = new UnAuthZ(acsClient); // listeners for rules / policies / policySets modified, so as to // delete the Cache as it would be invalid if ACS resources are modified if (kafkaCfg && kafkaCfg.evictACSCache) { const events = new Events(kafkaCfg, logger); await events.start(); for (const topicLabel in kafkaCfg.evictACSCache) { const topicCfg = kafkaCfg.evictACSCache[topicLabel]; const topic = await events.topic(topicCfg.topic); if (topicCfg.events) { for (const eachEvent of topicCfg.events) { await topic.on(eachEvent, eventListener); } } } } return authZ; } } return authZ; }; //# sourceMappingURL=authz.js.map