Role-based access control

[!IMPORTANT] The Angular permission guard is defence-in-depth and does not replace server-side authorization. Fineract Core remains the authoritative security boundary.

Every screen this application shows is backed by a Fineract API that performs its own permission check. A user who reaches a screen anyway — with a patched bundle, a stale cache, or a direct API call — gets no further than Fineract lets them. What the client-side controls buy is that a user is not led into a screen whose every request will 403, and that the set of URLs a user can reach agrees with the navigation they are shown.

Read that as a statement about what to rely on, not a licence to skip the client-side gate. Both halves are expected; neither substitutes for the other.

The three layers

LayerWhere it is declaredWhat it decides
Routedata.permissions on a route, enforced by permissionGuardwhether a URL opens
NavigationrequiredPermissions on a NAV_CONFIG entrywhether a menu item appears
Action*appHasPermission in a templatewhether a control is offered

All three ask the same question through the same method — AuthService.hasPermission() in src/app/core/services/auth.service.ts. There is one implementation of permission semantics and everything else defers to it.

Permission semantics

hasPermission(permission, matchAll = false):

  • ALL_FUNCTIONS admits everything. It is Fineract's superuser code.
  • ALL_FUNCTIONS_READ admits a request only when every required code begins with READ_. A requirement mixing a read code with a write one falls through to the ordinary check.
  • One code must be held.
  • Several codes are OR by default — any one admits. matchAll makes them AND.
  • Codes are trimmed when a session is stored. Fineract's seed contains a duplicate STANDINGINSTRUCTION family with a trailing space, and READ_STANDINGINSTRUCTION exists only in that padded form; without the trim, that gate could never be satisfied.

READ vs CREATE vs UPDATE

This is what makes ALL_FUNCTIONS_READ work, so it is a rule rather than a style:

{ path: '',          data: { permissions: 'READ_CLIENT' },    }  // list
{ path: 'view/:id',  data: { permissions: 'READ_CLIENT' },    }  // detail
{ path: 'create',    data: { permissions: 'CREATE_CLIENT' },  }  // form
{ path: 'edit/:id',  data: { permissions: 'UPDATE_CLIENT' },  }  // form

Declaring READ_CLIENT on the create route would hand a read-only user a form they cannot submit.

Use the code the screen's own API call requires — not the one that happens to be convenient.

Adding a permission-protected feature

1. Find the real permission code. GET /v1/permissions on a running Fineract returns the catalogue (698 codes on the version this was written against). Do not invent codes. A gate on a code that does not exist can never be satisfied by any role, which is worse than no gate.

bash scripts/e2e-stack.sh
curl -sk -u mifos:password -H 'Fineract-Platform-TenantId: default' \
  https://localhost:8443/fineract-provider/api/v1/permissions

If the screen needs a permission Fineract does not define, that is a finding — record the route in UNRESTRICTED (below) with the reason and raise it upstream.

2. Declare it on the route, with authGuard first:

{
  path: 'create',
  canActivate: [authGuard, permissionGuard],
  data: { permissions: 'CREATE_CHARGE' },
  loadComponent: () => import('./charge-form.component').then((m) => m.ChargeFormComponent),
}

Order matters. authGuard sends an unauthenticated visitor to /login; it must run first so a signed-out user is asked to sign in rather than told they are forbidden.

For AND semantics add permissionsMatchAll: true alongside permissions.

3. Declare the same code on the navigation entry, if the screen has one:

{ route: '/accounting/charges/create', requiredPermissions: 'CREATE_CHARGE', labelKey: '…' }

requiredAllPermissions: true is the navigation's spelling of permissionsMatchAll.

4. Gate the control that leads there. A list built on app-data-table takes a createPermission input:

<app-data-table
  createButtonLabel="CHARGES.CREATE"
  createPermission="CREATE_CHARGE"></app-data-table>

Anything else uses the structural directive:

<ion-button *appHasPermission="'APPROVE_LOAN'" (click)="onApprove()"></ion-button>
<ion-item *appHasPermission="['A', 'B']; matchAll: true"></ion-item>

