blob: 1e19912329667e823ffafe4f60bee44ee4926190 [file] [view]
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# 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
| Layer | Where it is declared | What it decides |
| ---------- | ------------------------------------------------------------ | ---------------------------- |
| Route | `data.permissions` on a route, enforced by `permissionGuard` | whether a URL opens |
| Navigation | `requiredPermissions` on a `NAV_CONFIG` entry | whether a menu item appears |
| Action | `*appHasPermission` in a template | whether 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:
```ts
{ 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
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:
```ts
{
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:
```ts
{ 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:
```html
<app-data-table
createButtonLabel="CHARGES.CREATE"
createPermission="CREATE_CHARGE"
></app-data-table>
```
Anything else uses the structural directive:
```html
<ion-button *appHasPermission="'APPROVE_LOAN'" (click)="onApprove()"></ion-button>
<ion-item *appHasPermission="['A', 'B']; matchAll: true"></ion-item>
```
**5. Run the drift check** — `npm 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
```bash
# 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.