feat(security): tenant-level nav menu access control panel
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Adds the frontend half of the per-tenant menu-access ceiling
(TenantMenuAccessApiResource on the backend): a bank cannot show a top-nav
section the platform operator has capped, no matter what any role inside
that tenant grants itself — closing the gap where a role carrying
ALL_FUNCTIONS (as "Clerk" was found to, in two tenants this session)
bypassed every ordinary MENU_.../SIDEBAR_... permission check.

- tenant-menu-access.service.ts: loads the current tenant's list once,
  into a plain Set (not an Observable the directive would have to
  subscribe to, since mifosxHasPermission's setter runs synchronously).
  A failed load is treated as "nothing disabled" so a network hiccup on
  login never hides the whole nav.
- has-permission.directive.ts: checks the tenant's ceiling first, ahead of
  the ALL_FUNCTIONS bypass — that ordering is the actual fix.
- shell.component.ts/html: the nav now waits on the list being loaded
  before rendering at all, so a disabled menu is never shown and then
  hidden a moment later.
- New standalone platform-admin/menu-access screen (#/platform-admin/
  menu-access), reachable without logging in — its own X-Api-Key is the
  real boundary, checked on the backend, not this route. Lets the
  operator pick any tenant and toggle its menu list. Redesigned per
  request: full-width layout, icon-matched toggle tiles (same icon per
  item as the real nav), live enabled-count, connection status.
- Loan Origination and Investment now use their own MENU_LOAN_ORIGINATION
  / MENU_INVESTMENT codes (see backend 0542) instead of sharing
  MENU_ADVANCES / MENU_RBI, so all 27 nav items are independently
  controllable from this screen.

Also: src/assets/env.js — restored 'socity_bank' to
fineractPlatformTenantIds. The tenant now exists in the tenant-store
database; the comment explaining its earlier removal was stale.
This commit is contained in:
2026-09-11 15:13:29 +05:30
parent 7afa9c7a50
commit 42424dff8b
10 changed files with 882 additions and 6 deletions
@@ -11,6 +11,7 @@ import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angula
/** Custom Services */
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { TenantMenuAccessService } from './tenant-menu-access.service';
/** Environment */
import { environment } from '../../../environments/environment';
@@ -23,6 +24,7 @@ export class HasPermissionDirective {
private templateRef = inject<TemplateRef<any>>(TemplateRef);
private viewContainer = inject(ViewContainerRef);
private authenticationService = inject(AuthenticationService);
private tenantMenuAccessService = inject(TenantMenuAccessService);
/** User Permissions */
private userPermissions: any[];
@@ -75,6 +77,13 @@ export class HasPermissionDirective {
// Menu & Sidebar permissions are ALWAYS enforced (hide/show nav items based on role)
const isMenuPermission = permission.startsWith('MENU_') || permission.startsWith('SIDEBAR_');
// The tenant's own ceiling on which menu sections it may ever show — checked before anything
// else below, ALL_FUNCTIONS included, because ALL_FUNCTIONS bypassing this is exactly the gap
// this exists to close (see TenantMenuAccessApiResource). Non-menu permissions never touch this.
if (isMenuPermission && !this.tenantMenuAccessService.isMenuAllowed(permission)) {
return false;
}
// For non-menu permissions: if RBAC is disabled, show everything (backward compatibility)
if (!this.rbacEnabled && !isMenuPermission) {
return true;
@@ -0,0 +1,61 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, of, tap } from 'rxjs';
/**
* The current tenant's own cap on which top-nav menu codes may ever show — see
* `TenantMenuAccessApiResource` on the backend for why this exists separately from a user's own
* permission list: a role carrying `ALL_FUNCTIONS` bypasses every ordinary permission check, so
* `HasPermissionDirective` needs a second, tenant-level list that check cannot see past.
*
* Loaded once per session, into a plain `Set` rather than an `Observable` the directive would have
* to subscribe to — `mifosxHasPermission`'s setter runs synchronously and once per binding, so
* `ShellComponent` loads this before the nav renders (see `ready`) and every directive instance
* afterwards just reads the already-populated set.
*/
@Injectable({ providedIn: 'root' })
export class TenantMenuAccessService {
private http = inject(HttpClient);
/** Menu codes this tenant has explicitly turned off. Absence from this set means "shown" —
* a tenant that never configured anything (every tenant, until an operator narrows one) has
* nothing here, which is what keeps every existing deployment behaving exactly as it does today. */
private disabledCodes = new Set<string>();
/** True once the initial load has settled, success or failure. `ShellComponent` waits on this
* before rendering the nav, so a menu is never shown-then-hidden a moment later. */
private loaded = false;
isReady(): boolean {
return this.loaded;
}
isMenuAllowed(menuCode: string): boolean {
return !this.disabledCodes.has(menuCode);
}
/** Fetches this tenant's list once. A failed request is treated as "nothing disabled" — the same
* as a tenant that never configured this at all — so a transient network hiccup on login never
* makes the whole nav disappear. */
load() {
return this.http.get<{ menuCode: string; enabled: boolean }[]>('/tenant-menu-access').pipe(
tap((rows) => {
this.disabledCodes = new Set((rows || []).filter((r) => r.enabled === false).map((r) => r.menuCode));
this.loaded = true;
}),
catchError(() => {
this.disabledCodes = new Set();
this.loaded = true;
return of([]);
})
);
}
}