feat(auth,ui): hardened Agnet auth, admin workspace and role whitelist

Backend (controller/heicode_agnet_session.go):
- Add HEICODE_ROOT_EMAILS / HEICODE_ADMIN_EMAILS whitelists for JIT role
  assignment. Manager no longer trusts Agnet's role claim — admin / root
  is granted only by local config.
- Default JIT-synced users to RoleCommonUser.
- Promote-only role sync on every login (never demote).

Frontend auth fixes:
- login() no longer hard-codes id=1; preserves the real manager user id
  returned by /api/user/session/from-agnet so the New-Api-User header
  matches the cookie session.
- After login, prefer local /api/user/self over Agnet /me so role /
  status reflect actual manager state (e.g. whitelist promotion).
- lib/api.ts: scope 401 -> "Session expired" handling to identity
  endpoints only; admin-only 401 no longer resets the session.

UI restructuring:
- Default sidebar shows only Code delivery + Console + Personal, plus a
  single "System settings" entry for ROLE.ADMIN+.
- system-settings workspace now hosts the full Tenant administration
  tree (Channels / Models / Subscriptions / Redemption codes / Tenants /
  Templates / Agents / Vendors / All usage logs) for ROLE.ADMIN+, with
  System Administration sub-tree gated to ROLE.SUPER_ADMIN.
- Workspace switch triggers on admin paths (channels, users, templates,
  agents, subscriptions, models, redemption-codes) — not only
  /system-settings.
- system-settings route now allows ROLE.ADMIN+ instead of root-only.

Branding cleanup:
- Drop orphan "NewAPI" i18n keys from web/default locales.
- Rename web/default workspace package newapi-web -> heicode-web.

Config:
- docker-compose.azure-vm.yml exposes HEICODE_ROOT_EMAILS /
  HEICODE_ADMIN_EMAILS.

