UNPKG

@restorecommerce/acs-client

Version:

Access Control Service Client

687 lines 33.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.mapResourceURNObligationProperties = exports.createResourceFilterMap = exports.attributesMatch = exports.generateOperationStatus = exports.buildFilterPermissions = exports.handleError = exports._ = void 0; const lodash_1 = __importDefault(require("lodash")); // eslint-disable-next-line @typescript-eslint/no-require-imports require('deepdash')(lodash_1.default); exports._ = lodash_1.default; const config_1 = require("./config"); // @ts-expect-error TS7016 const node_eval_1 = __importDefault(require("node-eval")); const logger_1 = __importDefault(require("./logger")); const cache_1 = require("./acs/cache"); const authz_1 = require("./acs/authz"); const resource_base_1 = require("@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/resource_base"); const access_control_1 = require("@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/access_control"); const rule_1 = require("@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/rule"); const handleError = (err) => { let error; if (typeof err == 'string') { error = config_1.errors[err] || config_1.errors.SYSTEM_ERROR; } else { error = config_1.errors.SYSTEM_ERROR; } return error; }; exports.handleError = handleError; const reduceUserScope = (hrScope, reducedUserScope, hierarchicalRoleScoping) => { reducedUserScope.push(hrScope.id); if (hrScope?.children?.length > 0 && hierarchicalRoleScoping === 'true') { for (const childNode of hrScope.children) { reduceUserScope(childNode, reducedUserScope, hierarchicalRoleScoping); } } }; const checkTargetScopeExists = (hrScopes, targetScope, reducedUserScope, hierarchicalRoleScopingCheck) => { return hrScopes.some((hrScope) => { if (hrScope?.id === targetScope) { // found the target scope object, iterate and put the orgs in reducedUserScope array logger_1.default.debug(`Target entity match found in the user's hierarchical scope`); reduceUserScope(hrScope, reducedUserScope, hierarchicalRoleScopingCheck); return true; } else if (hrScope?.children?.length > 0 && hierarchicalRoleScopingCheck === 'true') { for (const childNode of hrScope.children) { if (checkTargetScopeExists([childNode], targetScope, reducedUserScope, hierarchicalRoleScopingCheck)) { return true; } } } return false; }); }; const checkSubjectMatch = (user, ruleSubjectAttributes, reducedUserScope) => { // 1) Iterate through ruleSubjectAttributes and check if the roleScopingEntity URN and // role URN exists // 2) Now check if the subject rule role value matches with one of the users ctx role_associations // then get the corresponding scope instance and check if the targetScope is present in user HR scope Object let hierarchicalRoleScopingCheck = 'true'; // by default HR scoping check is considered let ruleRoleValue; let ruleRoleScopeEntityName; const urns = config_1.cfg.get('authorization:urns'); if (ruleSubjectAttributes?.length === 0) { return true; } for (const attribute of ruleSubjectAttributes) { if (attribute?.id === 'urn:restorecommerce:acs:names:unauthenticated-user' && attribute?.value === 'true') { return true; } if (attribute?.id === urns.roleScopingEntity) { ruleRoleScopeEntityName = attribute.value; } else if (attribute.id === urns.role) { // urns.role -> urn:restorecommerce:acs:names:role ruleRoleValue = attribute.value; logger_1.default.debug(`Found Rule's Subject role ${ruleRoleValue}`); } else if (attribute?.id === urns.hierarchicalRoleScoping) { hierarchicalRoleScopingCheck = attribute.value; logger_1.default.debug('HR Scoping URN set on rule', { hierarchicalRoleScopingCheck }); } } if (ruleRoleValue && ruleRoleScopeEntityName) { const matchingRoleScopedInstance = user?.role_associations?.flatMap(ra => ra.attributes).filter(a => a?.id === urns?.roleScopingEntity && a?.value === ruleRoleScopeEntityName).flatMap(a => a?.attributes).filter(aa => aa?.id === urns?.roleScopingInstance).map(aa => aa.value); logger_1.default.debug('Role scoped instances for matching entity', { id: user?.id, ruleRoleScopeEntityName, matchingRoleScopedInstance }); // validate HR scope root ID contains the role scope instances const hrScopeExist = user?.hierarchical_scopes?.some((hrScope) => matchingRoleScopedInstance.includes(hrScope.id)); if (!hrScopeExist) { logger_1.default.info('Hierarchial scopes for matching role does not exist', { role: ruleRoleValue, hrScopes: user?.hierarchical_scopes, instances: matchingRoleScopedInstance }); return false; } else if (hrScopeExist && user?.scope) { logger_1.default.debug('Target scope set and HR scopes exist, validate target scope from HR scopes', { targetScope: user?.scope }); return checkTargetScopeExists(user?.hierarchical_scopes?.filter((hrScope) => matchingRoleScopedInstance?.includes(hrScope?.id) && hrScope?.role === ruleRoleValue), user?.scope, reducedUserScope, hierarchicalRoleScopingCheck); } else if (hrScopeExist && !user.scope) { // HR scope match exist but user has not provided scope so still a match is considered logger_1.default.debug('Target scope not provided using full HR tree for matched role', { role: ruleRoleValue }); // if no scope is provided then use the complete HR tree for user scopes user?.hierarchical_scopes?.filter((hrScope) => matchingRoleScopedInstance?.includes(hrScope?.id) && hrScope?.role === ruleRoleValue).forEach((eachHRScope) => { reduceUserScope(eachHRScope, reducedUserScope, hierarchicalRoleScopingCheck); }); return reducedUserScope?.length > 0; } } else if (ruleRoleValue) { return user?.role_associations?.some(ra => ra.role === ruleRoleValue); } return false; }; const validateCondition = (condition, request) => { const evalResponse = (0, node_eval_1.default)(condition, 'condition.js', request); if (typeof evalResponse === 'function') { return evalResponse(request); } else { return evalResponse; } }; const buildQueryFromTarget = (target, effect, userTotalScope, urns, scopingUpdated, reqResources, condition, reqSubject, database) => { const { subjects, resources } = target; let ruleCondition = false; let filter = []; const query = {}; let filterId; let filterOperator; if (condition) { ruleCondition = true; } // if there is a condition add this to filter if (condition && !exports._.isEmpty(condition)) { condition = condition.replace(/\\n/g, '\n'); if (!reqResources) { reqResources = []; } if (!exports._.isArray(reqResources)) { reqResources = [reqResources]; } const request = { target, context: { subject: { id: reqSubject.id, token: reqSubject.token, scope: reqSubject.scope, role_associations: reqSubject.role_associations }, resources: reqResources } }; try { filterId = validateCondition(condition, request); // special filter added to filter user read for his own entity if (filterId === true) { return; } else if (typeof filterId === 'string') { if (filterId && !scopingUpdated) { // verify if the returned filterId is same as the targetID if (reqResources && reqResources[0] && reqResources[0].filters && reqResources[0].filters.length > 0) { const targetId = reqResources[0]?.filters[0]?.filters[0]?.value; if (targetId && targetId === filterId) { ruleCondition = true; filter.push({ field: 'id', operation: resource_base_1.Filter_Operation.eq, value: filterId }); } } else { ruleCondition = true; filter.push({ field: 'id', operation: resource_base_1.Filter_Operation.eq, value: filterId }); } } } else if (typeof filterId === 'object') { // prebuilt filter // handle array if (filterId.filters && exports._.isArray(filterId.filters)) { filter.push(...filterId.filters); // map filter operator if its returned from condition if (filterId?.operator) { filterOperator = filterId.operator; } } else { ruleCondition = true; filter.push(filterId); } } else { return; } } catch (err) { logger_1.default.error('Error caught evaluating condition:', { condition }); logger_1.default.error('Error', { code: err.code, message: err.message, stack: err.stack }); return; } } const scopingAttribute = subjects?.find((attribute) => attribute.id == urns.roleScopingEntity); if (!!scopingAttribute && effect == rule_1.Effect.PERMIT && database === 'arangoDB' && !ruleCondition) { // note: there is currently no query to exclude scopes // userTotalScope is an array accumulated scopes for each rule query['scope'] = { custom_query: config_1.cfg.get('authorization:custom_query_name') ?? 'filterByOwnership', custom_arguments: { // value: Buffer.from(JSON.stringify({ entity: scopingAttribute.value, instance: userTotalScope } }; scopingUpdated = true; } else if (database && database === 'postgres' && effect == rule_1.Effect.PERMIT) { query['filters'] = []; const filterKeyMapArray = config_1.cfg?.get('authorization:filterParamKey'); let filterParamKey; if (Array.isArray(filterKeyMapArray)) { filterParamKey = filterKeyMapArray?.find((obj) => obj?.scopingEntity === scopingAttribute?.value)?.value; } if (!filterParamKey) { // default filter Paramkey for PostgresDB filterParamKey = 'orgKey'; } logger_1.default.debug('Filter paramter key for Postgres DB', { filterParamKey }); for (const eachScope of userTotalScope) { query['filters'].push({ field: filterParamKey, operation: 'eq', value: eachScope }); } // apply filter from condition for (const eachFilter of filter) { if (eachFilter && eachFilter.value) { query['filters'].push({ field: filterParamKey, operation: 'eq', value: eachFilter.value }); filter = []; } } } if (resources?.length > 0) { for (const attribute of resources) { if (attribute.id == urns.resourceID) { if (effect == rule_1.Effect.PERMIT) { filter.push({ field: 'id', operation: resource_base_1.Filter_Operation.eq, value: attribute.value }); } else { filter.push({ field: 'id', operation: resource_base_1.Filter_Operation.neq, value: attribute.value }); } // add ID filter } else if (attribute.id == urns.property) { // add fields filter if (!query['fields']) { query['fields'] = []; } query['fields'].push({ name: attribute.value.split('#')[1], include: effect == rule_1.Effect.PERMIT }); } } } const key = effect == rule_1.Effect.PERMIT ? resource_base_1.FilterOp_Operator.or : resource_base_1.FilterOp_Operator.and; if (query.filters) { // query.filters = { filter: query['filter'] }; // and or operator comparision query.filters.operator = key; // override the operator if its returned from rule condition if (filterOperator) { query.filters.operator = filterOperator; } delete query['filter']; } else if (!exports._.isEmpty(filter) || key == resource_base_1.FilterOp_Operator.or) { query['filters'] = filter; // override the operator if its returned from rule condition if (filterOperator) { query.filters.operator = filterOperator; } } query.scopingUpdated = scopingUpdated; return query; }; const buildFilterPermissions = async (policySet, subject, reqResources, database) => { if (subject?.id) { if (!subject.hierarchical_scopes?.length) { subject.hierarchical_scopes = await (0, cache_1.get)(`cache:${subject.id}:${subject.token}:hrScopes`); } if (!subject.hierarchical_scopes?.length) { subject.hierarchical_scopes = await (0, cache_1.get)(`cache:${subject.id}:hrScopes`); } if (!subject.role_associations?.length) { subject.role_associations = await (0, cache_1.get)(`cache:${subject.id}:subject`).then(subject => subject?.role_associations ?? []); } } else { subject.hierarchical_scopes ??= []; subject.role_associations ??= []; } const urns = config_1.cfg.get('authorization:urns'); const query = { filters: [] }; const pSetAlgorithm = policySet.combining_algorithm; const policyEffects = []; const policyFiltersArr = []; if (policySet?.policies?.length > 0) { for (const policy of policySet.policies) { if (policy.has_rules) { const algorithm = policy.combining_algorithm; // iterate through policy_set and check subject in policy and Rule: if (policy?.target?.subjects) { const userSubjectMatched = checkSubjectMatch(subject, policy.target.subjects); if (!userSubjectMatched) { logger_1.default.debug(`Skipping policy as policy subject and user subject don't match`); continue; } } let effect; for (const rule of policy.rules || []) { if (algorithm == urns.permitOverrides && rule.effect == rule_1.Effect.PERMIT) { effect = rule_1.Effect.PERMIT; break; } else if (algorithm == urns.denyOverrides && rule.effect == rule_1.Effect.DENY) { effect = rule_1.Effect.DENY; } } if (effect === undefined) { effect = algorithm == urns.permitOverrides ? rule_1.Effect.DENY : rule_1.Effect.PERMIT; } let scopingUpdated = false; for (const rule of policy?.rules || []) { const reducedUserScope = []; if (rule?.target?.subjects) { const userSubjectMatched = checkSubjectMatch(subject, rule.target.subjects, reducedUserScope); if (!userSubjectMatched) { logger_1.default.debug(`Skipping rule as user subject and rule subject don't match`); continue; } } const filterPermissions = buildQueryFromTarget(rule.target, rule.effect, reducedUserScope, urns, scopingUpdated, reqResources, rule.condition, subject, database); if (!exports._.isEmpty(filterPermissions)) { scopingUpdated = filterPermissions.scopingUpdated; delete filterPermissions.scopingUpdated; } if (!exports._.isEmpty(filterPermissions)) { policyFiltersArr.push(filterPermissions); // if reducedUserScope is empty - no filters are applied further // as this is a rule without scoping and should override the filters // from other Rules which have scoping entity if (exports._.isEmpty(reducedUserScope) && rule.effect === effect) { return { filters: [] }; } } } policyEffects.push(effect); } else { policyEffects.push(policy.effect); } } } if (exports._.isEmpty(policyEffects)) { return null; } let applicable; if (pSetAlgorithm == urns.permitOverrides) { applicable = exports._.includes(policyEffects, rule_1.Effect.PERMIT) ? rule_1.Effect.PERMIT : rule_1.Effect.DENY; } else { applicable = exports._.includes(policyEffects, rule_1.Effect.DENY) ? rule_1.Effect.DENY : rule_1.Effect.PERMIT; } const key = applicable == rule_1.Effect.PERMIT ? 'or' : 'and'; if (policyFiltersArr.length === 0) { return undefined; } let aqlQueryFilters = false; for (const policy of policyFiltersArr) { let filterList = []; // fix to override the AQL query filters with ACS policy filters if they exist for ArangoDB // TODO remove this once the AQL filterByOwnership is removed and ACS policy filters are returned if (policy?.scope && applicable == rule_1.Effect.PERMIT && !query['custom_query']) { if (!query['custom_queries']) { query['custom_queries'] = []; } // example Policy // {"scope":{"custom_query":"filterByOwnership", // "custom_arguments":{"entity":"urn:restorecommerce:acs:model:organization.Organization","instance":["restorecommerce-demo-customer-000-organization"] } // }, "filters":[],"scopingUpdated":true} if (policy?.scope?.custom_query && policy?.scope?.custom_arguments?.instance?.length > 0) { let customQueryExist = false; if (query['custom_queries']?.length > 0) { customQueryExist = query['custom_queries'].some((obj) => obj === policy.scope.custom_query); } // policy.scope.custom_query -> filterByOwnerShip does not exist or is a different AQL query if (!customQueryExist) { query['custom_queries'].push(policy.scope.custom_query); } if (!query['custom_arguments']) { query['custom_arguments'] = []; } const customArgEntityExist = query['custom_arguments']?.find((obj) => obj?.entity === policy?.scope?.custom_arguments?.entity); if (!customArgEntityExist) { query['custom_arguments']?.push(policy?.scope?.custom_arguments); } else { // same entity already exists, update instances on this object query['custom_arguments']?.forEach((obj) => { if (obj?.entity === policy?.scope?.custom_arguments?.entity) { obj?.instance?.push(...policy?.scope?.custom_arguments?.instance || []); } }); } aqlQueryFilters = true; } } if (policy?.filters && !aqlQueryFilters) { filterList = policy.filters; } for (const filter of filterList) { query.filters.push(filter); } if (exports._.isArray(filterList) && filterList.length > 0) { query.filters.operator = key; // override the operator if its returned from rule condition if (policy?.filters?.operator) { query.filters.operator = policy.filters.operator; } } if (policy.fields) { if (!query['fields']) { query['fields'] = policy.fields; } else { query['fields'] = policy.fields.concat(query['fields']); } } } if (aqlQueryFilters && query?.filters?.length > 0) { query.filters = []; } if (!exports._.isEmpty(query) && (!exports._.isNil(query.filters) || !exports._.isEmpty(query['fields']) || !exports._.isEmpty(query['custom_query']))) { if (query['custom_arguments']) { query['custom_arguments'] = { value: Buffer.from(JSON.stringify(query['custom_arguments'])) }; } query.filters = [{ filters: query.filters }]; return query; } return undefined; }; exports.buildFilterPermissions = buildFilterPermissions; const generateOperationStatus = (code, message) => { if (!code) { code = 500; // Internal server error } return { code, message }; }; exports.generateOperationStatus = generateOperationStatus; /** * Check if the attributes of a resources from a rule, policy * or policy set match the attributes from a request. * * @param ruleAttributes * @param requestAttributes */ const attributesMatch = (ruleAttributes, requestAttributes) => { if (ruleAttributes?.length > 0) { for (const attribute of ruleAttributes) { const id = attribute.id; const value = attribute.value; const match = !!requestAttributes.find((requestAttribute) => { // return requestAttribute.id == id && requestAttribute.value == value; if (requestAttribute.id == id && requestAttribute.value == value) { return true; } else if (requestAttribute.id == id) { // rule entity const pattern = value.substring(value.lastIndexOf(':') + 1); const nsEntityArray = pattern.split('.'); // firstElement could be either entity or namespace const nsOrEntity = nsEntityArray[0]; const entityRegexValue = nsEntityArray[nsEntityArray.length - 1]; let reqNS, ruleNS; if (nsOrEntity.toUpperCase() != entityRegexValue.toUpperCase()) { // rule name space is present ruleNS = nsOrEntity.toUpperCase(); } // request entity const reqValue = requestAttribute.value; const reqAttributeNS = reqValue.substring(0, reqValue.lastIndexOf(':')); const ruleAttributeNS = value.substring(0, value.lastIndexOf(':')); // verify namespace before entity name if (reqAttributeNS != ruleAttributeNS) { return false; } const reqPattern = reqValue.substring(reqValue.lastIndexOf(':') + 1); const reqNSEntityArray = reqPattern.split('.'); // firstElement could be either entity or namespace const reqNSOrEntity = reqNSEntityArray[0]; const requestEntityValue = reqNSEntityArray[reqNSEntityArray.length - 1]; if (reqNSOrEntity.toUpperCase() != requestEntityValue.toUpperCase()) { // request name space is present reqNS = reqNSOrEntity.toUpperCase(); } if ((reqNS && ruleNS && (reqNS === ruleNS)) || (!reqNS && !ruleNS)) { const reExp = new RegExp(entityRegexValue); if (requestEntityValue.match(reExp)) { return true; } } } else { return false; } }); if (!match) { return false; } } } return true; }; exports.attributesMatch = attributesMatch; /** * creates resource filters and custom query / arguments for the resource list provided * It iterates through each resource and filter the applicable policies and * provide them to buildFilterPermissions to create filters for each of the resource requested * * @param {ACSResource[]} resource Contains resource name, resource instance and optional resource properties * @param {PolicSetResponse} policySetResponse contains set of applicable policies for entities list * @param {any} resources context resources * @param {AuthZAction} action Action to be performed on resource * @param {Subject} subject Contains subject information * @param {string} subjectID resolved subject identifier from token * @param {boolean} authzEnforced authorization enforcement flag * @param {string} targetScope target scope * @param {Database} database database used either `arangoDB` or `postgres`, * if this param is missing defaults to `arangoDB` * */ const createResourceFilterMap = async (resource, policySetResponse, resources, action, subject, subjectID, authzEnforced, targetScope, database) => { const resourceFilterMap = []; const customQueryArgs = []; for (const resourceObj of resource) { const resourcenameNameSpace = resourceObj.resource; let resourceNameSpace, resourceName; if (resourcenameNameSpace && resourcenameNameSpace.indexOf('.') > -1) { resourceNameSpace = resourcenameNameSpace.slice(0, resourcenameNameSpace.lastIndexOf('.')); // resource name from `.` till end, when no end index is specified for // slice api it returns till end of string resourceName = resourcenameNameSpace.slice(resourcenameNameSpace.lastIndexOf('.') + 1); } else { resourceName = resourcenameNameSpace; } const resourceType = (0, authz_1.formatResourceType)(resourceName, resourceNameSpace); const urns = config_1.cfg.get('authorization:urns'); const resourceValueURN = urns?.model + `:${resourceType}`; const resourcePolicies = { policy_sets: [{ policies: [] }] }; const resourceAttributes = [{ id: urns?.entity, value: resourceValueURN }]; if (policySetResponse && policySetResponse.policy_sets && policySetResponse.policy_sets.length > 0) { policySetResponse.policy_sets.forEach((policySet) => { const policies = policySet.policies; // check if the policy and rule set is applicable to the enitity if (policies?.length > 0) { for (const policy of policies) { const policyTargetResources = policy?.target?.resources; if (policyTargetResources) { const policyMatch = (0, exports.attributesMatch)(policyTargetResources, resourceAttributes); if (policyMatch && policy?.rules?.length > 0) { for (const rule of policy.rules) { const ruleMatch = (0, exports.attributesMatch)(rule?.target?.resources, resourceAttributes); if (ruleMatch) { resourcePolicies.policy_sets[0].policies.push(policy); break; } } } } else if (policy?.rules) { // check for rule for (const rule of policy.rules) { const ruleMatch = (0, exports.attributesMatch)(rule?.target?.resources, resourceAttributes); if (ruleMatch) { resourcePolicies.policy_sets[0].policies.push(policy); break; } } } } } }); } const permissionArguments = await (0, exports.buildFilterPermissions)(resourcePolicies.policy_sets[0], subject, resources, database); if (permissionArguments) { if (!exports._.isArray(permissionArguments.filters)) { permissionArguments.filters = [permissionArguments.filters]; } resourceFilterMap.push({ resource: resourceName, filters: permissionArguments.filters }); if (permissionArguments.custom_queries && permissionArguments.custom_arguments) { customQueryArgs.push({ resource: resourceName, custom_queries: permissionArguments.custom_queries, custom_arguments: permissionArguments.custom_arguments }); } } else if (authzEnforced) { const msg = [ `Access not allowed for request with subject:${subjectID},`, `resource:${resourceName}, action:${action}, target_scope:${targetScope};`, `the response was ${access_control_1.Response_Decision.DENY}` ].join(' '); const details = `Subject:${subjectID} does not have access to target scope ${targetScope}}`; logger_1.default.verbose(msg); logger_1.default.verbose('Details:', { details }); return { decision: access_control_1.Response_Decision.DENY, operation_status: (0, exports.generateOperationStatus)(Number(config_1.errors.ACTION_NOT_ALLOWED.code), msg) }; } else { logger_1.default.verbose([ `The Access response was ${access_control_1.Response_Decision.DENY} for a request from subject:${subjectID}`, `resource:${resourceName}, action:${action}, target_scope:${targetScope}`, `but since ACS enforcement config is disabled overriding the ACS result`, ].join(' ')); return { decision: access_control_1.Response_Decision.PERMIT, operation_status: { code: 200, message: 'success' } }; } } return { resourceFilterMap, customQueryArgs }; }; exports.createResourceFilterMap = createResourceFilterMap; /** * converts the Obligation Attribute[] to Obligation[] object * * @param {Attribute[]} obligation contains list of obligations * @returns {Obligation[]} maps the URNS of the entity to resource and obligation attributes * to property[]. * */ const mapResourceURNObligationProperties = (obligations) => { const mappedResourceObligation = []; const urns = config_1.cfg.get('authorization:urns'); if (obligations?.length > 0) { for (const obligationObj of obligations) { if (obligationObj?.id === urns.entity && obligationObj?.value) { const resourceValueURN = obligationObj.value; const resourceNameSpace = resourceValueURN.substring(resourceValueURN.lastIndexOf(':') + 1); let resource = resourceNameSpace.substring(resourceNameSpace.lastIndexOf('.') + 1); const resourceWithNameSpace = resourceNameSpace.substring(0, resourceNameSpace.lastIndexOf('.')); if (resource != resourceWithNameSpace) { // name space exists add the entity name to obligation as well with name space resource = resourceWithNameSpace; } const obligationAttributes = obligationObj.attributes; const property = new Set(); for (const obligationAttribute of obligationAttributes) { if (obligationAttribute.id === urns.maskedProperty) { property.add(obligationAttribute.value.substring(obligationAttribute.value.lastIndexOf('#') + 1)); } } mappedResourceObligation.push({ resource, property: Array.from(property) }); } } } return mappedResourceObligation; }; exports.mapResourceURNObligationProperties = mapResourceURNObligationProperties; //# sourceMappingURL=utils.js.map