import { CallExpressionShape, type Diagnostics, isGUID, type ObjectShape, Plugin, type Record, Shape, } from '@servicenow/sdk-build-core' import { AclTypes, AclOperations, AclAttributes, AclNamedTypes, AclDataBrokerType, } from '@servicenow/sdk-core/runtime/app' import { ModuleFunctionShape } from './server-module-plugin' import { NowIdShape } from './now-id-plugin' import { generateDeprecatedDiagnostics, validateServerScriptField } from './utils' const ExecuteOnlyTypes = new Set([ 'client_callable_flow_object', 'client_callable_script_include', 'graphql', 'processor', 'rest_endpoint', ]) const aclAliases = { adminOverrides: ['admin_overrides'], decisionType: ['decision_type'], localOrExisting: ['local_or_existing'], securityAttribute: ['security_attribute'], appliesTo: ['applies_to'], } function computeAclName(name: Shape, table: Shape, field: Shape, dataBroker: Shape) { if (dataBroker.isDefined()) { return dataBroker.isString() ? dataBroker.getValue() : dataBroker.asRecord().getId().getValue() } return name.ifString() ?? (field.ifString() ? `${table.getValue()}.${field.getValue()}` : table) } /** * @param config - an object containing the following properties: * * **$id** - unique id for the record, typically using `Now.ID["value"]` * * **operation** - the operation this ACL rule secure * * **active**? - whether the ACL is enabled * * **adminOverrides**? - indicates whether users with the admin role automatically pass the permissions check for this ACL rule * * **condition**? - a filter query that specifies the fields and values that must be true for users to access the object * * **decisionType**? - whether the ACL should allow or deny access * * **description**? - description of the object or permissions this ACL rule secures * * **localOrExisting**? - if `"Local"`: A security attribute based on the * condition property that is saved only for the ACL it is created in\ * if `"Exisiting"`: An existing security attribute to reference in the `security_attribute` property * * **roles**? - `Role` objects or sys_ids of roles that a user must have to access the object * * **script**? - a function or inline script preceded by a `script` tagged * template literal. The script should define the permissions required to access the object * * **securityAttribute**? - pre-defined conditions to use. For example, whether a user is impersonating another user */ export const AclPlugin = Plugin.create({ name: 'AclPlugin', records: { sys_security_acl: { relationships: { sys_security_acl_role: { via: 'sys_security_acl', descendant: true, relationships: { sys_user_role: { via: 'sys_user_role', inverse: true, }, }, }, }, toShape(record, { descendants }) { const type = reverseLookup(AclTypes, record.get('type').getValue() as string) || record.get('type') const roles = descendants.query('sys_security_acl_role').map((m2m) => m2m.get('sys_user_role')) return { success: true, value: new CallExpressionShape({ source: record, callee: 'Acl', args: [ record .transform(({ $, merge }) => ({ $id: $.val(NowIdShape.from(record)), condition: $.def(''), description: $.def(''), localOrExisting: $.from('local_or_existing').map((v) => v.ifString()?.isEmpty() ? undefined : v ), active: $.toBoolean().def(true), decisionType: $.from('decision_type').def('allow'), adminOverrides: $.from('admin_overrides').toBoolean().def(true), appliesTo: $.from('applies_to').def(''), type: $.val(type), securityAttribute: $.from('security_attribute') .map((v) => reverseLookup(AclAttributes, v.getValue() as string) || v) .def(''), operation: $.map((v) => reverseLookup(AclOperations, v.getValue() as string) || v), script: $.map((v) => v.ifString()?.getValue()).def(''), roles: $.val(roles.length > 0 ? roles : undefined), [merge]: $.from('type', 'name').map((type, name) => resolveAclTarget( type.pipe((t) => reverseLookup(AclTypes, t.getValue() as string)), name.asString().getValue() ) ), })) .withAliasedKeys(aclAliases), ], }), } }, }, sys_security_acl_role: { coalesce: ['sys_security_acl', 'sys_user_role'], }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { config, diagnostics, factory }) { if (callExpression.getCallee() !== 'Acl') { return { success: false } } const rawArgs = callExpression.getArgument(0).asObject() generateDiagnosticForDuplicatedAliases(rawArgs, diagnostics) const arg = rawArgs.withAliasedKeys(aclAliases) generateDeprecatedDiagnostics(arg, diagnostics) const operation = arg.get('operation') const type = arg.get('type').asString() if (!operation.equals('execute') && ExecuteOnlyTypes.has(type.getValue())) { diagnostics.error( operation, `ACL must use operation 'execute', because it is of type '${type.getValue()}'` ) } const script = arg.get('script') const advanced = !script.isUndefined() if (advanced && type.equals('graphql')) { diagnostics.error(script, `ACL does not support scripts, because it is of type graphql`) } const appliesTo = arg.get('appliesTo').ifString() if (appliesTo && !type.equals('record')) { diagnostics.error(appliesTo, `ACL cannot set applies_to unless its type is 'record'`) } generateDiagnosticForMutuallyExclusiveProperties(arg, diagnostics) // Check for deprecated table/field properties on ux_route and ux_page ACLs if (type.equals('ux_route') || type.equals('ux_page')) { const table = arg.get('table') const field = arg.get('field') if (!table.isUndefined() || !field.isUndefined()) { diagnostics.warn( table.isUndefined() ? field : table, `The 'table' and 'field' properties are deprecated for '${type.getValue()}' ACLs and will be ignored. Use the 'name' property instead.` ) } } if (type.equals('ux_data_broker')) { const dataBroker = arg.get('dataBroker') const table = arg.get('table') const field = arg.get('field') if (dataBroker.isUndefined() && table.isUndefined() && field.isUndefined()) { diagnostics.error( dataBroker, `ACL of type 'ux_data_broker' must have either a 'dataBroker' property or 'table' and 'field' properties defined` ) } if (dataBroker.isDefined() && (table.isDefined() || field.isDefined())) { diagnostics.error( dataBroker, `ACL of type 'ux_data_broker' cannot have both 'dataBroker' and 'table'/'field' properties defined` ) } if (dataBroker.isDefined()) { if (dataBroker.isString() && !isGUID(dataBroker.getValue())) { diagnostics.error( dataBroker, `'dataBroker' must be a valid GUID or a Record<'sys_ux_data_broker'>. Received: '${dataBroker.getValue()}'` ) } } } if (operation.equals('add_to_list')) { const condition = arg.get('condition').ifString() if (advanced) { diagnostics.error(script, `ACL cannot have a script due to its 'add_to_list' operation`) } else if (condition) { diagnostics.error(condition, `ACL cannot have a condition due to its 'add_to_list' operation`) } } validateServerScriptField(arg.get('script'), diagnostics, config.serverModulesDir) const acl = await factory.createRecord({ source: callExpression, table: 'sys_security_acl', explicitId: arg.get('$id'), properties: arg.transform(({ $ }) => ({ active: $.def(true), decision_type: $.from('decisionType').def('allow'), description: $.def(''), admin_overrides: $.from('adminOverrides').def(true), local_or_existing: $.from('localOrExisting').map((v) => v.omitFromXmlIfUndefined()), condition: $.def(''), applies_to: $.from('appliesTo').def(''), name: $.from('name', 'table', 'field', 'dataBroker').map(computeAclName), sys_name: $.from('name', 'table', 'field', 'dataBroker').map(computeAclName), type: $.map((type) => { const typeKey = type.asString().getValue() return AclTypes[typeKey as keyof typeof AclTypes] ?? type }), operation: $.map((op) => { const opKey = op.asString().getValue() const value = AclOperations[opKey as keyof typeof AclOperations] ?? opKey const operationShape = Shape.from(op, value) return operationShape.withXmlAttribute('display_value', opKey) }), advanced: $.val(advanced), script: $.map( (v) => v.if(ModuleFunctionShape)?.toString((n) => `answer = ${n}({{PARAMS}})`, ['current']) ?? v ).toCdata(), security_attribute: $.from('securityAttribute').map( (v) => v.ifString()?.pipe((v) => { const attrKey = v.getValue() return AclAttributes[attrKey as keyof typeof AclAttributes] }) ?? v ), })), }) const roles = arg.get('roles').ifArray()?.getElements() ?? [] const securityAttribute = arg.get('securityAttribute') const attributeRecord = securityAttribute.ifRecord() if (roles.length < 1) { if (!securityAttribute.getValue() && !advanced && !arg.get('condition').getValue()) { diagnostics.warn( callExpression, 'ACLs must have at least one of the following: roles, security_attribute, condition, or script' ) } else if (attributeRecord) { const localOrExisting = arg.get('localOrExisting').ifString()?.getValue() if ( localOrExisting === 'Existing' && (attributeRecord.get('type').getValue() !== 'compound' || attributeRecord.get('is_localized').getValue()) ) { diagnostics.error( arg, `Invalid ACL with 'Existing' security_attribute: Must have a security_attribute with a type of 'compound' and is_localized set to false.` ) } else if ( (!localOrExisting || localOrExisting === 'Local') && !attributeRecord.get('is_localized').ifBoolean()?.getValue() ) { diagnostics.error( arg, `Invalid ACL with 'Local' security_attribute: security_attribute must have is_localized set to true` ) } } } const related: Record[] = [] for (const role of roles) { const reference = role.isString() ? isGUID(role.getValue()) ? await factory.createReference({ source: role, table: 'sys_user_role', guid: role, }) : await factory.createReference({ source: role, table: 'sys_user_role', keys: { name: role }, }) : role related.push( await factory.createRecord({ source: callExpression, table: 'sys_security_acl_role', properties: { sys_security_acl: acl, sys_user_role: reference, }, }) ) } return { success: true, value: acl.with(...related), } }, }, ], }) function resolveAclTarget(type: keyof typeof AclTypes, name: string) { if (type in AclNamedTypes) { return { name } } const split = name.indexOf('.') const table = split === -1 ? name : name.substring(0, split) const field = split === -1 ? '' : name.substring(split + 1) if (type in AclDataBrokerType && isGUID(table) && !field) { return { dataBroker: table } } return { table, ...(field ? { field } : {}) } } function reverseLookup(obj: T, sysId: string): keyof T { return (Object.entries(obj) .filter(([_, id]) => id === sysId) .map(([key]) => key)[0] || '') as keyof T } function generateDiagnosticForDuplicatedAliases(object: ObjectShape, diagnostics: Diagnostics) { Object.keys(aclAliases).forEach((key) => { const alias = aclAliases[key as keyof typeof aclAliases].find((v) => object.get(v).isDefined()) if (alias && object.get(key).isDefined()) { diagnostics.error( object.get(key), `Both '${key}' and its deprecated alias '${alias}' are defined. Remove '${alias}' and keep '${key}'.` ) } }) } function generateDiagnosticForMutuallyExclusiveProperties(object: ObjectShape, diagnostics: Diagnostics) { const aclType = object.get('type').asString().getValue() if (!(aclType in AclTypes)) { const table = object.get('table') const field = object.get('field') const name = object.get('name') if (name.isDefined() && (table.isDefined() || field.isDefined())) { diagnostics.error( name, `'name' and 'table'/'field' are mutually exclusive. Use either 'name' alone or 'table' with an optional 'field', not both.` ) } const appliesTo = object.get('appliesTo') if (appliesTo.isDefined() && name.isDefined()) { diagnostics.error( appliesTo, `'appliesTo' can only be used with 'table' and 'field' properties. Remove 'appliesTo' when using 'name'.` ) } } }