feat(security): tenant-level nav menu access control panel
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:
@@ -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([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user