feat: document multi-tenant rbac and harden panel access (#455)

This commit is contained in:
shenlan 2025-10-07 16:44:02 +08:00 committed by GitHub
parent e0844f09f8
commit 02b3af62e3
10 changed files with 306 additions and 20 deletions

30
docs/multi-tenant-rbac.md Normal file
View File

@ -0,0 +1,30 @@
# 多租户适配与权限角色规划检查报告
## 概览
本次检查聚焦于前后端在多租户环境下的适配度与角色权限控制实现。后端账户服务的数据模型已包含 `role`、`groups`、`permissions` 字段,可支撑细粒度的访问控制,并且前端会话 API 现已补充 `tenantId``tenants` 元数据便于后续按照租户维度做隔离控制。【F:account/sql/schema.sql†L27-L67】【F:ui/homepage/app/api/auth/session/route.ts†L12-L116】
前端 `userStore` 会解析并缓存上述字段,同时归一化多租户信息,为 React 组件提供统一上下文;新增的 `accessControl` 工具封装了访问信息判定逻辑使页面与组件能够以声明式的方式定义访问规则。面向用户的首页、Docs 与下载中心保持公开访问,而 `/panel` 下页面默认要求登录,`/panel/management` 进一步限制为管理员与操作员角色访问。【F:ui/homepage/lib/userStore.tsx†L1-L161】【F:ui/homepage/lib/accessControl.ts†L1-L99】【F:ui/homepage/app/page.tsx†L1-L28】【F:ui/homepage/app/panel/layout.tsx†L1-L115】
此外,轻量级 IDP 组件已经具备 `TENANT` 配置项可与前端新增的租户字段对接实现多租户身份源隔离。【F:light-idp/idp-server/internal/config/config.go†L1-L36】
## 角色权限规划
| 角色 | 访问范围 | 备注 |
| ---- | -------- | ---- |
| 超级管理员admin | 拥有全部模块访问权限,包括管理面板、权限矩阵编辑、用户角色调整等操作。 | 导航栏会额外展示“Management Console”入口。 |
| 操作员operator | 与管理员共享管理面板视图,但仅具备运营授权范围内的只读/执行权限。 | 可访问 `/panel/management`,但无法执行管理员专属操作。 |
| 普通用户user | 需登录后方可进入 `/panel`,默认仅开放 `/panel/account` 个人中心与 MFA 设置。 | |
| 访客guest | 无需登录即可浏览首页、Docs、下载中心以及 Ask AI 入口。 | Ask AI 组件默认允许访客使用,但支持后续通过权限规则关闭。 |
### 控制点落地
- **路由守卫**`PanelLayout` 在客户端根据路由前缀与访问规则执行跳转,未登录用户统一重定向到 `/login`,无权限用户重定向回 `/panel`。(`/panel/account` 在登录后始终可达【F:ui/homepage/app/panel/layout.tsx†L21-L104】
- **组件能力**`Navbar` 会根据用户角色动态显示“Management Console”菜单项`AskAIButton` 使用 `useAccess` Hook 实现可配置的权限判定并保持默认公开访问策略。【F:ui/homepage/components/Navbar.tsx†L1-L228】【F:ui/homepage/components/AskAIButton.tsx†L1-L41】
- **管理页面**`/panel/management` 在页面层面复用统一的 `resolveAccess` 判定能够对未登录与越权场景给出不同提示同时只对管理员角色开放权限矩阵写入操作。【F:ui/homepage/app/panel/management/page.tsx†L1-L235】
## 后续建议
1. **租户切换能力**:后端会话响应已带上 `tenants` 列表,可以在前端补充租户选择器,并将租户上下文透传给 API 请求,实现真正的多租户隔离。
2. **权限矩阵配置化**:结合 `accessControl`,可以将模块到角色/权限的映射抽取到配置文件,便于在不改代码的情况下调整授权策略。
3. **审计与日志**:为关键管理操作增加审计记录,与租户信息绑定,确保满足企业级多租户合规要求。

View File

@ -25,6 +25,12 @@ type AccountUser = {
role?: string
groups?: string[]
permissions?: string[]
tenantId?: string
tenants?: Array<{
id?: string
name?: string
role?: string
}>
}
type SessionResponse = {
@ -95,6 +101,41 @@ export async function GET(request: NextRequest) {
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedTenantId =
typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
? rawUser.tenantId.trim()
: undefined
const normalizedTenants = Array.isArray(rawUser.tenants)
? rawUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const label =
typeof tenant.name === 'string' && tenant.name.trim().length > 0
? tenant.name.trim()
: undefined
const roleValue =
typeof tenant.role === 'string' && tenant.role.trim().length > 0
? tenant.role.trim().toLowerCase()
: undefined
return {
id: identifier,
name: label,
role: roleValue,
}
})
.filter((tenant): tenant is { id: string; name?: string; role?: string } => Boolean(tenant))
: undefined
const normalizedMfa = Object.keys(rawMfa).length
? {
@ -118,6 +159,8 @@ export async function GET(request: NextRequest) {
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},
})
}