5. Run the drift checknpm run check:route-permissions.

The drift check

scripts/check-route-permissions.mjs compares the route tables against NAV_CONFIG statically and runs in CI beside the other static checks. It fails when:

  • a screen route declares no permission and is not in UNRESTRICTED;
  • a route declares permissions but has no permissionGuard, or does not run authGuard first;
  • a navigation entry and its route disagree on the codes, or on AND/OR semantics;
  • a navigation entry points at a path no route serves;
  • an UNRESTRICTED entry no longer matches a real route;
  • it parses zero routes, which would otherwise make it pass vacuously.

The comparison is static because it has to be: every feature is behind loadChildren, so a lazy feature's child routes are not in Router.config until something navigates into them — and the navigation is built at login, long before that.

The UNRESTRICTED allow-list

Entries are one of two kinds, and the distinction matters when adding one:

  • Self-service — the screen is about the signed-in user or their session (/profile, /dashboard, /forbidden, /login). Gating these on a Fineract permission would be wrong.
  • No such permission — Fineract's catalogue has no code covering the read. Tellers, share products and accounts, provisioning, ad-hoc queries and several others have CREATE/UPDATE codes but no READ. These are not exemptions anyone chose. Where write codes do exist, the write routes are gated even though the read route is not; that asymmetry is deliberate.

A screen that merely looks unprivileged is not automatically an entry. Check what its API requires first.

/forbidden

permissionGuard returns a UrlTree pointing at /forbidden, rendered by features/errors/access-denied.component.ts. The route sits inside the authenticated shell but carries no data.permissions of its own — a refusal that could itself be refused would loop.

It is deliberately a CanActivateFn and not a CanMatchFn. A failing canMatch makes the router carry on looking for a match and land on path: '**' → redirectTo: '', depositing the user on the dashboard with no indication anything was denied. That reads as a broken link rather than a decision, and it cannot be asserted on.

The page names what happened, takes focus on its heading (the user did not ask for this navigation), announces itself politely, and offers a return to the dashboard. It does not name the missing permission: a code is not something an end user can act on, and telling an unauthorized visitor exactly which grant would unlock a screen is a hint worth withholding.

rbacEnabled: false

A deployment can turn the whole thing off in config.json. All three layers honour it — the guard admits, the navigation shows everything, the directive renders. It exists for a gradual rollout, and the behaviour is the pre-RBAC one exactly.

It changes nothing server-side. Fineract still enforces permissions on every request.

Running the tests

# Unit — guard semantics, the Access Denied page, navigation filtering
npm run test -- --watch=false --browsers=ChromeHeadless

# Static — route/navigation parity
npm run check:route-permissions

# Mocked permission matrix: superuser, single-module, read-only, empty, missing,
# unknown code, unauthenticated, RBAC disabled
npx playwright test --project=mocked rbac-route-protection.spec.ts

# Real backend: seeds a role and a restricted user, signs in as them, and checks that
# Fineract refuses what the UI refused
bash scripts/e2e-stack.sh
npx playwright test --project=backend rbac-backend-restricted-user.spec.ts --workers=1

The backend spec uses seedRole() and seedRestrictedUser() in e2e/utils/seed-api.ts, plus statusAs(), which re-asks Fineract the same question as the restricted user so a spec can assert the platform's answer rather than infer it from the UI. Use it for any new refusal assertion: proving only that the client refused something would quietly invite the conclusion that the client is the boundary.

Seeded users get a password generated at run time by generatePassword() rather than a literal. Fineract's policy is ^(?!.*(.)\1)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^\w\s]).{12,50}$ — 12 to 50 characters, one of each class, no whitespace, and no character repeated consecutively. Two things about it are easy to get wrong: that last clause, whose validation error does not mention the rule until you read args; and [^\w\s], which excludes _ because \w includes it, so an underscore does not count as the punctuation the policy demands. Any literal satisfying all of that is by construction a credential-shaped string that secret scanners flag, which is the other reason it is generated.