feat(auth): 实现平台超管与多租户角色权限深度隔离、前端动态路由白名单及DeepSeek配额守卫
这个提交包含在:
@@ -105,7 +105,11 @@ function PageLayout() {
|
||||
(state: GlobalState) => state
|
||||
);
|
||||
|
||||
const [routes, defaultRoute] = useRoute(userInfo?.permissions);
|
||||
const [routes, defaultRoute] = useRoute(userInfo?.permissions, {
|
||||
isSuper: userInfo?.isSuper,
|
||||
role: userInfo?.role,
|
||||
tenantId: userInfo?.tenantId,
|
||||
});
|
||||
const defaultSelectedKeys = [currentComponent || defaultRoute];
|
||||
const paths = (currentComponent || defaultRoute).split('/');
|
||||
const defaultOpenKeys = paths.slice(0, paths.length - 1);
|
||||
|
||||
@@ -27,6 +27,9 @@ interface CoolPerson {
|
||||
username?: string;
|
||||
email?: string;
|
||||
headImg?: string;
|
||||
isSuper?: boolean;
|
||||
role?: string;
|
||||
tenantId?: string | number;
|
||||
}
|
||||
|
||||
interface CoolResponse<T> {
|
||||
@@ -59,6 +62,8 @@ function Index() {
|
||||
.get<CoolResponse<CoolPerson>>('/api/admin/base/comm/person')
|
||||
.then((res) => {
|
||||
const person = res.data?.code === COOL_OK ? res.data.data : undefined;
|
||||
const isSuper = Boolean(person?.isSuper);
|
||||
const role = person?.role || (isSuper ? 'super_admin' : 'tenant_owner');
|
||||
store.dispatch({
|
||||
type: 'update-userInfo',
|
||||
payload: {
|
||||
@@ -67,7 +72,10 @@ function Index() {
|
||||
name: person?.name || person?.nickName || person?.username || '',
|
||||
email: person?.email || '',
|
||||
avatar: person?.headImg || '',
|
||||
permissions: generatePermission('admin'),
|
||||
isSuper,
|
||||
role,
|
||||
tenantId: person?.tenantId,
|
||||
permissions: generatePermission(role),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -78,7 +86,9 @@ function Index() {
|
||||
payload: {
|
||||
userLoading: false,
|
||||
userInfo: {
|
||||
permissions: generatePermission('admin'),
|
||||
isSuper: false,
|
||||
role: 'sales_rep',
|
||||
permissions: generatePermission('sales_rep'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,8 +9,20 @@ export type IRoute = AuthParams & {
|
||||
children?: IRoute[];
|
||||
// 当前路由是否渲染菜单项,为 true 的话不会在菜单中显示,但可通过路由地址访问。
|
||||
ignore?: boolean;
|
||||
// 是否仅平台超级管理员可见
|
||||
superAdminOnly?: boolean;
|
||||
// 是否仅租户可见
|
||||
tenantOnly?: boolean;
|
||||
// 角色白名单访问限制
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
export interface RoleContext {
|
||||
isSuper?: boolean;
|
||||
role?: string;
|
||||
tenantId?: string | number;
|
||||
}
|
||||
|
||||
export const routes: IRoute[] = [
|
||||
{
|
||||
name: 'menu.dashboard',
|
||||
@@ -27,6 +39,7 @@ export const routes: IRoute[] = [
|
||||
{
|
||||
name: 'menu.dashboard.financial',
|
||||
key: 'dashboard/financial',
|
||||
superAdminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -73,22 +86,39 @@ export const routes: IRoute[] = [
|
||||
key: 'team',
|
||||
children: [
|
||||
{ name: 'menu.team.members', key: 'team/members' },
|
||||
{ name: 'menu.team.brandKit', key: 'team/brand-kit' },
|
||||
{ name: 'menu.team.handover', key: 'team/handover' },
|
||||
{
|
||||
name: 'menu.team.brandKit',
|
||||
key: 'team/brand-kit',
|
||||
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||
},
|
||||
{
|
||||
name: 'menu.team.handover',
|
||||
key: 'team/handover',
|
||||
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'menu.settingsBiz',
|
||||
key: 'settings-biz',
|
||||
children: [
|
||||
{ name: 'menu.settings.tenant', key: 'settings/tenant' },
|
||||
{ name: 'menu.settings.billing', key: 'settings/billing' },
|
||||
{
|
||||
name: 'menu.settings.tenant',
|
||||
key: 'settings/tenant',
|
||||
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||
},
|
||||
{
|
||||
name: 'menu.settings.billing',
|
||||
key: 'settings/billing',
|
||||
roles: ['super_admin', 'tenant_owner', 'admin'],
|
||||
},
|
||||
{ name: 'menu.settings.auditLogs', key: 'settings/audit-logs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'menu.platform',
|
||||
key: 'platform',
|
||||
superAdminOnly: true,
|
||||
children: [
|
||||
{ name: 'menu.platform.subscribers', key: 'platform/subscribers' },
|
||||
{ name: 'menu.platform.renewals', key: 'platform/renewals' },
|
||||
@@ -127,8 +157,57 @@ export const getName = (path: string, routes) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const filterRoutesByRole = (
|
||||
routesList: IRoute[],
|
||||
roleCtx?: RoleContext
|
||||
): IRoute[] => {
|
||||
if (!routesList || !routesList.length) {
|
||||
return [];
|
||||
}
|
||||
const isSuper = Boolean(roleCtx?.isSuper || roleCtx?.role === 'super_admin');
|
||||
const role = roleCtx?.role || (isSuper ? 'super_admin' : 'sales_rep');
|
||||
|
||||
const result: IRoute[] = [];
|
||||
|
||||
for (const route of routesList) {
|
||||
// 1. 平台超级管理员专属路由隔离
|
||||
if (route.superAdminOnly && !isSuper) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 租户专属路由隔离
|
||||
if (route.tenantOnly && isSuper) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 角色白名单访问限制
|
||||
if (route.roles && route.roles.length > 0) {
|
||||
const isAllowed = isSuper || route.roles.includes(role);
|
||||
if (!isAllowed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 递归处理子路由
|
||||
if (route.children && route.children.length > 0) {
|
||||
const filteredChildren = filterRoutesByRole(route.children, roleCtx);
|
||||
if (filteredChildren.length === 0) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
...route,
|
||||
children: filteredChildren,
|
||||
});
|
||||
} else {
|
||||
result.push({ ...route });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const generatePermission = (role: string) => {
|
||||
const actions = role === 'admin' ? ['*'] : ['read'];
|
||||
const actions = role === 'admin' || role === 'super_admin' ? ['*'] : ['read'];
|
||||
const result = {};
|
||||
routes.forEach((item) => {
|
||||
if (item.children) {
|
||||
@@ -140,7 +219,10 @@ export const generatePermission = (role: string) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const useRoute = (userPermission): [IRoute[], string] => {
|
||||
const useRoute = (
|
||||
userPermission?: Record<string, string[]>,
|
||||
roleCtx?: RoleContext
|
||||
): [IRoute[], string] => {
|
||||
const filterRoute = (routes: IRoute[], arr = []): IRoute[] => {
|
||||
if (!routes.length) {
|
||||
return [];
|
||||
@@ -148,7 +230,7 @@ const useRoute = (userPermission): [IRoute[], string] => {
|
||||
for (const route of routes) {
|
||||
const { requiredPermissions, oneOfPerm } = route;
|
||||
let visible = true;
|
||||
if (requiredPermissions) {
|
||||
if (requiredPermissions && userPermission) {
|
||||
visible = auth({ requiredPermissions, oneOfPerm }, userPermission);
|
||||
}
|
||||
|
||||
@@ -172,9 +254,10 @@ const useRoute = (userPermission): [IRoute[], string] => {
|
||||
const [permissionRoute, setPermissionRoute] = useState(routes);
|
||||
|
||||
useEffect(() => {
|
||||
const newRoutes = filterRoute(routes);
|
||||
const roleFiltered = filterRoutesByRole(routes, roleCtx);
|
||||
const newRoutes = filterRoute(roleFiltered);
|
||||
setPermissionRoute(newRoutes);
|
||||
}, [JSON.stringify(userPermission)]);
|
||||
}, [JSON.stringify(userPermission), JSON.stringify(roleCtx)]);
|
||||
|
||||
const defaultRoute = useMemo(() => {
|
||||
const first = permissionRoute[0];
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface GlobalState {
|
||||
organization?: string;
|
||||
location?: string;
|
||||
email?: string;
|
||||
isSuper?: boolean;
|
||||
role?: string;
|
||||
tenantId?: string | number;
|
||||
permissions: Record<string, string[]>;
|
||||
};
|
||||
userLoading?: boolean;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { routes, filterRoutesByRole } from '../src/routes';
|
||||
|
||||
test('1. 平台超级管理员 (Super Admin) 应拥有包含平台总控与全局财务大屏的完整菜单', () => {
|
||||
const adminRoutes = filterRoutesByRole(routes, {
|
||||
isSuper: true,
|
||||
role: 'super_admin',
|
||||
});
|
||||
|
||||
const keys = adminRoutes.map((r) => r.key);
|
||||
assert.ok(keys.includes('platform'), '超管必须拥有 platform 平台总控菜单');
|
||||
assert.ok(keys.includes('dashboard'), '超管必须拥有 dashboard 菜单');
|
||||
|
||||
const dashboardRoute = adminRoutes.find((r) => r.key === 'dashboard');
|
||||
const dashChildrenKeys = dashboardRoute?.children?.map((c) => c.key) || [];
|
||||
assert.ok(
|
||||
dashChildrenKeys.includes('dashboard/financial'),
|
||||
'超管必须拥有 dashboard/financial 全局财务大屏'
|
||||
);
|
||||
});
|
||||
|
||||
test('2. 租户企业管理员 (Tenant Owner) 必须完全物理剔除【平台总控】与【全局财务大屏】', () => {
|
||||
const tenantRoutes = filterRoutesByRole(routes, {
|
||||
isSuper: false,
|
||||
role: 'tenant_owner',
|
||||
});
|
||||
|
||||
const keys = tenantRoutes.map((r) => r.key);
|
||||
assert.ok(!keys.includes('platform'), '普通租户侧边栏绝对不得出现 platform 平台总控');
|
||||
|
||||
const dashboardRoute = tenantRoutes.find((r) => r.key === 'dashboard');
|
||||
const dashChildrenKeys = dashboardRoute?.children?.map((c) => c.key) || [];
|
||||
assert.ok(
|
||||
!dashChildrenKeys.includes('dashboard/financial'),
|
||||
'普通租户绝不得访问或看到 dashboard/financial 全局财务大屏'
|
||||
);
|
||||
|
||||
// 但租户必须能看到自己的名片、线索、自身订阅账单
|
||||
assert.ok(keys.includes('card'), '租户必须有名片微官网菜单');
|
||||
assert.ok(keys.includes('crm'), '租户必须有 CRM 菜单');
|
||||
assert.ok(keys.includes('settings-biz'), '租户必须有 企业设置 菜单');
|
||||
|
||||
const settingsRoute = tenantRoutes.find((r) => r.key === 'settings-biz');
|
||||
const settingsKeys = settingsRoute?.children?.map((c) => c.key) || [];
|
||||
assert.ok(
|
||||
settingsKeys.includes('settings/billing'),
|
||||
'租户必须能访问自身的订阅账单 settings/billing'
|
||||
);
|
||||
});
|
||||
|
||||
test('3. 租户一线销售员 (Sales Rep) 必须进一步限制企业高层设置与敏感人事交接', () => {
|
||||
const repRoutes = filterRoutesByRole(routes, {
|
||||
isSuper: false,
|
||||
role: 'sales_rep',
|
||||
});
|
||||
|
||||
const keys = repRoutes.map((r) => r.key);
|
||||
assert.ok(!keys.includes('platform'), '销售员不得看到 platform');
|
||||
|
||||
const teamRoute = repRoutes.find((r) => r.key === 'team');
|
||||
const teamKeys = teamRoute?.children?.map((c) => c.key) || [];
|
||||
assert.ok(!teamKeys.includes('team/handover'), '销售员不得看到离职交接');
|
||||
assert.ok(!teamKeys.includes('team/brand-kit'), '销售员不得篡改企业统一品牌规范');
|
||||
|
||||
const settingsRoute = repRoutes.find((r) => r.key === 'settings-biz');
|
||||
const settingsKeys = settingsRoute?.children?.map((c) => c.key) || [];
|
||||
assert.ok(!settingsKeys.includes('settings/tenant'), '销售员不得修改企业主域名与证书');
|
||||
assert.ok(!settingsKeys.includes('settings/billing'), '销售员不得查看企业订阅账单');
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户