This module implements CASL-based role-based access control (RBAC) for Aam Digital.
Config:Permissions CouchDB document (JSON rules)EntityAbility blocks writes client-side before networkConfig:Permissions in the database, defining which roles can do what on which entity types.replication-backend uses CASL rules to filter what documents each user can sync (via _changes feed and bulk-document endpoints).ndb-core uses CASL rules to block write operations client-side (before hitting the network) via EntityAbility.can() checks.replication-backend also checks permissions on write operations as defense-in-depth.ndb-core) architectureKey files:
permission-types.ts — type definitions (EntityActionPermission, DatabaseRule, DatabaseRules)ability/entity-ability.ts — extends CASL Ability, converts Entity instances to CASL subjectsability/ability.service.ts — loads Config:Permissions and builds the EntityAbility../entity/entity-mapper/entity-mapper.service.ts — calls assertPermission() before save() / remove() (write enforcement)Flow:
AbilityService.initializeRules() loads the Config:Permissions entity.getRulesForUser() merges rules: [...admin-default-rules, ...role-rules].${user.entityId}, ${user.projects}) and fed to EntityAbility.update(rules).EntityAbility.assertPermission(action, entity) throws if ability.cannot() — this blocks the write before network.Rule evaluation order: Last matching rule wins (CASL default).
replication-backend) architectureKey files:
src/permissions/rules/permission.ts — defines Permission entity (doc ID Config:Permissions)src/permissions/rules/rules.service.ts — loads and hot-reloads Config:Permissions, implements getRulesForUser()src/permissions/permission/permission.service.ts — builds Ability from rules, exports getAbilityFor() and isAllowedTo()src/restricted-endpoints/replication/changes/changes.controller.ts — filters _changes feed via ability.can('read', doc)src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts — filters bulk-pull, bulk-push via ability.can()src/restricted-endpoints/document/document-write.service.ts — checks write permissions via isAllowedTo()Flow:
RulesService.onModuleInit() loads Config:Permissions and subscribes to live changes to hot-reload.admin_app users get full access.getRulesForUser(user) merges rules: for authenticated users, [...admin-default, ...role-rules] (rule order same as frontend).PermissionService.getAbilityFor(user)..can() on this ability or goes through isAllowedTo()._public rules (legacy public read as a fallback).Choke points:
changes.controller.ts is the replication-feed authorization boundary — the vast majority of read traffic is filtered there.Both repos use CASL v6.8.1. Key facts:
inverted: true works as a deny, not a grant. This can be confusing with multiple roles; use with care.ability.can(subject, doc) — not just the subject type string.cannot(): Calling ability.cannot(action, subject) is literally !ability.can(action, subject).All permission rules are defined in a single document: Config:Permissions in the database.
Admins edit this document to control what each role can do.
In the Aam Digital app:
Config:Permissions document in the app's JSON editorDirectly in the database:
Permissions use JSON format with a role → rules mapping:
Example :{
"_id": "Config:Permissions",
"data": {
"_default": [
{
"subject": "Config",
"action": "read"
}
],
"field_officer": [
{
"subject": ["Child", "School"],
"action": "manage"
},
{
"subject": "Note",
"action": "manage",
"conditions": {
"authors": {
"$elemMatch": {
"$eq": "${user.entityId}"
}
}
}
}
],
"supervisor": [
{
"subject": "all",
"action": "manage"
}
]
}
}Key concepts:
subject: The entity type(s) — e.g., Child, School, Note, or all for any typeaction: What users can do — read, create, update, delete, or manage (all operations)_default: Rules applied to all authenticated users (regardless of role)_public: Rules applied to anonymous (not logged-in) visitors_default and _public are reserved section keys, not roles. The leading underscore keeps them from colliding with a realm role of the same name, and any user role that starts with _ is ignored when resolving rules. Older documents may still use the non-prefixed default / public names; these are read as a fallback: every place loading the document normalizes it with migrateLegacySectionKeys() (see permissions-config-migration.ts), so all readers can rely on the prefixed keys. The stored document is migrated to the underscore form by the oneoff-20260724-permissions-key-rename migration (see cli/migration/). _default and _public are the only allowed underscore-prefixed keys; do not create realm roles, or any other rule section, whose name starts with _._default rules first, then each role's rules). CASL evaluates them so that the last matching rule wins — this is not necessarily the most permissive one. This ordering matters when deny/inverted rules are involved: a later "inverted": true rule can revoke access granted earlier, and a later granting rule can re-enable access a previous inverted rule denied.Use "inverted": true to deny access instead of grant it:
{
"subject": "SensitiveReport",
"action": "delete",
"inverted": true
}This says: the role cannot delete SensitiveReport documents.
⚠️ Warning: When a user has multiple roles with overlapping inverted rules, it can be unclear which permissions actually apply. Use inverted rules sparingly and document them well.
Instead of allowing all access to an entity type, you can restrict to specific documents:
Example :{
"subject": "Note",
"action": "manage",
"conditions": {
"authors": {
"$elemMatch": {
"$eq": "${user.entityId}"
}
}
}
}This allows users to manage only Notes they authored.
Available user variables:
${user.entityId} — the entity ID of the currently logged-in user (e.g., User:john_doe)${user.projects} — array of projects linked to the user (if configured in the user entity)Example: Restrict by linked projects:
Example :{
"subject": "Report",
"action": "read",
"conditions": {
"project": {
"$in": "${user.projects}"
}
}
}Users can only read Reports linked to one of their assigned projects.
⚠️ Warning: Conditions do not support the $or operator (or $and, $nor, $not). Instead, you combine multiple separate can rules for the same action and subject to mimic an logical OR condition.
If you change Config:Permissions:
import { EntityAbility } from "@app/core/permissions/ability/entity-ability";
export class MyService {
constructor(private ability: EntityAbility) {
if (this.ability.can("read", new SomeEntity())) {
// Permission granted
} else {
// Permission denied
}
}
}<button
*appDisabledEntityOperation="{
entity: myEntity,
operation: 'update'
}"
>
Edit
</button>The DisableEntityOperationDirective automatically disables buttons based on the user's permissions.
Pass the entity and the operation (create, read, update, delete, manage).
src/app/core/permissions/ability/ability.service.spec.ts, entity-ability.spec.tssrc/permissions/rules/rules.service.spec.ts, src/permissions/permission/permission.service.spec.tsWhen testing permissions:
testing-entity-ability-factory.ts (frontend) or test fixtures in test/utils/test-app.ts (backend) to seed test rules.replication-backend) — authoritative enforcement, replication filtering