@restorecommerce/acs-client
Version:
Access Control Service Client
425 lines • 16.1 kB
JavaScript
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