VERSION: 1.1.0-default-user-role
Made-with: Cursor
This commit is contained in:
Ubuntu
2026-04-30 21:03:08 +00:00
parent aae8dd329a
commit a668e1fca1
22 changed files with 289 additions and 74 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.2-agnet-jit
1.1.0-default-user-role
+41 -9
View File
@@ -69,15 +69,41 @@ func jitUsernameFromEmail(email string) string {
return "ag_" + hex.EncodeToString(sum[:])[:16]
}
func roleFromAgnet(role string) int {
switch strings.ToLower(strings.TrimSpace(role)) {
case "root":
return common.RoleRootUser
case "admin":
return common.RoleAdminUser
default:
return common.RoleCommonUser
// parseEmailList returns a set of normalized lowercase emails from an env value.
func parseEmailList(raw string) map[string]struct{} {
out := map[string]struct{}{}
for _, e := range strings.Split(raw, ",") {
e = strings.ToLower(strings.TrimSpace(e))
if e != "" {
out[e] = struct{}{}
}
}
return out
}
// roleFromAgnetWithEmail decides the local role for a JIT-synced Agnet user.
//
// 安全策略:管理员权限只能通过本地配置(环境变量白名单)显式授予,
// **不信任** Agnet 平台返回的 role 字段。这样防止外部身份平台
// 的角色被直接映射到 Manager 的高权限角色。
//
// - 邮箱命中 HEICODE_ROOT_EMAILS -> RoleRootUser
// - 邮箱命中 HEICODE_ADMIN_EMAILS -> RoleAdminUser
// - 其他任何情况 -> RoleCommonUser(默认普通用户)
//
// 第二参数 `role` 当前未使用,保留是为了未来扩展(例如在策略中允许
// 信任部分上游 role),不破坏调用点签名。
func roleFromAgnetWithEmail(_ string, email string) int {
emailKey := strings.ToLower(strings.TrimSpace(email))
rootEmails := parseEmailList(os.Getenv("HEICODE_ROOT_EMAILS"))
if _, ok := rootEmails[emailKey]; ok {
return common.RoleRootUser
}
adminEmails := parseEmailList(os.Getenv("HEICODE_ADMIN_EMAILS"))
if _, ok := adminEmails[emailKey]; ok {
return common.RoleAdminUser
}
return common.RoleCommonUser
}
func statusFromAgnet(status string) int {
@@ -170,7 +196,7 @@ func syncLocalUserFromAgnet(me agnetMeEnvelope) (*model.User, error) {
Password: common.GetRandomString(32),
DisplayName: display,
Email: email,
Role: roleFromAgnet(me.Data.Role),
Role: roleFromAgnetWithEmail(me.Data.Role, email),
Status: statusFromAgnet(me.Data.Status),
Group: group,
}
@@ -204,6 +230,12 @@ func syncLocalUserFromAgnet(me agnetMeEnvelope) (*model.User, error) {
user.Group = ch
changed = true
}
// Promote role from Agnet / email whitelist on every login (never demote).
desiredRole := roleFromAgnetWithEmail(me.Data.Role, email)
if desiredRole > user.Role {
user.Role = desiredRole
changed = true
}
if changed {
if err := user.Update(false); err != nil {
return nil, err
+3
View File
@@ -44,6 +44,9 @@ services:
- NODE_NAME=heicode-node-1
# 默认与 docs/integration/Heicode-登录接口对接文档.md §2.1 一致;覆盖仅用于非标准网关。
- HEICODE_AUTH_BASE_URL=${HEICODE_AUTH_BASE_URL:-https://apimtaiji.azure-api.net/api/mcp}
# Agnet 登录后 JIT 同步:邮箱命中以下白名单则自动提权
- HEICODE_ROOT_EMAILS=${HEICODE_ROOT_EMAILS:-}
- HEICODE_ADMIN_EMAILS=${HEICODE_ADMIN_EMAILS:-}
networks:
- heicode-network
healthcheck:
+1 -1
View File
@@ -3,7 +3,7 @@
"configVersion": 0,
"workspaces": {
"": {
"name": "newapi-web",
"name": "heicode-web",
"dependencies": {
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.2.2",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "newapi-web",
"name": "heicode-web",
"private": false,
"version": "1.0.0",
"type": "module",
@@ -38,13 +38,18 @@ export function AppSidebar() {
const configFilteredNavGroups = useSidebarConfig(allNavGroups)
// Filter navigation groups based on user role
// Non-Admin users cannot see Admin navigation group
// - admin-entry group: visible only to ROLE.ADMIN+
// (single entry point that switches into the system-settings workspace)
// - admin group (legacy): visible only to ROLE.ADMIN+
// - system group (legacy): visible only to ROLE.SUPER_ADMIN (root)
const currentNavGroups = useMemo(() => {
const isAdmin = userRole && userRole >= ROLE.ADMIN
const isRoot = userRole && userRole >= ROLE.SUPER_ADMIN
return configFilteredNavGroups.filter((group) => {
if (group.id === 'admin') {
return isAdmin
}
if (group.id === 'admin-entry') return Boolean(isAdmin)
if (group.id === 'admin') return Boolean(isAdmin)
if (group.id === 'system') return Boolean(isRoot)
if (group.id === 'system-administration') return Boolean(isRoot)
return true
})
}, [configFilteredNavGroups, userRole])
@@ -1,11 +1,21 @@
import { type TFunction } from 'i18next'
import {
Box,
Boxes,
Building2,
CircuitBoard,
ClipboardList,
Cpu,
Layout,
ListChecks,
Plug,
Receipt,
Settings,
Shield,
ShieldAlert,
Layout,
Plug,
Box,
Store,
TerminalSquare,
Ticket,
Wrench,
} from 'lucide-react'
import { getAuthSectionNavItems } from '@/features/system-settings/auth/section-registry.tsx'
@@ -18,13 +28,78 @@ import { getRequestLimitsSectionNavItems } from '@/features/system-settings/requ
import { type NavGroup } from '../types'
/**
* System settings sidebar configuration
* Displayed when switching to "System Settings" workspace
* System Settings workspace 侧边栏(管理员入口后才显示)。
*
* 包含两大块:
* 1. Tenant administration (ROLE.ADMIN+ 业务管理类入口)
* 2. System Administration (ROLE.SUPER_ADMIN 平台层级配置)
*
* Workspace 进入条件:URL 命中 `/system-settings/...`。
* 角色过滤由 `app-sidebar.tsx` 按 group.id 完成。
*/
export const WORKSPACE_SYSTEM_SETTINGS_ID = 'system-settings'
export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
return [
// ============ Tenant administration (ROLE.ADMIN+) ============
{
id: 'admin',
title: t('Tenant administration'),
items: [
{
title: t('Channels'),
url: '/channels',
icon: CircuitBoard,
},
{
title: t('Models'),
url: '/models',
icon: Boxes,
},
{
title: t('Model deployments'),
url: '/deployments',
icon: Cpu,
},
{
title: t('Subscription plans'),
url: '/subscriptions',
icon: Receipt,
},
{
title: t('Redemption codes'),
url: '/redemption-codes',
icon: Ticket,
},
{
title: t('Tenants'),
url: '/users',
icon: Building2,
},
{
title: t('Templates'),
url: '/templates',
icon: ListChecks,
},
{
title: t('Agents'),
url: '/agents',
icon: TerminalSquare,
},
{
title: t('Vendors'),
url: '/system-settings/integrations',
icon: Store,
},
{
title: t('All usage logs'),
url: '/usage-logs',
icon: ClipboardList,
},
],
},
// ============ System Administration (ROLE.SUPER_ADMIN) ============
{
id: 'system-administration',
title: t('System Administration'),
@@ -55,7 +130,7 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
items: getIntegrationsSectionNavItems(t),
},
{
title: t('Models'),
title: t('Models catalog'),
icon: Box,
items: getModelsSectionNavItems(t),
},
@@ -43,10 +43,13 @@ export type WorkspaceConfig = {
*/
const workspaceRegistry: WorkspaceConfig[] = [
// System Settings workspace
// 触发条件:访问 /system-settings/* 或任意 admin 管理类路径(频道/模型/兑换码/
// 租户/模板/Agents/订阅管理/Models 管理 等)。
{
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
name: 'System Settings',
pathPattern: /^\/system-settings/,
pathPattern:
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models)(\/|$)/,
getNavGroups: getSystemSettingsNavGroups,
},
// Default workspace (must be last)
+8 -3
View File
@@ -53,7 +53,9 @@ export function clearHeicodeTokens() {
* 外部 Agnet 登录成功后,用 token 向本站校验身份并写入 Manager 会话 Cookie。
* 不在本站再做密码校验;本地用户按需 JIT 创建。
*/
async function establishManagerSessionFromAgnet() {
async function establishManagerSessionFromAgnet(): Promise<{
managerUserId?: number
}> {
const access_token = readToken(ACCESS_TOKEN_KEY)
const refresh_token = readToken(REFRESH_TOKEN_KEY)
if (!access_token) {
@@ -95,6 +97,7 @@ async function establishManagerSessionFromAgnet() {
if (body.data?.id != null) {
saveUserId(body.data.id)
}
return { managerUserId: body.data?.id }
}
async function callHeicodeAuth<T>(
@@ -164,10 +167,12 @@ export async function login(payload: LoginPayload) {
role: 'user',
}),
})
let managerUserId: number | undefined
if (res?.success) {
writeTokens(res.data?.token, res.data?.refreshToken)
try {
await establishManagerSessionFromAgnet()
const sessionRes = await establishManagerSessionFromAgnet()
managerUserId = sessionRes.managerUserId
} catch (syncErr) {
if (isTwoFactorRequiredError(syncErr)) {
throw syncErr
@@ -182,7 +187,7 @@ export async function login(payload: LoginPayload) {
success: Boolean(res?.success),
message: res?.message || res?.detail || '',
data: {
id: 1,
id: managerUserId,
user: res?.data?.user,
},
}
@@ -2,6 +2,7 @@ import { useNavigate } from '@tanstack/react-router'
import i18n from 'i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getHeicodeCurrentUser } from '@/features/auth/api'
import { getSelf } from '@/lib/api'
import { markHeicodeAuthenticatedSessionVerified } from '@/features/auth/heicode-authenticated-session'
import { saveUserId } from '../lib/storage'
@@ -75,24 +76,52 @@ export function useAuthRedirect() {
saveUserId(userData.id)
}
// Fetch and set user data from external auth only
// 优先从本地 Manager 拉真实用户(含 role / status / quota),
// 因为 Agnet 上的 role 不一定与本地 JIT/管理员白名单同步后的角色一致。
let userSet = false
try {
const heicodeUser = await getHeicodeCurrentUser()
if (heicodeUser) {
auth.setUser(heicodeUser)
saveUserId(heicodeUser.id)
const savedLang = (heicodeUser as Record<string, unknown>).language as
| string
| undefined
if (savedLang && savedLang !== i18n.language) {
i18n.changeLanguage(savedLang)
}
} else {
throw new Error('External auth session invalid')
const selfRes = (await getSelf()) as {
success?: boolean
data?: {
id?: number
username?: string
display_name?: string
email?: string
role?: number
status?: number
group?: string
} | null
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch user data:', error)
if (selfRes?.success && selfRes.data) {
auth.setUser(selfRes.data as never)
if (selfRes.data.id != null) {
saveUserId(selfRes.data.id)
}
userSet = true
}
} catch {
// Fall through to Agnet /me / fallback below.
}
if (!userSet) {
try {
const heicodeUser = await getHeicodeCurrentUser()
if (heicodeUser) {
auth.setUser(heicodeUser)
saveUserId(heicodeUser.id)
const savedLang = (heicodeUser as Record<string, unknown>)
.language as string | undefined
if (savedLang && savedLang !== i18n.language) {
i18n.changeLanguage(savedLang)
}
userSet = true
}
} catch {
/* fall through */
}
}
if (!userSet) {
// Use login response as temporary session profile so route guards can pass.
auth.setUser(buildFallbackUser(userData))
}
+74 -19
View File
@@ -1,30 +1,35 @@
import {
Activity,
BookOpenText,
Building2,
Command,
FileBarChart,
KeyRound,
LayoutDashboard,
PlaySquare,
Rocket,
Settings,
ShieldCheck,
UserCog,
Wallet,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { WORKSPACE_IDS } from '@/components/layout/lib/workspace-registry'
import { type SidebarData } from '@/components/layout/types'
/**
* Heicode Manager sidebar IA — code delivery command axis.
* Heicode Manager 默认 workspace 侧边栏。
*
* Main axis (always visible to authenticated users):
* Overview / Deployments / Events / SK Snapshots / Audit
* 设计原则:
* - 默认进来界面干净,仅展示 Code delivery cockpit + 个人/控制台入口;
* - 所有 admin/root 管理类入口(Channels/Models/Tenants/Redemption/...)
* 收纳进 "系统设置" workspace(点击进入 /system-settings 后才出现)。
*
* Admin axis (RoleAdminUser+):
* Tenants / Settings
* Visibility rules (filter happens in `app-sidebar.tsx` by `group.id`):
* - everyone (no id filter): cockpit / console / personal
* - id === 'admin-entry' -> ROLE.ADMIN+ (单一入口:系统设置)
*
* Gateway-flavored entries (Provider Channels, Model Catalog,
* Subscriptions, API Keys, Playground, Redemption Codes...) are
* intentionally NOT on the main axis. They live as sub-pages under
* Settings or are deprecated outright.
* 注意:进入 /system-settings 之后侧边栏由
* `components/layout/config/system-settings.config.ts` 接管。
*/
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
@@ -39,6 +44,7 @@ export function useSidebarData(): SidebarData {
},
],
navGroups: [
// ============ Code delivery cockpit ============
{
id: 'cockpit',
title: t('Code delivery'),
@@ -66,24 +72,73 @@ export function useSidebarData(): SidebarData {
{
title: t('Audit'),
url: '/audit',
activeUrls: ['/usage-logs', '/usage-logs/common'],
icon: ShieldCheck,
},
],
},
// ============ Console (登录用户均可见) ============
{
id: 'admin',
title: t('Tenant administration'),
id: 'console',
title: t('Console'),
items: [
{
title: t('Tenants'),
url: '/users',
icon: Building2,
title: t('Playground'),
url: '/playground',
icon: PlaySquare,
},
{
title: t('Settings'),
url: '/system-settings/general',
activeUrls: ['/system-settings'],
title: t('API Keys'),
url: '/keys',
icon: KeyRound,
},
{
title: t('Usage logs'),
url: '/usage-logs',
icon: FileBarChart,
},
],
},
// ============ Personal ============
{
id: 'personal',
title: t('Personal'),
items: [
{
title: t('Wallet'),
url: '/wallet',
icon: Wallet,
},
{
title: t('Profile'),
url: '/profile',
icon: UserCog,
},
],
},
// ============ Admin entry (仅 ROLE.ADMIN+ 可见) ============
// 单一入口:跳转到管理 workspace。admin 进入 /channels(管理首页),
// root 进入 /system-settings/general。两种角色进入后侧边栏均切换为
// system-settings workspace(由 workspace-registry 路径匹配触发)。
{
id: 'admin-entry',
title: t('Administration'),
items: [
{
title: t('System settings'),
url: '/channels',
activeUrls: [
'/system-settings',
'/channels',
'/users',
'/redemption-codes',
'/templates',
'/agents',
'/subscriptions',
'/models',
],
icon: Settings,
},
],
@@ -56,7 +56,6 @@
"Moonshot": "Moonshot",
"my-status": "my-status",
"name@example.com": "name@example.com",
"NewAPI": "NewAPI",
"noreply@example.com": "noreply@example.com",
"OhMyGPT": "OhMyGPT",
"Ollama": "Ollama",
@@ -59,7 +59,6 @@
"MokaAI": "MokaAI",
"Moonshot": "Moonshot",
"name@example.com": "name@example.com",
"NewAPI": "NewAPI",
"noreply@example.com": "noreply@example.com",
"OAuth Client Secret": "OAuth Client Secret",
"OhMyGPT": "OhMyGPT",
@@ -65,7 +65,6 @@
"name@example.com": "name@example.com",
"New API": "New API",
"New API &lt;noreply@example.com&gt;": "New API &lt;noreply@example.com&gt;",
"NewAPI": "NewAPI",
"noreply@example.com": "noreply@example.com",
"OhMyGPT": "OhMyGPT",
"Ollama": "Ollama",
-1
View File
@@ -2074,7 +2074,6 @@
"New password must be different from current password": "New password must be different from current password",
"New User Quota": "New User Quota",
"New version available: {{version}}": "New version available: {{version}}",
"NewAPI": "NewAPI",
"Next": "Next",
"Next branch": "Next branch",
"Next page": "Next page",
-1
View File
@@ -2074,7 +2074,6 @@
"New password must be different from current password": "Le nouveau mot de passe doit être différent de l'actuel",
"New User Quota": "Quota nouvel utilisateur",
"New version available: {{version}}": "Nouvelle version disponible : {{version}}",
"NewAPI": "NewAPI",
"Next": "Suivant",
"Next branch": "Branche suivante",
"Next page": "Page suivante",
-1
View File
@@ -2074,7 +2074,6 @@
"New password must be different from current password": "新しいパスワードは現在のものと異なっていなければなりません",
"New User Quota": "新しいユーザー割り当て",
"New version available: {{version}}": "新しいバージョンが利用可能です:{{version}}",
"NewAPI": "NewAPI",
"Next": "次へ",
"Next branch": "次のブランチ",
"Next page": "次のページ",
-1
View File
@@ -2074,7 +2074,6 @@
"New password must be different from current password": "Новый пароль должен отличаться от текущего пароля",
"New User Quota": "Новая квота пользователя",
"New version available: {{version}}": "Доступна новая версия: {{version}}",
"NewAPI": "NewAPI",
"Next": "Следующий шаг",
"Next branch": "Следующая ветка",
"Next page": "Следующая страница",
-1
View File
@@ -2074,7 +2074,6 @@
"New password must be different from current password": "Mật khẩu mới phải khác với mật khẩu hiện tại",
"New User Quota": "Hạn mức người dùng mới",
"New version available: {{version}}": "Có phiên bản mới: {{version}}",
"NewAPI": "NewAPI",
"Next": "Tiếp",
"Next branch": "Nhánh tiếp theo",
"Next page": "Trang tiếp theo",
-1
View File
@@ -2074,7 +2074,6 @@
"New password must be different from current password": "新密码必须与当前密码不同",
"New User Quota": "新用户配额",
"New version available: {{version}}": "有新版本可用:{{version}}",
"NewAPI": "NewAPI",
"Next": "下一步",
"Next branch": "下一个分支",
"Next page": "下一页",
+17 -3
View File
@@ -76,17 +76,31 @@ api.interceptors.response.use(
const skip = error?.config?.skipErrorHandler
if (!skip) {
const status = error?.response?.status
const url = String(error?.config?.url || '')
if (status === 401) {
// Unauthorized: clear auth state and show toast
// Only treat 401 on identity/self endpoints as a real session expiry.
// Admin-only endpoints (/api/agnet, /api/channel, etc.) returning 401 for
// non-admin users should NOT reset the session.
const isIdentityEndpoint =
url.includes('/api/user/self') ||
url.includes('/api/user/logout') ||
url.endsWith('/api/user/login')
if (status === 401 && isIdentityEndpoint) {
toast.error(i18next.t('Session expired!'))
try {
useAuthStore.getState().auth.reset()
} catch {
/* empty */
}
} else if (status === 401) {
// Insufficient permission for this specific request — surface the
// backend message (if any) but do not kill the session.
const msg =
error?.response?.data?.message ||
i18next.t('Insufficient permission')
toast.error(msg)
} else {
// Other errors: show error message from response or default
const msg =
error?.response?.data?.message || error?.message || 'Request error'
toast.error(msg)
@@ -7,7 +7,10 @@ export const Route = createFileRoute('/_authenticated/system-settings')({
beforeLoad: () => {
const { auth } = useAuthStore.getState()
if (auth.user?.role !== ROLE.SUPER_ADMIN) {
// 允许 ADMIN+ 进入系统设置 workspace(其中具体子页面会再做权限校验:
// Tenant administration 子页面只要 ADMIN 即可,
// System Administration 子页面要求 SUPER_ADMIN)。
if (!auth.user || auth.user.role < ROLE.ADMIN) {
throw redirect({
to: '/403',
})