View File

@ -107,16 +107,17 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
}))
if (user?.isAdmin || user?.isOperator) {
const userCenterSection = sections.find((section) => section.title === '用户中心')
if (userCenterSection) {
userCenterSection.items.push({
href: '/panel/management',
label: 'Management Console',
description: '零信任策略与运维控制',
icon: Settings,
})
}
sections.push({
title: '管理页面',
items: [
{
href: '/panel/management',
label: 'Management',
description: '集中化的权限矩阵与用户编排',
icon: Settings,
},
],
})
}
return sections

View File

@ -8,8 +8,36 @@ import Header from './components/Header'
import Sidebar from './components/Sidebar'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess, type AccessRule } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
type RouteGuard = {
test: (pathname: string) => boolean
redirect: {
unauthenticated: string
forbidden?: string
}
rule: AccessRule
}
const routeGuards: RouteGuard[] = [
{
test: (pathname) => pathname.startsWith('/panel/management'),
redirect: {
unauthenticated: '/login',
forbidden: '/panel',
},
rule: { requireLogin: true, roles: ['admin', 'operator'] },
},
{
test: (pathname) => pathname.startsWith('/panel'),
redirect: {
unauthenticated: '/login',
},
rule: { requireLogin: true },
},
]
export default function PanelLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const router = useRouter()
@ -17,24 +45,29 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const { user, isLoading, logout } = useUser()
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const isManagementRoute = pathname.startsWith('/panel/management')
useEffect(() => {
if (isLoading) {
return
}
if (!user) {
router.replace('/login')
const guard = routeGuards.find((entry) => entry.test(pathname))
if (!guard) {
return
}
if (isManagementRoute && !(user.isAdmin || user.isOperator)) {
router.replace('/panel')
const decision = resolveAccess(user, guard.rule)
if (!decision.allowed) {
const destination =
decision.reason === 'unauthenticated'
? guard.redirect.unauthenticated
: guard.redirect.forbidden ?? guard.redirect.unauthenticated
if (destination && destination !== pathname) {
router.replace(destination)
}
}
}, [isLoading, isManagementRoute, router, user])
}, [isLoading, pathname, router, user])
useEffect(() => {
if (!requiresSetup || pathname.startsWith('/panel/account')) {

View File

@ -10,6 +10,7 @@ import PermissionMatrixEditor, {
} from './components/PermissionMatrixEditor'
import UserGroupManagement, { type ManagedUser } from './components/UserGroupManagement'
import Card from '../components/Card'
import { resolveAccess } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
type UserMetricsResponse = {
@ -57,7 +58,8 @@ async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T
export default function ManagementPage() {
const { user, isLoading: isUserLoading } = useUser()
const canAccess = Boolean(user?.isAdmin || user?.isOperator)
const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
const canAccess = accessDecision.allowed
const canEditPermissions = Boolean(user?.isAdmin)
const canEditRoles = Boolean(user?.isAdmin)
@ -227,7 +229,11 @@ export default function ManagementPage() {
<Card>
<div className="flex flex-col items-start gap-3 text-sm text-gray-700">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p></p>
<p>
{accessDecision.reason === 'unauthenticated'
? '请先登录后再访问该页面。'
: '该页面仅向管理员与运营角色开放。如果你认为这是一个错误,请联系管理员。'}
</p>
</div>
</Card>
)

View File

@ -3,12 +3,18 @@
import { useState } from 'react'
import { Bot } from 'lucide-react'
import { AskAIDialog } from './AskAIDialog'
import { useAccess } from '@lib/accessControl'
import { getServerServiceBaseUrl } from '@lib/serviceConfig'
export function AskAIButton() {
const [open, setOpen] = useState(false)
const [minimized, setMinimized] = useState(false)
const apiBase = getServerServiceBaseUrl()
const { allowed, isLoading } = useAccess({ allowGuests: true })
if (!allowed && !isLoading) {
return null
}
return (
<>

View File

@ -106,6 +106,16 @@ export default function Navbar() {
href: '/panel',
togglePath: '/panel',
},
...(user?.isAdmin || user?.isOperator
? [
{
key: 'management',
label: accountCopy.management,
href: '/panel/management',
togglePath: '/panel/management',
} satisfies NavSubItem,
]
: []),
{
key: 'logout',
label: accountCopy.logout,

View File

@ -349,6 +349,7 @@ export type Translation = {
welcome: string
logout: string
userCenter: string
management: string
}
releaseChannels: ReleaseChannelLabels
}
@ -413,6 +414,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
welcome: 'Welcome, {username}',
logout: 'Sign out',
userCenter: 'User Center',
management: 'Management Console',
},
releaseChannels: {
label: 'Preview',
@ -804,6 +806,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
welcome: '欢迎,{username}',
logout: '退出登录',
userCenter: '用户中心',
management: '管理控制台',
},
releaseChannels: {
label: '体验版本',

View File

@ -0,0 +1,107 @@
import { useMemo } from 'react'
import { useUser } from './userStore'
import type { SessionUser, TenantMembership, UserRole } from './userStore'
type AccessReason = 'unauthenticated' | 'forbidden'
export type AccessDecision = {
allowed: boolean
reason?: AccessReason
userRole: UserRole
userTenants?: TenantMembership[]
tenantId?: string
}
export type AccessRule = {
requireLogin?: boolean
allowGuests?: boolean
roles?: UserRole[]
permissions?: string[]
}
const EVERYONE_ROLES: UserRole[] = ['guest', 'user', 'operator', 'admin']
function normalizeRoles(roles?: UserRole[]): UserRole[] | undefined {
if (!roles || roles.length === 0) {
return undefined
}
const known = new Set<UserRole>()
for (const role of roles) {
if (EVERYONE_ROLES.includes(role)) {
known.add(role)
}
}
return known.size ? Array.from(known) : undefined
}
function normalizePermissions(permissions?: string[]): string[] | undefined {
if (!permissions || permissions.length === 0) {
return undefined
}
const known = new Set<string>()
for (const permission of permissions) {
const trimmed = permission.trim()
if (trimmed.length > 0) {
known.add(trimmed)
}
}
return known.size ? Array.from(known) : undefined
}
export function resolveAccess(user: SessionUser, rule?: AccessRule): AccessDecision {
const normalizedRule = rule ?? {}
const normalizedRoles = normalizeRoles(normalizedRule.roles)
const normalizedPermissions = normalizePermissions(normalizedRule.permissions)
const role: UserRole = user?.role ?? 'guest'
const isAuthenticated = Boolean(user)
const allowGuests =
normalizedRule.allowGuests ?? (!normalizedRoles || normalizedRoles.includes('guest'))
const requiresLogin =
normalizedRule.requireLogin ??
(!allowGuests ||
Boolean(normalizedPermissions && normalizedPermissions.length > 0) ||
Boolean(normalizedRoles && !normalizedRoles.includes('guest')))
if (!isAuthenticated && requiresLogin) {
if (allowGuests) {
// Guests explicitly allowed to pass through.
} else {
return { allowed: false, reason: 'unauthenticated', userRole: role }
}
}
if (normalizedRoles && !normalizedRoles.includes(role)) {
if (!isAuthenticated && allowGuests) {
return { allowed: false, reason: 'unauthenticated', userRole: role }
}
return { allowed: false, reason: isAuthenticated ? 'forbidden' : 'unauthenticated', userRole: role }
}
if (normalizedPermissions && normalizedPermissions.length > 0) {
const userPermissions = new Set(user?.permissions ?? [])
const missing = normalizedPermissions.some((permission) => !userPermissions.has(permission))
if (missing) {
return { allowed: false, reason: isAuthenticated ? 'forbidden' : 'unauthenticated', userRole: role }
}
}
return {
allowed: true,
userRole: role,
userTenants: user?.tenants,
tenantId: user?.tenantId,
}
}
export function useAccess(rule?: AccessRule) {
const { user, isLoading } = useUser()
const decision = useMemo(() => resolveAccess(user, rule), [user, rule])
return {
...decision,
isLoading,
}
}

View File

@ -12,6 +12,12 @@ import { create } from 'zustand'
export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
export type TenantMembership = {
id: string
name?: string
role?: UserRole
}
type User = {
id: string
uuid: string
@ -27,6 +33,8 @@ type User = {
isUser: boolean
isOperator: boolean
isAdmin: boolean
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
@ -36,6 +44,8 @@ type User = {
}
}
export type SessionUser = User | null
type UserContextValue = {
user: User | null
isLoading: boolean
@ -106,6 +116,8 @@ async function fetchSessionUser(): Promise<User | null> {
role?: string
groups?: string[]
permissions?: string[]
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
@ -158,6 +170,39 @@ async function fetchSessionUser(): Promise<User | null> {
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedTenantId =
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
? sessionUser.tenantId.trim()
: undefined
const normalizedTenants = Array.isArray(sessionUser.tenants)
? sessionUser.tenants
.map((tenant) => {
if (!tenant || typeof tenant !== 'object') {
return null
}
const identifier =
typeof tenant.id === 'string' && tenant.id.trim().length > 0
? tenant.id.trim()
: undefined
if (!identifier) {
return null
}
const label =
typeof tenant.name === 'string' && tenant.name.trim().length > 0
? tenant.name.trim()
: undefined
const roleValue =
typeof tenant.role === 'string' && tenant.role.trim().length > 0
? normalizeRole(tenant.role)
: undefined
return {
id: identifier,
name: label,
role: roleValue,
}
})
.filter((tenant): tenant is TenantMembership => Boolean(tenant))
: undefined
return {
id: identifier,
@ -175,6 +220,8 @@ async function fetchSessionUser(): Promise<User | null> {
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
tenantId: normalizedTenantId,
tenants: normalizedTenants,
}
} catch (error) {
console.warn('Failed to resolve user session', error)