From fcabd60523cf45345d9d8914e1c73a2ba44646d8 Mon Sep 17 00:00:00 2001 From: ochenstarik Date: Fri, 11 Sep 2026 15:00:40 +0700 Subject: [PATCH] feat(rbac): unified session store, deny-by-default guards, and tenant isolation --- src/app/guards/RouteGuard.tsx | 29 ++- src/app/providers/TenantProvider.tsx | 12 +- src/app/shell/Sidebar.tsx | 14 +- src/core/session/sessionStore.ts | 234 ++++++++++++++++++ .../__tests__/rbacSessionIntegrity.test.tsx | 127 ++++++++++ src/mock/store.ts | 68 +++-- src/pages/error/ForbiddenPage.tsx | 6 +- src/stores/demo.ts | 35 +-- 8 files changed, 440 insertions(+), 85 deletions(-) create mode 100644 src/core/session/sessionStore.ts create mode 100644 src/features/__tests__/rbacSessionIntegrity.test.tsx diff --git a/src/app/guards/RouteGuard.tsx b/src/app/guards/RouteGuard.tsx index c1f50c4..a785ee8 100644 --- a/src/app/guards/RouteGuard.tsx +++ b/src/app/guards/RouteGuard.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useSessionStore } from '../../core/session/sessionStore'; import { useDemoStore } from '../../stores/demo'; import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types'; import { BUSINESS_PROFILES } from '../../core/profiles/profiles'; @@ -19,7 +20,8 @@ export const RouteGuard: React.FC = ({ requiredCapability, children, }) => { - const { currentRoleId, currentProfileId, stateOverride } = useDemoStore(); + const { currentRoleId, currentProfileId, capabilities, hasModulePermission } = useSessionStore(); + const { stateOverride } = useDemoStore(); // 1. Check Demo State Override if (stateOverride === 'permission_denied') { @@ -34,29 +36,26 @@ export const RouteGuard: React.FC = ({ throw new Error(`[Демо-ошибка]: Имитация сбоя при загрузке раздела «${moduleName || module}».`); } - // 2. Check RBAC Role permissions + // 2. Strict deny-by-default role permission check const role = SYSTEM_ROLES[currentRoleId as SystemRoleId]; - if (role) { - const isSuperRole = role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*'); - if (!isSuperRole && !role.allowedModules.includes(module)) { - return ( - - ); - } + if (!role || !hasModulePermission(module)) { + return ( + + ); } // 3. Check Business Profile Capabilities const profile = BUSINESS_PROFILES[currentProfileId]; - if (profile && requiredCapability) { - const isCapabilityEnabled = profile.capabilities[requiredCapability]; + if (requiredCapability) { + const isCapabilityEnabled = capabilities ? capabilities[requiredCapability] : profile?.capabilities[requiredCapability]; if (isCapabilityEnabled === false) { return ( ); } diff --git a/src/app/providers/TenantProvider.tsx b/src/app/providers/TenantProvider.tsx index 2d9dc2f..c8088b7 100644 --- a/src/app/providers/TenantProvider.tsx +++ b/src/app/providers/TenantProvider.tsx @@ -10,6 +10,7 @@ import { PRESET_TENANTS, TenantContextValue, } from './tenantContext'; +import { useSessionStore } from '../../core/session/sessionStore'; export interface TenantProviderProps { children: React.ReactNode; @@ -20,15 +21,24 @@ export const TenantProvider: React.FC = ({ children, initialTenantId, }) => { + const sessionTenantId = useSessionStore((s) => s.activeTenantId); const [currentTenantId, setCurrentTenantId] = useState(() => { - return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID; + return initialTenantId || sessionTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID; }); const switchTenant = useCallback((tenantId: string) => { setCurrentTenantId(tenantId); setActiveGlobalTenantId(tenantId); + useSessionStore.getState().setActiveTenantId(tenantId); }, []); + useEffect(() => { + if (sessionTenantId && sessionTenantId !== currentTenantId) { + setCurrentTenantId(sessionTenantId); + setActiveGlobalTenantId(sessionTenantId); + } + }, [sessionTenantId, currentTenantId]); + useEffect(() => { setActiveGlobalTenantId(currentTenantId); }, [currentTenantId]); diff --git a/src/app/shell/Sidebar.tsx b/src/app/shell/Sidebar.tsx index 8560748..0312524 100644 --- a/src/app/shell/Sidebar.tsx +++ b/src/app/shell/Sidebar.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { NavLink } from 'react-router-dom'; -import { useDemoStore } from '../../mock/store'; +import { useSessionStore } from '../../core/session/sessionStore'; import { useMetadata } from '../../services/hooks/useMetadata'; import { SYSTEM_MODULES } from '../../core/modules/registry'; import { SYSTEM_ROLES } from '../../core/permissions/types'; @@ -55,19 +55,13 @@ export interface SidebarProps { export const Sidebar: React.FC = ({ isTabletCompact = false }) => { const [userCollapsed, setUserCollapsed] = useState(null); const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact; - const { currentRoleId } = useDemoStore(); + const { currentRoleId, hasModulePermission } = useSessionStore(); const { entities } = useMetadata(); const customEntities = entities.filter((e) => !e.isSystem); const role = SYSTEM_ROLES[currentRoleId]; - // Filter modules by role permissions (industry profile does not hide modules) - const visibleModules = SYSTEM_MODULES.filter((m) => { - // Role check - if (!role.allowedModules.includes('*') && !role.allowedModules.includes(m.id)) { - return false; - } - return true; - }); + // Filter modules by role permissions (strict deny-by-default via sessionStore) + const visibleModules = SYSTEM_MODULES.filter((m) => hasModulePermission(m.id)); return (