Repository navigation
fix(rbac): withhold cross-domain links a reader cannot follow - #662
Conversation
Neither structural directive can decide this. `*appHasPermission` removes an element and `appRequiresPermission` disables it; neither has an else-branch, and a cross-domain link needs one. The right treatment there is to render the record's name as plain text rather than as a link to a screen the user cannot open — the record's existence is not the secret, and dropping the row would misrepresent the data. Shaped as a factory returning a signal, called as a field initializer, matching `createPickersReady`. Mirrors HasPermissionDirective including the rbacEnabled short-circuit, so a deployment that has not adopted RBAC keeps every link. Presentation, not enforcement: Fineract refuses the read either way.
Four screens list records from one domain and link to another, where the destination carries a read code the listing does not require: - a client's accounts come back with READ_CLIENT, but each account screen wants its own code - a group's members come back with READ_GROUP, the client screen wants READ_CLIENT - a center's groups come back with READ_CENTER, the group screen wants READ_GROUP - the asset owner screen is ungated, the loan screen wants READ_LOAN Confirmed against a real instance for the first: a seeded READ_CLIENT user opened the Loan Accounts tab, clicked the account number and landed on `/forbidden`; the same click as a user holding READ_LOAN opens the loan. The three list screens keep naming the record and withhold only the link. The asset owner screen's control is a header button with no record name to preserve, so it is removed with `*appHasPermission` — the convention for a control that navigates elsewhere. Replaces the private helper client-view carried with the shared `createPermissionCheck`, rather than making a fourth copy of it.
`seedGroup` created a group with no members, so the member list could not be exercised. It now takes client ids and passes them as `clientMembers` at creation, which the group screen reads from its `associations=all` fetch. Both tests open the Members tab explicitly. The list is not the default tab, and without the click the member is absent for a reason that has nothing to do with permissions — which is how the first draft of this passed its negative assertion for the wrong reason.
E2E — mocked backend🎭 E2E Tests✅ All green — 409 passed · 0 failed · 1 skipped, across 38 spec files in 6m 26s. By spec file
All 410 tests — click to expand
Slowest 10 — what the shard counts should be tuned against
📼 Download the HTML report, videos and traces — see the Generated by run 37004741340 from |
E2E — real Fineract🎭 E2E Tests✅ All green — 121 passed · 0 failed · 0 skipped · 1 flaky, across 36 spec files in 7m 29s.
|
| Spec | ✅ | ❌ | ⏭️ | 🔒 | Time |
|---|---|---|---|---|---|
| backend.setup.ts | 3 | 0 | 0 | 0 | 5.7s |
| accounting-closure-backend.spec.ts | 2 | 0 | 0 | 0 | 13.1s |
| batch-api-operations.spec.ts | 5 | 0 | 0 | 0 | 30.3s |
| center-servicing.spec.ts | 2 | 0 | 0 | 0 | 17.8s |
| client-legal-form-search.spec.ts | 1 | 0 | 0 | 0 | 4.6s |
| client-transfer.spec.ts | 4 | 0 | 0 | 0 | 46.0s |
| deposit-account-servicing.spec.ts | 3 | 0 | 0 | 0 | 25.9s |
| deposit-product-configuration.spec.ts | 3 | 0 | 0 | 0 | 16.1s |
| form-layout-reflow.spec.ts | 6 | 0 | 0 | 0 | 30.6s |
| group-membership.spec.ts | 3 | 0 | 0 | 0 | 48.8s |
| loan-account-actions.spec.ts | 3 | 0 | 0 | 0 | 24.3s |
| loan-approved-amount-revision.spec.ts | 3 | 0 | 0 | 0 | 20.1s |
| loan-charge-off.spec.ts | 2 | 0 | 0 | 0 | 21.1s |
| loan-product-accounting.spec.ts | 1 | 0 | 0 | 0 | 32.0s |
| loan-servicing.spec.ts | 2 | 0 | 0 | 0 | 19.3s |
| login.spec.ts | 4 | 0 | 0 | 0 | 5.7s |
| oidc-login-backend.spec.ts | 2 | 0 | 0 | 0 | 5.4s |
| parity-screens.spec.ts | 8 | 0 | 0 | 0 | 44.4s |
| rbac-backend-restricted-user.spec.ts | 7 | 0 | 0 | 0 | 35.1s |
| rbac-dead-end-controls.spec.ts | 7 | 0 | 0 | 0 | 34.4s |
| rbac-disabled-flag-backend.spec.ts | 6 | 0 | 0 | 0 | 25.3s |
| rbac-multi-permission.spec.ts | 9 | 0 | 0 | 0 | 52.7s |
| bulk-import-entity-types.spec.ts | 1 | 0 | 0 | 0 | 9.0s |
| delinquency-configuration.spec.ts | 2 | 0 | 0 | 0 | 16.4s |
| full-demo.spec.ts | 1 | 0 | 0 | 0 | 53.9s |
| interest-rate-charts.spec.ts | 1 | 0 | 0 | 0 | 7.1s |
| loan-lifecycle.spec.ts | 4 | 0 | 0 | 0 | 1m 47s |
| loan-schedule-type.spec.ts | 3 | 0 | 0 | 0 | 29.9s |
| rbac-office-scoped-user.spec.ts | 6 | 0 | 0 | 0 | 22.8s |
| rbac-permissionless-user.spec.ts | 4 | 0 | 0 | 0 | 18.4s |
| report-mailing-job.spec.ts | 3 | 0 | 0 | 0 | 15.7s |
| report-parameter-backend.spec.ts | 4 | 0 | 0 | 0 | 19.0s |
| savings-transaction-correction.spec.ts | 1 | 0 | 0 | 0 | 5.7s |
| share-account-servicing.spec.ts | 2 | 0 | 0 | 0 | 16.3s |
| share-product-accounting.spec.ts | 1 | 0 | 0 | 0 | 21.5s |
| teller-cash-management.spec.ts | 2 | 0 | 0 | 0 | 32.1s |
All 121 tests — click to expand
backend.setup.ts
- ✅ seed backend reference data —
1.9s - ✅ seed backend reference data —
2.1s - ✅ seed backend reference data —
1.7s
accounting-closure-backend.spec.ts
- ✅ Accounting closures against Fineract › a closed period reads as Closed, and the platform refuses a posting inside it —
6.8s - ✅ Accounting closures against Fineract › the form keeps the user on the page and names the reason when Fineract refuses —
6.3s
batch-api-operations.spec.ts
- ✅ Batch API Operations against Fineract › runs the sample batch scenario — create client, create loan, add and read back a charge —
8.7s(retried 1×) - ✅ Batch API Operations against Fineract › shows a parse error instead of submitting when the batch input is not valid JSON —
6.3s - ✅ Batch API enclosingTransaction semantics against Fineract › rolls back the earlier steps when enclosingTransaction is true and a later step fails —
5.3s - ✅ Batch API enclosingTransaction semantics against Fineract › does not roll back the earlier steps when enclosingTransaction is false and a later step fails —
5.2s - ✅ Batch API Operations on a mobile viewport against Fineract › the sample batch scenario is reachable and works by touch at mobile width —
4.8s
center-servicing.spec.ts
- ✅ Center servicing › a center is activated, staffed and given a group —
12.0s - ✅ Center servicing › notes are recorded against the center —
5.8s
client-legal-form-search.spec.ts
- ✅ Client list: legal form filtering › an entity client appears in the main client list —
4.6s
client-transfer.spec.ts
- ✅ Client transfer between offices › a proposed transfer is held until the destination accepts, and then the client moves —
11.6s - ✅ Client transfer between offices › a rejected transfer leaves the client on hold, and withdrawing is the way back —
12.5s - ✅ Client transfer between offices › a client can be transferred in one step when the user may act for both offices —
10.5s - ✅ Client staff assignment › an officer can be assigned and then removed —
11.4s
deposit-account-servicing.spec.ts
- ✅ Term deposit account servicing › an account is approved, activated and closed before maturity —
11.0s - ✅ Term deposit account servicing › a deposit is recorded, listed, and reversed without leaving the list —
9.0s - ✅ Term deposit account servicing › an application can be rejected instead of approved —
5.9s
deposit-product-configuration.spec.ts
- ✅ Deposit product configuration › a fixed deposit product survives being edited —
5.2s - ✅ Deposit product configuration › a recurring deposit product can be created at all —
5.3s - ✅ Deposit product configuration › a savings product carries its accounting configuration —
5.6s
form-layout-reflow.spec.ts
- ✅ form layout at the narrow viewport › Create GL Account gives every field a usable width at 320px —
4.3s - ✅ form layout at the narrow viewport › Create Loan gives every field a usable width at 320px —
4.5s - ✅ form layout at the narrow viewport › Create Office gives every field a usable width at 320px —
4.9s - ✅ form layout at the narrow viewport › Create Staff gives every field a usable width at 320px —
4.0s - ✅ form layout at the narrow viewport › no page-level horizontal scroll at 320px —
8.7s - ✅ form layout at the wide viewport › Create GL Account still uses more than one column at 1280px —
4.0s
group-membership.spec.ts
- ✅ Group membership and lifecycle › a group is activated, staffed, given members and a committee, then emptied —
20.7s - ✅ Group membership and lifecycle › notes are recorded against the group and can be removed again —
8.9s - ✅ Group membership and lifecycle › an empty group is closed with a reason, and a group with members is refused —
19.2s
loan-account-actions.spec.ts
- ✅ Loan account lifecycle actions › new action menu items appear only for active loans —
7.5s - ✅ Loan account lifecycle actions › undo disbursal shows a confirm dialog and reverts the loan to Approved —
9.1s - ✅ Loan account lifecycle actions › write off requires confirmation and moves the loan out of Active status —
7.7s
loan-approved-amount-revision.spec.ts
- ✅ Loan approved and available-disbursement amount revision › revises the approved amount on an active, partially-disbursed loan —
7.4s - ✅ Loan approved and available-disbursement amount revision › revises the available disbursement amount, which also changes the approved amount —
6.2s - ✅ Loan approved and available-disbursement amount revision › shows the platform’s own refusal, not a generic validation message —
6.6s
loan-charge-off.spec.ts
- ✅ Loan servicing commands › charges a loan off through the UI and reverses it —
11.2s - ✅ Loan servicing commands › records a goodwill credit through the shared transaction form —
9.9s
loan-product-accounting.spec.ts
- ✅ Loan product accounting › a cash-accounting product is configured, round-trips on edit, and posts to the ledger —
32.0s
loan-servicing.spec.ts
- ✅ Loan servicing: notes and transaction adjustment › notes can be added and removed, with a confirm dialog on delete —
8.9s - ✅ Loan servicing: notes and transaction adjustment › a repayment transaction can be viewed and adjusted with a corrected amount —
10.4s
login.spec.ts
- ✅ Login › login page displays correctly —
1.4s - ✅ Login › login form has required fields —
1.7s - ✅ Login › submit button is disabled when form is empty —
1.3s - ✅ Login › submit button is enabled when form is filled —
1.3s
oidc-login-backend.spec.ts
- ✅ identity-provider route against a real Fineract › is offered beside the password form, and pressing it sends nothing to Fineract —
1.4s - ✅ identity-provider route against a real Fineract › a real password sign-in still gets a session with the route offered —
3.9s
parity-screens.spec.ts
- ✅ Screens added for platform parity › a manual journal entry can be read whole and reversed —
6.2s - ✅ Screens added for platform parity › an entry that is already reversed is not offered again —
5.1s - ✅ Screens added for platform parity › a report definition can be created, edited and deleted; a core one cannot —
5.2s - ✅ Screens added for platform parity › a core report opens read-only with only its in-use setting —
4.8s - ✅ Screens added for platform parity › a pending loan is approved from the queue, in a batch —
6.1s - ✅ Screens added for platform parity › a fixed deposit is listed as a deposit, not as a savings account —
5.5s - ✅ Screens added for platform parity › an office has a screen, and it carries its custom fields —
4.6s - ✅ Screens added for platform parity › a savings account carries notes, and the note survives a reload —
6.9s
rbac-backend-restricted-user.spec.ts
- ✅ a genuinely restricted Fineract user › holds exactly the permissions their role was granted —
210ms - ✅ a genuinely restricted Fineract user › reaches the screen their permission covers —
4.4s - ✅ a genuinely restricted Fineract user › is refused a screen their permission does not cover, by URL and by the backend —
4.9s - ✅ a genuinely restricted Fineract user › is refused a write screen they can read the list for, and the write itself —
6.5s - ✅ a genuinely restricted Fineract user › is not offered the actions it would be refused for —
4.3s - ✅ a genuinely restricted Fineract user › is shown an action it cannot take, disabled and saying what it needs —
5.7s - ✅ a genuinely restricted Fineract user › the superuser the rest of the suite uses is unaffected —
9.1s
rbac-dead-end-controls.spec.ts
- ✅ a client's account tables › withhold the link, but not the number, from a user who cannot open the account —
4.7s - ✅ a client's account tables › keep the link for a user who holds READ_LOAN —
5.8s - ✅ the office transactions list › withholds Create from a reader, and the route refuses it too —
5.3s - ✅ the office transactions list › offers Create to an administrator —
4.5s - ✅ the office transactions list › says the list was refused instead of showing an empty table —
4.3s - ✅ a group's member list › names the member without linking when the reader cannot open clients —
5.1s - ✅ a group's member list › keeps the link for a reader who holds READ_CLIENT —
4.7s
rbac-disabled-flag-backend.spec.ts
- ✅ rbacEnabled: false, with a user who holds only READ_CLIENT › is shown the whole navigation, including modules their role does not cover —
2.9s - ✅ rbacEnabled: false, with a user who holds only READ_CLIENT › is admitted by the route guard to a screen their role does not cover —
7.2s - ✅ rbacEnabled: false, with a user who holds only READ_CLIENT › is still refused by Fineract, which the flag does not reach —
621ms - ✅ rbacEnabled: false, with a user who holds only READ_CLIENT › is told the list is beyond their role, and not offered a retry that cannot succeed —
4.8s - ✅ rbacEnabled: false, with a user who holds only READ_CLIENT › gets the same treatment on a second refused list, not a one-screen fix —
5.2s - ✅ rbacEnabled: false, with a user who holds only READ_CLIENT › still loads the list their role does cover, with no failure state at all —
4.6s
rbac-multi-permission.spec.ts
- ✅ a route declaring more than one permission code (OR semantics) › is admitted by either declared code alone —
4.8s - ✅ a route declaring more than one permission code (OR semantics) › is admitted by the other declared code alone —
4.1s - ✅ a route declaring more than one permission code (OR semantics) › is refused when holding neither declared code, by the router and by the backend —
4.7s - ✅ ALL_FUNCTIONS_READ, against the real Fineract permission catalogue › reaches read screens across modules it holds no specific code for —
8.1s - ✅ ALL_FUNCTIONS_READ, against the real Fineract permission catalogue › is refused every write screen, and the writes themselves —
7.0s - ✅ a restricted session across a real page reload › keeps the same permission boundary after reloading, not just after a fresh login —
9.4s - ✅ a second real action-level gate, distinct from loan repayment › is shown the Approve action disabled and naming what it needs, refused by the backend too —
4.9s - ✅ Security module writes (users, roles), against the real backend › reaches the list screens but is refused the write screens —
9.5s - ✅ Security module writes (users, roles), against the real backend › is refused creating a user and modifying a role, by the backend itself —
322ms
bulk-import-entity-types.spec.ts
- ✅ every offered entity type is one the platform accepts —
9.0s
delinquency-configuration.spec.ts
- ✅ creates a delinquency range, then edits it —
8.5s - ✅ creates a delinquency bucket from the ranges the template offers —
7.9s
full-demo.spec.ts
- ✅ Full feature demo recording › walk through loan schedule type, lifecycle, custom fields, collateral, and disbursement —
53.9s
interest-rate-charts.spec.ts
- ✅ lists charts without a server error —
7.1s
loan-lifecycle.spec.ts
- ✅ Loan lifecycle: creation, approval, disbursement › create, approve, and disburse a Cumulative loan —
27.8s - ✅ Loan lifecycle: creation, approval, disbursement › create, approve, and disburse a Progressive loan —
29.0s - ✅ Loan lifecycle: creation, approval, disbursement › an approved loan can be returned to pending approval —
25.8s - ✅ Loan lifecycle: creation, approval, disbursement › the delinquency tab reads a real loan, and the empty data tabs stay hidden —
24.5s
loan-schedule-type.spec.ts
- ✅ Loan Schedule Type (Cumulative vs Progressive) › loan products list shows a schedule type chip per product —
7.9s - ✅ Loan Schedule Type (Cumulative vs Progressive) › create a Progressive loan product end-to-end and verify it round-trips —
12.8s - ✅ Loan Schedule Type (Cumulative vs Progressive) › loan creation shows the schedule type badge for a Progressive product —
9.2s
rbac-office-scoped-user.spec.ts
- ✅ an account scoped to a branch office › differs from a head-office account in office alone, not in permissions —
364ms - ✅ an account scoped to a branch office › is shown only its own office in the client list, and told which office it is in —
4.1s - ✅ an account scoped to a branch office › sees a shorter list than an identically-permissioned head-office account —
4.1s - ✅ an account scoped to a branch office › is refused another office’s client by the platform, and told so once —
4.5s - ✅ an account scoped to a branch office › can still open the record that is in its own office —
4.1s - ✅ an account scoped to a branch office › is not offered an edit it would be refused for on the Offices screen —
5.7s
rbac-permissionless-user.spec.ts
- ✅ an account whose role grants nothing › is authenticated by Fineract and authorized by it for nothing —
669ms - ✅ an account whose role grants nothing › is signed in and landed on a screen rather than stranded at the login form —
4.3s - ✅ an account whose role grants nothing › is offered no permission-gated module, and refused each one by URL and by Fineract —
8.9s - ✅ an account whose role grants nothing › is left a navigation whose entries actually work —
4.5s
report-mailing-job.spec.ts
- ✅ report mailing job creation › is no longer refused for a missing parameter —
4.7s - ✅ report mailing job creation › the payload carries the locale and the schedule —
4.9s - ✅ report mailing job creation at the narrow viewport › every field is reachable and the job still saves at 320px —
6.0s
report-parameter-backend.spec.ts
- ✅ Dynamic report parameters against Fineract › keeps the parameter form available when a report has cascading lookups —
3.7s - ✅ Dynamic report parameters against Fineract › changing Office changes the Client Listing row set —
5.6s - ✅ Cascading report parameters against Fineract › sends the parent value to the child lookup and clears the child when it changes —
6.3s - ✅ Chart reports against Fineract › renders a chart report as a chart rather than a table —
3.5s
savings-transaction-correction.spec.ts
- ✅ Savings transaction correction › a deposit is reversed and a hold is released —
5.7s
share-account-servicing.spec.ts
- ✅ Share account servicing › an account is approved, activated, traded and closed —
11.8s - ✅ Share account servicing › an application can be rejected —
4.4s
share-product-accounting.spec.ts
- ✅ Share product accounting › a share product is mapped to equity and round-trips on edit —
21.5s
teller-cash-management.spec.ts
- ✅ Teller cash management › a cashier is listed, receives an allocation, and settles cash back —
21.9s - ✅ Teller cash management › settling more than the cashier holds is refused and the form stays usable —
10.1s
Slowest 10 — what the shard counts should be tuned against
| Test | Spec | Time |
|---|---|---|
| Full feature demo recording › walk through loan schedule type, lifecycle, custom fields, collateral, and disbursement | full-demo.spec.ts |
53.9s |
| Loan product accounting › a cash-accounting product is configured, round-trips on edit, and posts to the ledger | loan-product-accounting.spec.ts |
32.0s |
| Loan lifecycle: creation, approval, disbursement › create, approve, and disburse a Progressive loan | loan-lifecycle.spec.ts |
29.0s |
| Loan lifecycle: creation, approval, disbursement › create, approve, and disburse a Cumulative loan | loan-lifecycle.spec.ts |
27.8s |
| Loan lifecycle: creation, approval, disbursement › an approved loan can be returned to pending approval | loan-lifecycle.spec.ts |
25.8s |
| Loan lifecycle: creation, approval, disbursement › the delinquency tab reads a real loan, and the empty data tabs stay hidden | loan-lifecycle.spec.ts |
24.5s |
| Teller cash management › a cashier is listed, receives an allocation, and settles cash back | teller-cash-management.spec.ts |
21.9s |
| Share product accounting › a share product is mapped to equity and round-trips on edit | share-product-accounting.spec.ts |
21.5s |
| Group membership and lifecycle › a group is activated, staffed, given members and a committee, then emptied | group-membership.spec.ts |
20.7s |
| Group membership and lifecycle › an empty group is closed with a reason, and a group with members is refused | group-membership.spec.ts |
19.2s |
📼 Download the HTML report, videos and traces — see the playwright-report-backend artifact.
Generated by run 37004741340 from d51a775. The run executed a fork branch, so treat its contents as unverified.
Closes #663. Part of #123.
Follows #660, which fixed the first two instances of this. A static audit of controls navigating to a permission-gated route turned up four more, all the same shape: a screen lists records from one domain and links to another, where the destination carries a read code the listing itself does not require.
READ_CLIENTREAD_GROUP/clients/view/:idREAD_CLIENTREAD_CENTER/groups/view/:idREAD_GROUP/loans/view/:idREAD_LOANConfirmed against a real instance for the first: a seeded
READ_CLIENTuser opened the Loan Accounts tab, clicked the account number, and landed on/forbidden. The same click as a user who also holdsREAD_LOANopens the loan.The three list screens keep the name, drop the link
The record's existence is not the secret — a group's member list is for showing membership — and hiding the row would misrepresent the data. So the name still renders as plain text; only the link is withheld.
The asset owner screen is different: its control is a header button with no record name to preserve, so it is removed outright with
*appHasPermission, which is the documented convention for a control that navigates elsewhere.Why a new helper rather than a directive
Neither structural directive can express this.
*appHasPermissionremoves an element,appRequiresPermissiondisables it, and neither has an else-branch — but the plain-text fallback is the whole point here.createPermissionCheck()is a factory returning a signal, called as a field initializer, matching the existingcreatePickersReady()pattern. It mirrorsHasPermissionDirectiveincluding therbacEnabledshort-circuit, so a deployment that has not adopted RBAC keeps every link. This also replaces the private helperclient-viewwas carrying from #660, rather than making a fourth copy of it.This is presentation, not enforcement. Fineract refuses the read either way and remains the authorization boundary — see
security.md.Coverage
Extends
e2e/rbac-dead-end-controls.spec.tswith the group member link, paired as always with a reader who does holdREAD_CLIENT, because "the link is absent" passes just as well against a screen that renders nothing.seedGroupnow takes client ids and passes them asclientMembersat creation, since it previously created groups with no members and the list could not be exercised at all.Both group tests open the Members tab explicitly. That is worth calling out: the first draft passed its negative assertion for the wrong reason — the member was absent because the tab was closed, not because the link was withheld. The paired positive test caught it by failing too.
The
asset-owner-viewunit test now covers both states; it previously asserted the loan link renders and would otherwise have broken silently in the gated direction.Not covered by e2e
The center → group link and the asset owner button are covered by the shared helper's unit tests and, for the asset owner, its own component test — but not by a backend spec. Seeding a center with an associated group is a longer path than the value justified here; both go through the same code path as the group case that is covered.
Follow-ups filed, not fixed here
READ_OFFICETRANSACTIONbut the endpoint also needsREAD_OFFICE, so a user holding exactly what the route asks for gets a screen that cannot load. Changing it changes who sees a nav entry, so it is a product call.includeAllOffices=true, so a branch user is shown the whole institution's office tree #666 — the Offices screen requestsincludeAllOffices=true, showing a branch user the whole institution's office tree. Both scoped and unscoped reads return 200, so this is the client's choice to make.