feat: document multi-tenant rbac and harden panel access (#455)
This commit is contained in:
parent
e0844f09f8
commit
02b3af62e3
30
docs/multi-tenant-rbac.md
Normal file
30
docs/multi-tenant-rbac.md
Normal 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. **审计与日志**:为关键管理操作增加审计记录,与租户信息绑定,确保满足企业级多租户合规要求。
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: '体验版本',
|
||||
|
||||
107
ui/homepage/lib/accessControl.ts
Normal file
107
ui/homepage/lib/accessControl.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user