Access Control
The permissions system is a hybrid of role-based access control (RBAC) and fine-grained access control (FGA). Permissions are assigned to roles, roles are assigned to users, and every permission can optionally carry fine-grained rules that check - and mutate - the request input and the response output.
The logic lives in the @node-c/domain-iam module's IAMAuthorizationService. It's use-case-agnostic: the HTTP API modules use it automatically for every controller route, and it can also be called directly from business logic.
Data model
Tables (in the reference app, apps/test):
permissions- the permissions themselves (see the fields below)roles-id,name(unique),isActive,isEditablerolePermissions-roleId,permissionId(many-to-many between roles and permissions)userAssignedRoles-roleId,userId(many-to-many between users and roles)
Permission fields:
id: number- auto-generated. Permissions are evaluated in ascendingidorder (see below), so the id matters.name: string- required, uniquemoduleName: string | null- the API module the permission applies to (for HTTP usage)resourceContext: string | null- the controller context (for HTTP usage)resources: string[] | null- the resources (route handlers) within the contextrequiredStaticData: Record<string, unknown> | nullinputDataFieldName: string | null- required ifuserFieldNameis setuserFieldName: string | null- required ifinputDataFieldNameis setallowedInputData: Record<string, unknown> | nullforbiddenInputData: Record<string, unknown> | nullallowedOutputData: Record<string, unknown> | nullforbiddenOutputData: Record<string, unknown> | null
Older versions used
authorizationPoints,userTypes,userTypeAuthorizationPoints,userTypeAssignedUsers,moduleNames,controllerNamesandhandlerNames. These were renamed by thePermissionsUpdatesandPermissionsUpdate2migrations - use the new names in any new SQL.
How the user's permissions are loaded
The users domain service's getUserWithPermissionsData loads the user with assignedRoles.permissions (and any relations the permissions need, such as assignedCourses in the reference app), then flattens the permissions into user.currentPermissions, keyed by permission id. The authorization middleware puts the user in req.locals.user - either from the token payload or, if localSearchForUsersEnabledOnAuthorization is enabled for the API module, by loading it on every request.
Any field referenced by a permission's userFieldName (e.g. assignedCourses.id) must be loaded on that user object, otherwise the check fails (see the FGA rules below).
HTTP usage
The HTTPAccessControlInterceptor (from @node-c/api-http) runs before every route handler of a controller that uses it (RESTAPIEntityController does) and calls IAMAuthorizationService.checkAccess with:
moduleName- the value provided forNodeCDomainIAMConstants.ACCESS_CONTROL_MODULE_NAMEin the API module (or the API module name)resourceContext- the string passed to@AccessControlContext('...')on the controller class (or itscontextfield, if an object is passed), falling back to the class nameresource- the value of@AccessControlResource('...')on the handler, or theresourceMapentry of the context object, falling back to the handler method name (e.g.find,findOne,delete)- the input data -
{ body, headers, params, query }of the request (not the wholereqobject - so input paths always start withbody.,headers.,params.orquery.) - the user -
req.locals.user
If access is denied, the interceptor throws HttpException('Forbidden', 403). If access is granted, it applies the input mutations to the request (see below) before the handler runs, and the output mutations to the handler's result.
Other cases:
req.localsnot set (the authorization middleware didn't run) → 403.- Anonymous routes and API key routes (
locals.isAnonymous/locals.isApiKeyRoute) skip access control entirely. - No module name configured (neither
ACCESS_CONTROL_MODULE_NAMEnorAPI_MODULE_NAME) → 500, withNo moduleName configured for <Class>.<handler>.in the logs.
Direct usage
const { errorCode, hasAccess, inputDataToBeMutated, permissions } = IAMAuthorizationService.checkAccess(
inputData, // any object - the paths in the permissions are resolved against it
user, // must have currentPermissions
{ moduleName, resourceContext, resource }
);
// apply inputDataToBeMutated to the input with setNested, if access was granted
const { outputDataToBeMutated } = IAMAuthorizationService.processOutputData(permissions, outputData);permissions contains only the permission that granted access.
resourceis optional. Without it, only the module is checked (step 2 below is skipped).- Passing a
resourcewithout aresourceContextthrows anApplicationError.
How a check is evaluated
checkAccess goes through user.currentPermissions in ascending id order and stops at the first permission that passes. Only that permission's mutations and output rules are applied.
For each permission:
- RBAC - module. If
moduleNamedoesn't match, the permission is skipped. - RBAC - resource. If a resource is being checked and the permission's
resourceContextdoesn't match or itsresourcesdon't include it, the permission is skipped. - FGA - required static data. Every key of
requiredStaticDatais a path into{ inputData, user }(e.g.inputData.query.filters.courseTypeId). If any value doesn't match, the permission fails and the next one is checked. - FGA - user field vs input field (when both
userFieldNameandinputDataFieldNameare set):- if the user value is
undefined(including an empty array - see the pitfalls) → the permission fails - if the input value is missing → it's set to the user value (e.g.
query.filters.idbecomes the user's assigned course ids) and the permission passes - if the input value is present and none of its values match the user value(s) → the permission fails
- if the input value is an array → it's narrowed to the matching values (a single matching value is kept as is)
- if the user value is
- Input whitelist -
allowedInputData. For each path, values that don't match the allowed values are removed. If none match, the field is set toundefined. - Input blacklist -
forbiddenInputData. For each path, if any value matches, the whole field is set toundefined(e.g.query.include: ['courseType', 'category']with a forbidden/category/removes the whole include, not justcategory).
If no permission passes, hasAccess is false and errorCode is one of:
RBAC_NO_ACCESS_TO_MODULE- the user has no permissions, or all of them are for other modulesRBAC_NO_ACCESS_TO_RESOURCE- all of the user's permissions are for the module, but none cover the resource context/resourceFGA_NO_ACCESS- anything else. Usually a permission covered the resource but its fine-grained checks failed, but this is also the code when the user's permissions are a mix of other-module and other-resource ones.
The interceptor logs the error code: [<module>][HTTPAccessControlInterceptor]: No user access to resource <module>.<context>.<resource> - <errorCode>. - check the app logs when debugging a 403.
Output rules
After the handler, for the permission that granted access:
allowedOutputData- values at the path that don't match are set toundefined(and so dropped from the JSON response)forbiddenOutputData- values at the path that match are set toundefined
Paths go through arrays, e.g. result.items.deletedAt applies to every item of a find result, and result.courseType.createdAt to an included relation of a findOne result.
Value matching
Values are compared with IAMAuthorizationService.testValue:
- a string that starts and ends with
/is a regular expression, tested withRegExp.testagainst the value (or itsJSON.stringifyoutput, for non-strings) - objects are compared by their JSON representation
- otherwise, the value is compared as-is, as an integer and as a float (so
'2'from a query string matches2), and'true'/'false'match booleans
Example
From the reference app's seeds - non-admin users can find free courses and the courses assigned to them:
// id 1 - checked first
{
name: 'NonAdminFindFreeCourses',
moduleName: 'coursePlatform',
resourceContext: 'CoursePlatformCoursesEntityController',
resources: ['find'],
requiredStaticData: { 'inputData.query.filters.courseTypeId': 1 },
allowedInputData: { 'query.include': ['/^lessons(\\.lessonType)?$/', 'courseType'] },
allowedOutputData: { 'result.items.createdAt': '/[0-9-.:TZ]/', 'result.items.deletedAt': '/[0-9-.:TZ]/' }
}
// id 3 - only reached if id 1 fails
{
name: 'NonAdminFindNonFreeCourses',
moduleName: 'coursePlatform',
resourceContext: 'CoursePlatformCoursesEntityController',
resources: ['find'],
inputDataFieldName: 'query.filters.id',
userFieldName: 'assignedCourses.id',
allowedInputData: { 'query.include': ['/^lessons(\\.lessonType)?$/', 'courseType'] }
}GET /courses?filters[courseTypeId]=1&include[]=lessons&include[]=category→ permission 1 passes,categoryis removed from the include, anddeletedAt(which isnulland so doesn't match the pattern) is removed from every item.GET /courses→ permission 1 fails (nocourseTypeId), permission 3 passes and setsquery.filters.idto the user's assigned course ids.GET /courses?filters[id]=3(course 3 not assigned) → both fail → 403FGA_NO_ACCESS.
WARNING
Anchor your regular expressions (/^lessons(\.lessonType)?$/, not /lessons/) - RegExp.test isn't anchored, so an unanchored pattern also allows values that merely contain it.
WARNING
Input paths start with body, headers, params or query - a route param is params.id, not query.params.id. A path that never exists is set from the user's value, and the permission grants access.
TIP
Permissions are checked in ascending id order, and the first one that passes wins - so a new permission with a higher id is only reached when all the lower ones for the same resource fail. See the Adding a permission recipe.
Previous: Authentication · Next: API keys and CORS