Aman-Mittal opened a new pull request, #368:
URL: https://github.com/apache/fineract-backoffice-ui/pull/368

   Closes #367
   
   ## Summary
   
   An authenticated user could reach any screen in the application by knowing 
its URL. `authGuard`
   answered *"is this user signed in?"*; nothing answered *"may this user open 
this feature?"*. All
   324 routes carried authentication and nothing else, so a user holding 
`READ_CLIENT` could type
   `/accounting/chart-of-accounts` into the address bar and the screen opened. 
The sidebar hid the
   entry; the URL handed it straight back.
   
   This PR closes that, brings navigation visibility into agreement with it, 
gates the privileged
   actions behind it, and turns on the release gate that has been failing 
without anyone seeing it.
   
   > **The Angular permission guard is defence-in-depth and does not replace 
server-side
   > authorization. Fineract Core remains the authoritative security 
boundary.** Every screen here is
   > backed by an API that performs its own permission check. What this buys is 
that a user is not led
   > into a screen whose every request will 403, and that the URLs a user can 
reach agree with the
   > navigation they are shown. The backend E2E spec exists to demonstrate 
that, not to obscure it.
   
   ## Changes
   
   **Route authorization** — `core/guards/permission.guard.ts`. Reads 
`data.permissions` (and
   `data.permissionsMatchAll`) from the route and refuses to `/forbidden`. 
Evaluation is delegated
   entirely to the existing `AuthService.hasPermission()`, so there is no 
second permission
   implementation and `ALL_FUNCTIONS` / `ALL_FUNCTIONS_READ` work with no new 
logic.
   
   Deliberately a `CanActivateFn`, not a `CanMatchFn`: a failing `canMatch` 
makes the router carry on
   looking and land on `path: '**' → redirectTo: ''`, silently depositing the 
user on the dashboard.
   That reads as a broken link rather than a decision, and cannot be asserted 
on.
   
   **Access Denied** — `features/errors/access-denied.component.ts` at 
`/forbidden`, inside the
   authenticated shell but carrying no permission of its own (a refusal that 
could itself be refused
   would loop). Single `<h1>` that takes focus on arrival — the user did not 
ask for this navigation —
   a polite `aria-live` region, keyboard-reachable return to the dashboard, 
i18n keys in `en`/`hi`/`ko`.
   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 unlocks a screen is a 
hint worth withholding.
   
   **Route requirements** — 263 of 298 screens now declare one, across all 23 
feature route files.
   Lists and detail views take `READ_*`, create routes `CREATE_*`, edit routes 
`UPDATE_*`. That split
   is not stylistic: `ALL_FUNCTIONS_READ` admits a request only when *every* 
required code is a read
   one, so declaring `READ_CLIENT` on the create route would hand a read-only 
user a form they cannot
   submit.
   
   **Navigation parity** — `requiredPermissions` declarations go from 21 to 
100, covering every one
   of the 115 routed entries whose route is gated. `filterNavItems()` is 
unchanged and no second
   navigation permission system was introduced; this is data.
   
   **Anti-drift check** — `scripts/check-route-permissions.mjs`, in the style of
   `check-internal-endpoints.mjs`. Static by necessity: every feature is behind 
`loadChildren`, so a
   lazy feature's routes are absent from `Router.config` until something 
navigates into them — long
   after the navigation is built at login.
   
   **Action-level authorization** — the shared `app-data-table` gains a 
`createPermission` input, and
   all 58 list screens with a create button now name the permission their own 
create route declares.
   `loan-view` had 4 gated actions out of 33; the remaining 29 (approve, 
disburse, reject, write-off,
   charge-off, waive, repayment, re-age, re-amortize, refunds, …) are now gated 
with the code Fineract
   requires for each. The existing `*appHasPermission` directive is the 
mechanism throughout.
   
   **Four permission codes that do not exist** were being used, and so could 
never be satisfied by any
   role — the controls behind them were invisible to everyone except 
`ALL_FUNCTIONS` holders:
   
   | Used | Actual Fineract code | Where |
   |---|---|---|
   | `CREATE_/UPDATE_/DELETE_CLIENTFAMILYMEMBER` | `..._FAMILYMEMBERS` | 
`client-family-members-list` |
   | `CREATE_/UPDATE_/DELETE_NOTE` | `..._CLIENTNOTE` | `client-notes-list` |
   | `CREATE_/UPDATE_/DELETE_DELINQUENCYBUCKET` / `...RANGE` | 
`..._DELINQUENCY_BUCKET` / `..._RANGE` | `delinquency-management` |
   | `READ_LOANCOLLATERAL` | `READ_COLLATERAL` | `loans-list` |
   
   **A session with no `permissions` field crashed login.** `setSession` called
   `normalizePermissions(session.permissions)` unguarded while 
`getStoredSession` used `?? []`; a
   truncated or malformed response threw inside `login()` and the user could 
not sign in at all.
   
   **GA gate** — `npm run ga:check` has existed and failed without CI ever 
running it. It now runs as
   a blocking job, alongside `npm run api:surface`. No gate was weakened and no 
blocking failure was
   downgraded; the adapter-boundary backlog stays advisory and its count is 
