This module implements CASL-based role-based access control (RBAC) for Aam Digital.
- Where it lives:
Config:PermissionsCouchDB document (JSON rules) - Frontend enforcement:
EntityAbilityblocks writes client-side before network - Backend enforcement: replication-backend filters reads and validates writes
- Configuration: Admins edit via Admin UI or directly in the database
- Rule format: CASL JSON syntax
- Admin configures permissions: An org's admin edits
Config:Permissionsin the database, defining which roles can do what on which entity types. - Backend enforces on read:
replication-backenduses CASL rules to filter what documents each user can sync (via_changesfeed and bulk-document endpoints). - Frontend enforces on write:
ndb-coreuses CASL rules to block write operations client-side (before hitting the network) viaEntityAbility.can()checks. - Backend enforces on write:
replication-backendalso checks permissions on write operations as defense-in-depth.
Key files:
permission-types.ts— type definitions (EntityActionPermission,DatabaseRule,DatabaseRules)ability/entity-ability.ts— extends CASLAbility, convertsEntityinstances to CASL subjectsability/ability.service.ts— loadsConfig:Permissionsand builds theEntityAbilitypermission-pipe/able.pipe.ts—abletemplate pipe, re-evaluated whenever the rules change../entity/entity-mapper/entity-mapper.service.ts— callsassertPermission()beforesave()/remove()(write enforcement)feature-permission/feature-permission.service.ts— admin-facing editing of per-role access to a single "feature" entity type
Flow:
- On login,
AbilityService.initializeRules()loads theConfig:Permissionsentity. getRulesForUser()merges rules:[...admin-default-rules, ...role-rules].- Rules are interpolated (
${user.entityId},${user.projects}) and fed toEntityAbility.update(rules). - When saving an entity,
EntityAbility.assertPermission(action, entity)throws ifability.cannot()— this blocks the write before network. - Reads are not gated client-side; PouchDB sync and the backend's replication filter determine what gets downloaded.
Rule evaluation order: Last matching rule wins (CASL default).
Key files:
src/permissions/rules/permission.ts— definesPermissionentity (doc IDConfig:Permissions)src/permissions/rules/rules.service.ts— loads and hot-reloadsConfig:Permissions, implementsgetRulesForUser()src/permissions/permission/permission.service.ts— buildsAbilityfrom rules, exportsgetAbilityFor()andisAllowedTo()src/restricted-endpoints/replication/changes/changes.controller.ts— filters_changesfeed viaability.can('read', doc)src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts— filters bulk-pull, bulk-push viaability.can()src/restricted-endpoints/document/document-write.service.ts— checks write permissions viaisAllowedTo()
Flow:
- On startup,
RulesService.onModuleInit()loadsConfig:Permissionsand subscribes to live changes to hot-reload. - If the doc is missing (404), it falls back to bootstrap mode: only
admin_appusers get full access. getRulesForUser(user)merges rules: for authenticated users,[...admin-default, ...role-rules](rule order same as frontend).- Rules are interpolated and passed to
PermissionService.getAbilityFor(user). - Every access point (changes feed, bulk-doc, REST write, design-doc) calls
.can()on this ability or goes throughisAllowedTo(). - CASL's deny-by-default means unauthenticated (anonymous) users only get access via the
_publicrules.
Choke points:
changes.controller.tsis the replication-feed authorization boundary — the vast majority of read traffic is filtered there.
Both repos use CASL v6.8.1. Key facts:
- Deny-by-default: If no rule matches a subject/action pair, access is denied.
- No specificity: CASL does not rank rules by how specific they are. Instead, it uses last matching rule wins — the last rule in the array that matches the subject/action/conditions determines the result, regardless of whether earlier rules are more specific.
- Inverted rules: A rule with
inverted: trueworks as a deny, not a grant. This can be confusing with multiple roles; use with care.- The Admin UI does not ensure the order or rules is kept, so inverted rules effects might change!
- Conditions: Rules can specify MongoDB-style conditions to restrict access to specific documents or fields. Conditions are only evaluated if the document is passed to
ability.can(subject, doc)— not just the subject type string. cannot(): Callingability.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:
- Go to Admin > User Roles & Permissions to edit the rules of each role
- The same screen offers the raw
Config:Permissionsdocument through its "Edit JSON" menu entry
Directly in the database:
- Use CouchDB Fauxton GUI or another database tool to edit the document directly
Per feature, from its admin list view:
- On an internal "feature" type (e.g. Export Templates, Email Templates, Public Forms) use the "Configure Permissions" button
- This only writes plain rules for that one entity type; roles decided by an advanced rule stay read-only and have to be edited in the role administration
Permissions use JSON format with a role → rules mapping:
{
"_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, orallfor any typeaction: What users can do —read,create,update,delete, ormanage(all operations)_default: Rules applied to all authenticated users (regardless of role)_public: Rules applied to anonymous (not logged-in) visitors- Reserved section keys:
_defaultand_publicare 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. - Combining roles: If a user has multiple roles, their rules are appended in order (the
_defaultrules 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": truerule 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:
{
"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:
{
"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.
The entries of an embedded table field are stored inside their record, not as documents of their own. Rules therefore cannot target an entry or its inner fields: an entry is checked against the field of the record holding it.
- Viewing an entry needs
readpermission for the record's field. - Adding, editing or deleting an entry needs
updatepermission for the record's field (createwhile the record is new). - Field restrictions and
conditionsrefer to the fields of the record. A rule naming an inner field of an entry has no effect.
If you change Config:Permissions:
- Users gain new permissions — The app automatically re-syncs available data. No action needed.
- Users lose permissions — Their local database is cleared on next login, and they re-sync only what they're allowed to access.
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).
To hide an element instead of disabling it, use the able pipe:
@if ("update" | able: myEntity) {
<button>Edit</button>
}
Both re-evaluate whenever the rules change, so the UI follows a login, a role change or an edit of the permissions config without a reload.
- Frontend:
src/app/core/permissions/ability/ability.service.spec.ts,entity-ability.spec.ts,feature-permission/feature-permission.service.spec.ts - Backend:
src/permissions/rules/rules.service.spec.ts,src/permissions/permission/permission.service.spec.ts
When testing permissions:
- Always pass the fully-loaded entity (not just the type string) if testing rules with conditions.
- Use
testing-entity-ability-factory.ts(frontend) or test fixtures intest/utils/test-app.ts(backend) to seed test rules. - Test both the allow and deny cases, especially for inverted rules.
- CASL Documentation — rule format, conditions, API reference
- Backend (
replication-backend) — authoritative enforcement, replication filtering