unchanged at 420.
   
   The API surface manifest was re-recorded after reviewing all 21 reported 
entries individually: 4
   services genuinely removed upstream (MIX ×3, CashierJournals — retirements 
already established),
   and 17 operations with real non-spec call sites in merged work. One further 
stale entry surfaced,
   `RunReportsService.getRunreportsReportName` — 
`report-execution.service.ts:244` builds that URL by
   hand, so nothing calls the generated operation.
   
   **Documentation** — `DOCS/RBAC.md`, with a pointer from `security.md`. The 
"not release-ready"
   notice in `security.md` stays; that comes out when the gates justify it, not 
because this landed.
   
   ## RBAC
   
   - **Source of truth**: `AuthService.hasPermission()`. Route guard, 
navigation service and the
     `*appHasPermission` directive all call it. Nothing re-implements the 
semantics.
   - **Super user**: `ALL_FUNCTIONS` admits everything, everywhere.
   - **Read-only**: `ALL_FUNCTIONS_READ` admits a requirement only when every 
code in it starts with
     `READ_`. A requirement mixing a read code with a write one falls through 
to the ordinary check.
   - **OR / AND**: several codes are OR by default; `permissionsMatchAll: true` 
on a route (or
     `requiredAllPermissions: true` on a nav entry) makes them AND.
   - **Ordering**: always `canActivate: [authGuard, permissionGuard]`. An 
unauthenticated visitor must
     reach `/login`, never `/forbidden`. The drift check enforces the order.
   - **Navigation is not the boundary**: every hidden entry is also refused by 
URL. That is the whole
     point — hiding alone was the old behaviour.
   - **`rbacEnabled: false`** restores pre-RBAC behaviour across all three 
layers, for deployments
     mid-rollout. It changes nothing server-side.
   
   ### Permission codes came from the platform, not from memory
   
   Every code was checked against `GET /v1/permissions` on a running Fineract 
(698 codes). Nothing was
   invented. Where Fineract defines no read code, the screen is recorded in the 
check's `UNRESTRICTED`
   list with that reason rather than given a fabricated gate that no role could 
satisfy — tellers,
   share products and accounts, provisioning, ad-hoc queries, entity mapping, 
group levels, external
   asset owners and the SPM screens are all in this category. Where write codes 
*do* exist for the
   same feature, the write routes are gated even though the read route is not; 
that asymmetry is
   deliberate and documented.
   
   One subtlety worth recording: Fineract seeds a duplicate 
`STANDINGINSTRUCTION ` family whose codes
   carry a **trailing space**, and `READ_STANDINGINSTRUCTION` exists *only* in 
that padded form. The
   existing session normalisation in `AuthService` is what makes that gate 
satisfiable.
   
   ## Testing
   
   All run locally against a fresh stack (`bash scripts/e2e-stack.sh`), in this 
order:
   
   | Command | Result |
   |---|---|
   | `npm run lint:prune` | pass — no new suppressions |
   | `npm run format:check` | pass |
   | `npm run i18n:check` | pass — 1522 keys, none missing |
   | `npm run check:icons` | pass |
   | `npm run check:internal-endpoints` | pass |
   | `npm run check:route-permissions` | pass — 298 screens, 115 nav entries 
agree |
   | `npm run test` | **977 SUCCESS**, 0 failed (grepped for `✘`/`FAILED`, not 
the tail line) |
   | `npm run test:mfe` | 2 SUCCESS |
   | `npm run build` | pass — production bundle generated |
   | `npm run api:surface` | pass — 142 services, 555 operations |
   | `npm run ga:check` | **8/9, 0 blocking failures** (was 7/9 with 1 
blocking) |
   | `./scripts/check-license.sh` | pass |
   | `npx playwright test --project=mocked rbac-route-protection.spec.ts` | 
**15 passed** |
   | `npx playwright test --project=backend 
rbac-backend-restricted-user.spec.ts` | **7 passed** |
   | `npm run test:e2e:local -- --workers=1` | full suite (314 tests) — running 
at time of writing; result added below when it completes |
   
   The drift check was verified to fail, not merely to pass: a removed route 
permission, a nav/route
   mismatch, a reversed guard order and a stale allow-list entry were each 
injected and each produced
   a specific error naming the route, the expected value and the actual one.
   
   ## E2E scenarios
   
   **Mocked** (`e2e/rbac-route-protection.spec.ts`, 15 tests) — the permission 
set is injected by
   mocking the authentication response, so combinations that would be tedious 
to seed are covered on
   every PR:
   
   - `ALL_FUNCTIONS` superuser — every sampled route reachable, full navigation
   - `READ_CLIENT` only — `/clients` reachable, accounting/security/loans 
refused by URL
   - `ALL_FUNCTIONS_READ` — lists reachable, `/clients/create` and 
`/clients/edit/1` refused
   - empty permission list — refused everywhere, navigation collapses, 
self-service still reachable, no crash
   - **missing** `permissions` field — treated as none, not as a crash and not 
as a wildcard
   - unrecognised permission code — grants nothing
   - unauthenticated, direct URL — `/login`, **not** `/forbidden`
   - `rbacEnabled: false` — pre-RBAC behaviour restored
   - Access Denied — single heading, focus lands on it, polite announcement, 
dashboard return, reachable by keyboard alone
   - navigation parity — a hidden entry is also refused by URL; the create 
button is withheld where the create route would refuse
   
   **Real backend** (`e2e/rbac-backend-restricted-user.spec.ts`, 7 tests) — 
`seedRole()` and
   `seedRestrictedUser()` create a real Fineract role and user through `POST 
/roles`,
   `PUT /roles/{id}/permissions` and `POST /users`, with no hardcoded ids:
   
   - the seeded user holds exactly `READ_CLIENT` (asserted, so the rest of the 
spec cannot pass vacuously)
   - reaches `/clients`
   - refused `/accounting/chart-of-accounts` — **and** Fineract answers 403 to 
`GET /glaccounts` and `GET /offices`
   - refused `/clients/create` — **and** Fineract answers 403 to `POST /clients`
   - the create button and the unauthorized nav entries are absent
   - the superuser the rest of the backend suite signs in as is unaffected
   
   The paired backend assertion is the point. Proving only that the client 
refused something would
   quietly invite the conclusion that the client is the boundary.
   
   ## Release readiness
   
   | Item | Status |
   |---|---|
   | Build (production) | **PASS** |
   | Unit tests | **PASS** — 977 + 2 |
   | E2E — mocked RBAC | **PASS** — 15/15 |
   | E2E — real-backend RBAC | **PASS** — 7/7 |
   | E2E — full regression suite | see Testing table |
   | RBAC — route protection | **FIXED** — 263/298 screens gated, 35 documented 
|
   | RBAC — navigation protection | **FIXED** — 21 → 100 gates, full parity, 
checked in CI |
   | RBAC — action authorization | **FIXED** — 58 create buttons, 29 loan 
actions, 4 wrong codes corrected |
   | Access Denied UX | **FIXED** |
   | Accessibility (new surface) | **PASS** — heading, focus, `aria-live`, 
keyboard |
   | i18n | **PASS** — en/hi/ko, `i18n:check` green |
   | Security — session robustness | **FIXED** — missing `permissions` no 
longer breaks login |
   | License / ASF checks | **PASS** |
   | GA gate | **FIXED** — 0 blocking failures, and now blocking in CI |
   | API surface manifest | **FIXED** |
   | Adapter-boundary backlog | **NOT APPLICABLE** — advisory, out of scope, 
count unchanged |
   
   ### Remaining blockers
   
   None introduced by this PR. The pre-existing ones are unchanged and out of 
scope: the advisory
   adapter-boundary backlog (420 sites), and whatever else stands behind the 
"not release-ready"
   notice in `security.md`, which this PR deliberately leaves in place.
   
   ## Known limitations
   
   - **Client-side RBAC is defence-in-depth.** It is not a security boundary 
and must not be relied on
     as one. A user with a patched bundle reaches whatever Fineract lets them 
reach, and no more.
   - **35 screens are unrestricted because Fineract defines no read permission 
for them.** Their
     write routes are gated where write codes exist, but the reads are open to 
any authenticated
     user and the backend is the only thing deciding. Each is listed with its 
reason; several are
     arguably upstream gaps in the permission catalogue.
   - **`/products/:accountType/:accountId/action/:command`** is a dispatch 
route whose requirement
     depends on runtime parameters, so no static declaration would be right for 
most of the commands
     it serves. It is unrestricted and documented as such.
   - **Action-level gating is not exhaustive across all 291 components.** The 
privileged surfaces the
     audit identified are covered; the routes behind everything else are gated, 
so an ungated control
     leads to Access Denied rather than to an unauthorized operation.
   - The mocked matrix proves the client is self-consistent, not that Fineract 
agrees. Only the
     backend spec does that, and it covers one restricted role rather than a 
matrix of them.
   
   ## Breaking changes
   
   **Yes.** Previously, any authenticated user could reach certain screens 
directly by URL. Protected
   routes now require the corresponding permission, and a user without it is 
sent to `/forbidden`.
   
   Concretely:
   
   - Users whose roles lack a permission lose URL access to the matching 
screens. Deployments that
     relied — knowingly or not — on screens being reachable regardless of role 
will see access change
     for real users. Review role grants before upgrading.
   - Navigation entries that were visible to everyone are now filtered by 
permission, so menus will
     look smaller for non-superusers. This is the intended behaviour, not a 
regression.
   - `/transfers/account-transfer` moves from `READ_ACCOUNTTRANSFER` to 
`CREATE_ACCOUNTTRANSFER`. The
     screen is a form that posts a transfer; offering it to a user who can only 
read led to a refusal.
   - Deployments not ready for this can set `rbacEnabled: false` in 
`config.json` to restore the
     previous behaviour exactly, at all three layers.
   
   No API contract changed, no existing test was deleted, and no E2E 
infrastructure was broken. Three
   navigation specs asserting the old, looser behaviour were **updated to the 
new contract** rather
   than removed, and their intent — per-item gating, no leakage between 
siblings — is preserved and
   strengthened.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to