feat(system-admin): implement bootstrap for system admin promotion and enhance system settings management

- Added WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL environment variable to promote a specified user to system admin on startup.
- Introduced a new bootstrap process in `bootstrap.go` to handle the promotion logic.
- Updated `.env.example` to document the new environment variable and its behavior.
- Created new views for managing system administrators and system settings, including listing, promoting, and revoking admin privileges.
- Enhanced the frontend to reflect the new system admin features, including UI elements for admin management and settings configuration.
- Updated API interfaces to support system admin functionalities, ensuring proper data handling and user management.
This commit is contained in:
wizardchen
2026-05-24 21:38:05 +08:00
committed by lyingbug
parent e6ee87759d
commit 47a183aa65
43 changed files with 3322 additions and 146 deletions
+11
View File
@@ -430,6 +430,10 @@ COS_ENABLE_OLD_DOMAIN=true
# ========== 文件上传大小限制 ==========
# 统一的文件大小限制(MB),默认为50MB
# 影响:单文件上传、gRPC消息大小、Nginx请求体大小
#
# 优先级(自 P1 起):DB 中的 system_settings.file.max_size_mb > 此环境变量 > 默认 50
# SystemAdmin 可以在「系统管理 → 全局设置」面板里实时调整该值,无需重启服务。
# 此处的环境变量仍然作为回退(DB 没有 row 时使用)。
# MAX_FILE_SIZE_MB=50
# ========== Agent Skills Sandbox 配置 ==========
@@ -460,6 +464,13 @@ WEKNORA_SANDBOX_TIMEOUT=60
# 注意:开发环境用 Air 热重载时,修改本变量需重启 dev 脚本(仅源代码变更才重读 .env)。
# WEKNORA_TENANT_ENABLE_RBAC=true
# 启动时自动把指定 email 的用户提升为系统管理员(SystemAdmin)。
# - 该用户必须已经注册过;如果尚未注册,启动只会 warn 一行,下次重启会重试
# - 操作幂等:已是系统管理员的用户重复 bootstrap 是 no-op
# - 仅 PROMOTE,从不 DEMOTE:在 UI 上手动撤销后,下次重启不会被覆盖
# - 适用于 docker-compose / k8s 部署的初次自举;后续的提权/撤销请走 SystemAdmin 管理页
# WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL=
# 单个非超管用户可自助创建(成为 Owner)的租户数上限。
# 仅统计 Owner 角色——被邀请为 Admin/Editor/Viewer 的不计入。
# 拥有 CanAccessAllTenants 的超管不受此限制。
+96
View File
@@ -0,0 +1,96 @@
// Bootstrap-time hooks that run after the DI container is built but
// before the HTTP server starts listening. These are deliberately
// best-effort: any failure here only warns and does NOT abort startup.
// The reasoning is that an operator running with a misconfigured env
// var should still be able to bring the server up (and fix the issue
// from the running instance) rather than have a typo brick the deploy.
package main
import (
"context"
"os"
"strings"
"go.uber.org/dig"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// bootstrapEnvVar is the env var that names the email of the user who
// should be promoted to system administrator on every startup.
//
// Why an env var (vs a CLI subcommand)?
// - Zero-friction in docker-compose / k8s deploys: set it once in the
// manifest and the very first user account that signs up with that
// email is auto-promoted, with no extra ops step.
// - Idempotent: if the user is already a system admin, bootstrapping is
// a no-op (no DB write, no log noise beyond a debug line).
// - Safe to leave set: the operation only ever PROMOTES; it never
// demotes. So leaving the var in place across restarts won't undo
// manual revokes from the UI.
const bootstrapEnvVar = "WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL"
// runStartupBootstrap consults the env and applies any one-shot
// bootstrap actions. Currently it only handles system-admin promotion;
// future bootstrap steps (default model seeding, etc.) can be added
// here as additional dig.Invoke calls.
func runStartupBootstrap(c *dig.Container) {
ctx := context.Background()
email := strings.TrimSpace(os.Getenv(bootstrapEnvVar))
if email == "" {
return
}
// dig.Invoke resolves UserService from the container; if user
// service registration is broken we want to know loudly, but still
// not abort startup — bootstrap is best-effort.
if err := c.Invoke(func(userSvc interfaces.UserService) {
bootstrapSystemAdmin(ctx, userSvc, email)
}); err != nil {
logger.Warnf(ctx, "[bootstrap] failed to resolve UserService: %v", err)
}
}
// bootstrapSystemAdmin promotes the user identified by `email` to system
// administrator if they exist and are not already one. The function is
// idempotent and non-fatal — it warns and returns on every error path.
//
// The bootstrap intentionally does NOT create a user when the email is
// not yet registered: account creation is a workflow with side effects
// (password hashing, tenant assignment, audit) that we don't want to
// short-circuit. Operators should sign up normally first, then set the
// env var on the next restart.
func bootstrapSystemAdmin(ctx context.Context, userSvc interfaces.UserService, email string) {
user, err := userSvc.GetUserByEmail(ctx, email)
if err != nil {
// "not found" surfaces as an error in this codebase; treat it
// gently — operators commonly set the var before the user has
// signed up. The next restart after registration will succeed.
logger.Warnf(ctx,
"[bootstrap] %s=%s: user lookup failed (have they signed up yet?): %v",
bootstrapEnvVar, email, err)
return
}
if user == nil {
logger.Warnf(ctx,
"[bootstrap] %s=%s: no matching user (will retry on next restart)",
bootstrapEnvVar, email)
return
}
if user.IsSystemAdmin {
logger.Infof(ctx,
"[bootstrap] %s=%s: user %s is already a system admin (no-op)",
bootstrapEnvVar, email, user.ID)
return
}
user.IsSystemAdmin = true
if err := userSvc.UpdateUser(ctx, user); err != nil {
logger.Warnf(ctx,
"[bootstrap] %s=%s: failed to promote user %s: %v",
bootstrapEnvVar, email, user.ID, err)
return
}
logger.Infof(ctx,
"[bootstrap] promoted user %s (%s) to system admin via %s",
user.ID, email, bootstrapEnvVar)
}
+14
View File
@@ -57,12 +57,17 @@ func main() {
// Build dependency injection container
c := container.BuildContainer(runtime.GetContainer())
// One-shot bootstrap hooks (e.g. promote env-named user to system
// admin). Best-effort: never aborts startup — see bootstrap.go.
runStartupBootstrap(c)
// Run application
err := c.Invoke(func(
cfg *config.Config,
router *gin.Engine,
tracer *tracing.Tracer,
resourceCleaner interfaces.ResourceCleaner,
systemSettingSvc interfaces.SystemSettingService,
) error {
// Create HTTP server
server := &http.Server{
@@ -77,6 +82,15 @@ func main() {
ctx, done := context.WithCancel(context.Background())
// Start the system_settings pubsub subscriber. Runs in its own
// goroutine and exits when ctx is cancelled at shutdown. Best-
// effort: an error here only warns (Redis may legitimately be
// disabled in lite-mode deployments — the service no-ops in
// that case anyway).
if err := systemSettingSvc.SubscribeRedis(ctx); err != nil {
logger.Warnf(ctx, "[system_settings] subscribe failed: %v", err)
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, shutdownSignals...)
go func() {
+1
View File
@@ -194,6 +194,7 @@ services:
- WEKNORA_TENANT_ENABLE_RBAC=${WEKNORA_TENANT_ENABLE_RBAC:-}
- WEKNORA_TENANT_MAX_OWNED_PER_USER=${WEKNORA_TENANT_MAX_OWNED_PER_USER:-}
- APK_MIRROR_ARG=${APK_MIRROR_ARG:-}
- WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL=${WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL:-}
depends_on:
redis:
condition: service_started
+2 -12
View File
@@ -6,7 +6,7 @@ import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next'
import ManualKnowledgeEditor from '@/components/manual-knowledge-editor.vue'
import { useAuthStore } from '@/stores/auth'
import { useSettingsStore } from '@/stores/settings'
import { getCurrentUser } from '@/api/auth'
import { getCurrentUser, userInfoFromApi } from '@/api/auth'
import { consumePendingTenantSwitchToast } from '@/utils/tenantSwitch'
import { useRoleLabel } from '@/composables/useRoleLabel'
import { notifyLoginSuccess } from '@/utils/loginNotify'
@@ -52,17 +52,7 @@ const syncOIDCUserContext = async () => {
}
const { user, tenant, memberships } = currentUserResponse.data
authStore.setUser({
id: user.id || '',
username: user.username || '',
email: user.email || '',
avatar: user.avatar,
tenant_id: String(user.tenant_id || tenant?.id || ''),
can_access_all_tenants: user.can_access_all_tenants || false,
preferences: user.preferences,
created_at: user.created_at || new Date().toISOString(),
updated_at: user.updated_at || new Date().toISOString()
})
authStore.setUser(userInfoFromApi(user, tenant?.id))
if (tenant) {
authStore.setTenant({
id: String(tenant.id) || '',
+42
View File
@@ -19,6 +19,7 @@ export interface LoginResponse {
avatar?: string
tenant_id: number
can_access_all_tenants?: boolean
is_system_admin?: boolean
is_active: boolean
created_at: string
updated_at: string
@@ -97,10 +98,51 @@ export interface UserInfo {
tenant_id: string
can_access_all_tenants?: boolean
preferences?: UserPreferences
is_system_admin?: boolean
created_at: string
updated_at: string
}
/**
* 把后端返回的 user JSON 规范化成前端 UserInfo。
*
* 历史上有 4 处独立的 setUser 调用(Login、autoSetup、token rehydrate、
* /auth/me 主动 refresh)各自手写字段白名单,每加一个 user 字段都要在
* 4 处同步——否则该字段就被悄悄过滤掉。is_system_admin 上线时就因为
* 漏拷一处而看不到「系统管理」入口;这个工厂存在的目的就是杜绝同类
* 漏拷再发生。**新增 user 字段请只改这里**。
*
* fallbackTenantId 是 tenant_id 缺失时的兜底来源——
* - autoSetup 响应顶层有 tenant.id,但 user 对象上没有 tenant_id
* - /auth/me 偶发只返回 user 不带 tenant 时也走兜底
* 调用方按需传入;不传则保持空字符串(与历史行为一致)。
*
* 字段读取统一走 `=== true` 而不是 `|| false`,对偶发非 boolean
* 类型(后端某天传 1/0 或字符串)做严格收敛,避免把 truthy 字符串
* 误判为权限通过。
*/
export function userInfoFromApi(
u: any,
fallbackTenantId?: string | number | null,
): UserInfo {
const tid =
u?.tenant_id !== undefined && u?.tenant_id !== null && u.tenant_id !== ''
? u.tenant_id
: fallbackTenantId ?? ''
return {
id: u?.id || '',
username: u?.username || '',
email: u?.email || '',
avatar: u?.avatar,
tenant_id: String(tid) || '',
can_access_all_tenants: u?.can_access_all_tenants === true,
is_system_admin: u?.is_system_admin === true,
preferences: u?.preferences,
created_at: u?.created_at || new Date().toISOString(),
updated_at: u?.updated_at || new Date().toISOString(),
}
}
// 租户信息接口
export interface TenantInfo {
id: string
+146
View File
@@ -221,3 +221,149 @@ export interface StorageCheckResponse {
export function checkStorageEngine(req: StorageCheckRequest): Promise<{ data: StorageCheckResponse }> {
return post('/api/v1/system/storage-engine-check', req)
}
// ---- System Admin Management ----
export interface SystemAdminUser {
id: string
username: string
email: string
avatar?: string
is_active: boolean
is_system_admin: boolean
created_at: string
updated_at: string
}
export interface PromoteUserRequest {
user_id: string
}
export interface RevokeSystemAdminRequest {
user_id: string
}
export interface ListSystemAdminsResponse {
total: number
admins: SystemAdminUser[]
}
/**
* Promote a user to system administrator.
*
* Backend handler (system.go) returns the updated UserInfo directly as
* the response body — no {data: ...} wrapping. The shared axios
* interceptor in utils/request.ts unwraps response.data at the
* interceptor layer, so the resolved value here IS the UserInfo.
*
* The `as unknown as T` cast is the project-wide pattern for telling
* TS "trust me, the interceptor unwraps this" — see api/auth/index.ts
* for the same convention. A naked `Promise<T>` annotation would
* compile (sometimes — vue-tsc is inconsistent on AxiosResponse vs
* inline interface assignability) but is fragile.
*/
export async function promoteUserToSystemAdmin(userId: string): Promise<SystemAdminUser> {
const response = await post('/api/v1/system/admin/promote', { user_id: userId })
return response as unknown as SystemAdminUser
}
/**
* Revoke system administrator privileges from a user.
* Same wrapping convention as promoteUserToSystemAdmin.
*/
export async function revokeSystemAdmin(userId: string): Promise<SystemAdminUser> {
const response = await post('/api/v1/system/admin/revoke', { user_id: userId })
return response as unknown as SystemAdminUser
}
/**
* List all system administrators (paginated).
* Returns {total, admins[]} directly — no {data: ...} wrapping.
*/
export async function listSystemAdmins(
params?: { offset?: number; limit?: number },
): Promise<ListSystemAdminsResponse> {
// The shared `get` helper doesn't accept a config object, so we
// assemble the query string manually. Both params are optional;
// the server applies sane defaults (offset=0, limit=50, max=200).
const qs = new URLSearchParams()
if (params?.offset != null) qs.set('offset', String(params.offset))
if (params?.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
const response = await get(`/api/v1/system/admin/list${suffix}`)
return response as unknown as ListSystemAdminsResponse
}
// ---- System Settings (P1) ----
/**
* SystemSettingItem mirrors types.SystemSetting on the backend, exactly
* as the JSON API serialises it (no `data: ...` wrapping; see
* utils/request.ts:97 — the axios interceptor unwraps response.data
* project-wide). New fields here MUST also be added to backend
* types/system_setting.go.
*
* `value` is typed as `unknown` because the underlying JSONB column can
* hold an int / string / bool depending on `value_type`. Callers narrow
* via the value_type field (`'int' | 'string' | 'bool'`).
*/
export interface SystemSettingItem {
id: number
key: string
/** Raw JSON value — narrow via value_type before rendering. */
value: unknown
value_type: 'int' | 'string' | 'bool' | 'string_list'
category: string
description: string
/** P3+ — currently always false. UI may surface a "redacted" state when true. */
is_secret: boolean
/** P3+ — currently always false. UI may show "needs restart to take effect" badge when true. */
requires_restart: boolean
last_modified_by: string
created_at: string
updated_at: string
/**
* Allowed values for `value` when this setting is constrained. Populated by
* the service from the in-code registry; absent/empty means "free-form".
* Frontend renders a t-select instead of t-input when this is non-empty.
*/
enum?: string[]
}
/**
* List every system setting row (system-scope, not tenant-scope).
* Backend returns the array directly; we cast through `unknown` to match
* the project-wide axios contract (see utils/request.ts:97).
*/
export async function listSystemSettings(): Promise<SystemSettingItem[]> {
const response = await get('/api/v1/system/admin/settings')
return response as unknown as SystemSettingItem[]
}
/**
* Fetch a single system setting by key. Throws (via the axios interceptor)
* if the key is unknown to the registry, or if the row is not yet persisted.
*/
export async function getSystemSetting(key: string): Promise<SystemSettingItem> {
const response = await get(`/api/v1/system/admin/settings/${encodeURIComponent(key)}`)
return response as unknown as SystemSettingItem
}
/**
* Persist a new value for `key`. The backend validates the value against
* the registry-declared value_type and rejects mismatches with 400; the
* error message is surfaced via err.message (see utils/request.ts:209).
*
* Successful updates emit an audit row (action=system.setting_changed)
* carrying old/new values for forensics.
*/
export async function updateSystemSetting(
key: string,
value: unknown,
): Promise<SystemSettingItem> {
const response = await put(
`/api/v1/system/admin/settings/${encodeURIComponent(key)}`,
{ value },
)
return response as unknown as SystemSettingItem
}
+32 -13
View File
@@ -110,6 +110,20 @@
<t-icon name="setting" class="menu-icon" />
<span>{{ $t('general.allSettings') }}</span>
</div>
<!--
System administration entry visible only to users with the
platform-wide is_system_admin flag. Hidden for everyone else,
including tenant Owners. Real authorisation lives server-side
(RequireSystemAdmin middleware); this is UI gating only.
-->
<div
v-if="authStore.isSystemAdmin"
class="menu-item"
@click="handleSystemAdmin"
>
<t-icon name="server" class="menu-icon" />
<span>系统管理</span>
</div>
<!-- 切换租户入口在下拉当前租户区块 hover此处仅为分隔线与菜单项 -->
<div class="menu-divider"></div>
<div class="menu-item" @click="openClawhubSkill">
@@ -231,7 +245,7 @@ import { useRouter } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { MessagePlugin } from 'tdesign-vue-next'
import { getCurrentUser, logout as logoutApi } from '@/api/auth'
import { getCurrentUser, logout as logoutApi, userInfoFromApi } from '@/api/auth'
import { useI18n } from 'vue-i18n'
import IMChannelsOverviewPanel from '@/components/IMChannelsOverviewPanel.vue'
import CreateTenantDialog from '@/components/CreateTenantDialog.vue'
@@ -343,6 +357,15 @@ const handleSettings = () => {
router.push('/platform/settings')
}
// Open the platform-wide system administration area. Mirrors the
// existing handleSettings flow: dismiss the menu, then route. There is
// no UI store flag for system-admin (settings.vue uses one for its
// modal-overlay pattern; system area is a regular routed page).
const handleSystemAdmin = () => {
menuVisible.value = false
router.push('/platform/system')
}
// Hover-driven submenu controls. A small hide delay tolerates the pointer
// slipping off briefly onto the gap between menu item and submenu pane.
const showIMSubmenu = () => {
@@ -644,18 +667,14 @@ const loadUserInfo = async () => {
email: user.email || 'user@example.com',
avatar: user.avatar || ''
}
// 同时更新 authStore 中的用户信息,确保包含 can_access_all_tenants 字段
authStore.setUser({
id: user.id,
username: user.username,
email: user.email,
avatar: user.avatar,
tenant_id: user.tenant_id,
can_access_all_tenants: user.can_access_all_tenants || false,
preferences: user.preferences,
created_at: user.created_at,
updated_at: user.updated_at
})
// 同时更新 authStore 中的用户信息,确保包含 can_access_all_tenants /
// is_system_admin 等所有字段。MUST 走 userInfoFromApi 工厂——历史
// 上这里手写字段白名单,每加一个 user 字段都要在 5 个 setUser 调用
// 点同步,is_system_admin 就因为漏了这一处导致进入 platform 后
// user.value 的字段被 mount 时的 loadUserInfo 静默覆盖回 undefined
// (同时污染 localStorage),系统管理入口在 hover 工作空间触发
// refreshFromAuthMe 后才出现。新增字段请只改 userInfoFromApi。
authStore.setUser(userInfoFromApi(user))
// 如果返回了租户信息,也更新租户信息
if (response.data.tenant) {
authStore.setTenant({
+41 -23
View File
@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { autoSetup, getCurrentUser } from '@/api/auth'
import { autoSetup, getCurrentUser, userInfoFromApi } from '@/api/auth'
/** Lite /桌面 WebView 硬刷新时可能只打开 `/`,用 session 记住上次页面以便恢复 */
const LITE_LAST_PATH_KEY = 'weknora_lite_last_path'
@@ -140,6 +140,33 @@ const router = createRouter({
component: () => import("../views/organization/OrganizationList.vue"),
meta: { requiresInit: true, requiresAuth: true }
},
// System administration area — gated to users with
// user.is_system_admin === true. The route guard below honours
// the meta.requiresSystemAdmin flag; non-admins are bounced to
// /platform/knowledge-bases. Real authorisation lives server-side.
{
path: "system",
component: () => import("../views/system/SystemLayout.vue"),
meta: { requiresInit: true, requiresAuth: true, requiresSystemAdmin: true },
redirect: "/platform/system/settings",
children: [
{
// P1 default landing page — the SystemAdmin lands here
// because most platform-level operations involve tweaking
// a setting, not promoting another admin.
path: "settings",
name: "systemSettings",
component: () => import("../views/system/SystemSettings.vue"),
meta: { requiresInit: true, requiresAuth: true, requiresSystemAdmin: true }
},
{
path: "admins",
name: "systemAdmins",
component: () => import("../views/system/SystemAdmins.vue"),
meta: { requiresInit: true, requiresAuth: true, requiresSystemAdmin: true }
},
],
},
],
},
// Dev-only markdown rendering test page
@@ -155,17 +182,7 @@ const router = createRouter({
// 持久化 auto-setup / login 返回的认证信息到 store
function persistLoginResponse(authStore: ReturnType<typeof useAuthStore>, response: any) {
if (response.user && response.tenant && response.token) {
authStore.setUser({
id: response.user.id || '',
username: response.user.username || '',
email: response.user.email || '',
avatar: response.user.avatar,
tenant_id: String(response.tenant.id) || '',
can_access_all_tenants: response.user.can_access_all_tenants || false,
preferences: response.user.preferences,
created_at: response.user.created_at || new Date().toISOString(),
updated_at: response.user.updated_at || new Date().toISOString()
})
authStore.setUser(userInfoFromApi(response.user, response.tenant.id))
authStore.setToken(response.token)
if (response.refresh_token) {
authStore.setRefreshToken(response.refresh_token)
@@ -201,17 +218,7 @@ async function hydrateSessionFromToken(authStore: ReturnType<typeof useAuthStore
return false
}
authStore.setUser({
id: user.id || '',
username: user.username || '',
email: user.email || '',
avatar: user.avatar,
tenant_id: String(user.tenant_id || response.data?.tenant?.id || ''),
can_access_all_tenants: user.can_access_all_tenants || false,
preferences: user.preferences,
created_at: user.created_at || new Date().toISOString(),
updated_at: user.updated_at || new Date().toISOString(),
})
authStore.setUser(userInfoFromApi(user, response.data?.tenant?.id))
const tenant = response.data?.tenant
if (tenant) {
@@ -315,6 +322,17 @@ router.beforeEach(async (to, from, next) => {
}
}
// SystemAdmin gate — checked AFTER auth so a non-admin who's logged
// out gets redirected to /login first (consistent with how the rest
// of the auth flow works), and only an authenticated non-admin sees
// the bounce. This is UI-only; the server enforces the real check.
if (to.meta.requiresSystemAdmin === true) {
if (!authStore.isSystemAdmin) {
next('/platform/knowledge-bases')
return
}
}
next()
})
+24 -12
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, TenantInfo, KnowledgeBaseInfo } from '@/api/auth'
import { userInfoFromApi } from '@/api/auth'
import type { TenantInfo as TenantInfoFromAPI } from '@/api/tenant'
import i18n from '@/i18n'
import { reloadFontFromStorage } from '@/composables/useFont'
@@ -87,6 +88,22 @@ export const useAuthStore = defineStore('auth', () => {
return user.value?.can_access_all_tenants || false
})
// isSystemAdmin reflects the platform-wide system-administrator flag
// (User.IsSystemAdmin on the server). It is independent of per-tenant
// Owner/Admin/Contributor/Viewer roles — a system admin can manage
// global settings, built-in models, and other system admins regardless
// of which tenant they're currently scoped into.
//
// SECURITY: same caveat as currentTenantRole — this value is hydrated
// from localStorage on reload and is therefore tamper-prone client-side.
// Use it ONLY to gate UI visibility (menu entries, route guards). All
// real authorisation lives in the server-side RequireSystemAdmin
// middleware (see internal/middleware/rbac.go). A user who flips this
// bit in DevTools will get a 403 the moment they hit a guarded endpoint.
const isSystemAdmin = computed(() => {
return user.value?.is_system_admin === true
})
// currentTenantRole returns the user's role in the active tenant
// (defaulting to '' when memberships have not been loaded). Used by
// role-aware UI gating; PR 2 wires backend enforcement, PR 3 uses
@@ -307,17 +324,7 @@ export const useAuthStore = defineStore('auth', () => {
const u = response.data?.user
if (!response.success || !u) return false
setUser({
id: u.id || '',
username: u.username || '',
email: u.email || '',
avatar: u.avatar,
tenant_id: String(u.tenant_id || response.data?.tenant?.id || ''),
can_access_all_tenants: u.can_access_all_tenants || false,
preferences: u.preferences,
created_at: u.created_at || new Date().toISOString(),
updated_at: u.updated_at || new Date().toISOString(),
})
setUser(userInfoFromApi(u, response.data?.tenant?.id))
const tenantSnapshot = response.data?.tenant
if (tenantSnapshot) {
@@ -407,7 +414,11 @@ export const useAuthStore = defineStore('auth', () => {
if (storedUser) {
try {
user.value = JSON.parse(storedUser)
// 走 userInfoFromApi 把老 localStorage(可能缺新字段,如
// is_system_admin)规范化一遍,避免「我新加了字段、但老登录态
// 没经过登录响应处理过、字段就永远是 undefined」的死角。
// 这是「漏拷 4 处」之外的第 5 个隐藏入口,专门给页面刷新走的。
user.value = userInfoFromApi(JSON.parse(storedUser))
} catch (e) {
console.error(i18n.global.t('authStore.errors.parseUserFailed'), e)
}
@@ -498,6 +509,7 @@ export const useAuthStore = defineStore('auth', () => {
currentTenantName,
currentUserId,
canAccessAllTenants,
isSystemAdmin,
currentTenantRole,
hasRole,
effectiveTenantId,
+2 -12
View File
@@ -379,7 +379,7 @@ import { Autoplay, EffectFade, Pagination } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/effect-fade'
import 'swiper/css/pagination'
import { login, register, getOIDCAuthorizationURL, getOIDCConfig, autoSetup, getAuthConfig } from '@/api/auth'
import { login, register, getOIDCAuthorizationURL, getOIDCConfig, autoSetup, getAuthConfig, userInfoFromApi } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
@@ -562,17 +562,7 @@ const persistLoginResponse = async (response: any) => {
// server honoured a remembered last-active-tenant preference) is
// expressed separately via setSelectedTenant below.
const homeTenantIdRaw = response.user.tenant_id ?? activeTenant.id
authStore.setUser({
id: response.user.id || '',
username: response.user.username || '',
email: response.user.email || '',
avatar: response.user.avatar,
tenant_id: String(homeTenantIdRaw) || '',
can_access_all_tenants: response.user.can_access_all_tenants || false,
preferences: response.user.preferences,
created_at: response.user.created_at || new Date().toISOString(),
updated_at: response.user.updated_at || new Date().toISOString()
})
authStore.setUser(userInfoFromApi(response.user, homeTenantIdRaw))
authStore.setToken(response.token)
if (response.refresh_token) {
authStore.setRefreshToken(response.refresh_token)
+259
View File
@@ -0,0 +1,259 @@
<template>
<!--
SystemAdmins list & manage the platform-wide system administrators.
Grants/revokes via the /api/v1/system/admin/{promote,revoke,list}
endpoints (server-side guarded by RequireSystemAdmin middleware).
P0 milestone scope is deliberately minimal:
- Paginated list of current system admins
- Promote a user by email
- Revoke an existing admin (with last-admin & self-revoke server guards)
Future milestones can layer richer UX on top search, audit-log
drawer, bulk operations but the API surface and gating are stable.
-->
<div class="system-admins">
<div class="page-header">
<div>
<h1 class="page-title">系统管理员</h1>
<p class="page-desc">
系统管理员独立于租户角色拥有平台级管理权限管理其他系统管理员全局设置内置模型等
</p>
</div>
<t-button theme="primary" @click="openPromoteDialog">
<template #icon><t-icon name="add" /></template>
提升用户为系统管理员
</t-button>
</div>
<t-card class="admins-card" :bordered="false">
<t-table
row-key="id"
:data="admins"
:columns="columns"
:loading="loading"
size="medium"
hover
stripe
>
<template #status="{ row }">
<t-tag v-if="row.is_active" theme="success" variant="light">
活跃
</t-tag>
<t-tag v-else theme="warning" variant="light">已停用</t-tag>
</template>
<template #actions="{ row }">
<t-button
theme="danger"
variant="text"
size="small"
:disabled="row.id === currentUserId"
@click="confirmRevoke(row)"
>
撤销权限
</t-button>
</template>
</t-table>
<div class="pagination-bar" v-if="total > 0">
<t-pagination
v-model="page"
v-model:page-size="pageSize"
:total="total"
:page-size-options="[20, 50, 100]"
@change="loadAdmins"
/>
</div>
</t-card>
<!-- Promote dialog -->
<t-dialog
v-model:visible="promoteDialogVisible"
header="提升用户为系统管理员"
:confirm-btn="{ content: '确认提升', loading: promoting }"
@confirm="submitPromote"
@close="resetPromoteDialog"
>
<p class="dialog-hint">
请输入要提升的用户的 ID该用户必须已经在系统中注册过操作幂等已是管理员的用户不会被重复提升
</p>
<t-input
v-model="promoteUserId"
placeholder="用户 IDUUID 形式)"
:disabled="promoting"
clearable
/>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { useAuthStore } from '@/stores/auth'
import {
listSystemAdmins,
promoteUserToSystemAdmin,
revokeSystemAdmin,
type SystemAdminUser,
} from '@/api/system'
const authStore = useAuthStore()
const currentUserId = computed(() => authStore.currentUserId)
const admins = ref<SystemAdminUser[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const promoteDialogVisible = ref(false)
const promoteUserId = ref('')
const promoting = ref(false)
// Columns are defined as a computed (vs a static const) so future
// localization/translation can hook in without component refactor.
const columns = [
{ colKey: 'username', title: '用户名', width: 180 },
{ colKey: 'email', title: '邮箱', width: 280 },
{ colKey: 'status', title: '状态', width: 100 },
{ colKey: 'created_at', title: '注册时间', width: 200 },
{ colKey: 'actions', title: '操作', width: 120, align: 'right' as const },
]
async function loadAdmins() {
loading.value = true
try {
const offset = (page.value - 1) * pageSize.value
// listSystemAdmins resolves to ListSystemAdminsResponse directly —
// utils/request.ts already unwraps axios's response.data wrapper at
// the interceptor layer (see line 97), so an extra `.data` here
// would explode at runtime as "Cannot read property 'admins' of
// undefined" and surface as the generic load-failed toast.
const res = await listSystemAdmins({ offset, limit: pageSize.value })
admins.value = res.admins ?? []
total.value = res.total ?? 0
} catch (err) {
// Surface a toast and bail — keeping prior list so the user isn't
// left staring at an empty table on a transient failure.
MessagePlugin.error('加载系统管理员列表失败')
console.error('listSystemAdmins failed:', err)
} finally {
loading.value = false
}
}
function openPromoteDialog() {
promoteUserId.value = ''
promoteDialogVisible.value = true
}
function resetPromoteDialog() {
promoteUserId.value = ''
promoting.value = false
}
async function submitPromote() {
const uid = promoteUserId.value.trim()
if (!uid) {
MessagePlugin.warning('请输入用户 ID')
return
}
promoting.value = true
try {
await promoteUserToSystemAdmin(uid)
MessagePlugin.success('已提升为系统管理员')
promoteDialogVisible.value = false
await loadAdmins()
} catch (err: any) {
// utils/request.ts 把后端的 {error: "..."} 提升到了顶层 message
// 字段(line 197-213),同时保留原始 data 字段。我们优先读 message
// —— 它已经是后端的 human-readable 错误文案,覆盖了 user-not-found
// / 自我撤销等业务错误场景。
const msg = err?.message || err?.error || '提升失败,请检查用户 ID 后重试'
MessagePlugin.error(msg)
} finally {
promoting.value = false
}
}
function confirmRevoke(row: SystemAdminUser) {
// Confirmation dialog is mandatory — revoking is destructive and
// the server's last-admin / self-revoke guards return 400 with a
// human-readable message which we re-surface in submitRevoke.
const confirmDialog = DialogPlugin.confirm({
header: '撤销系统管理员权限',
body: `确定要撤销用户 "${row.username}" (${row.email}) 的系统管理员权限吗?`,
confirmBtn: { content: '确认撤销', theme: 'danger' },
cancelBtn: '取消',
onConfirm: async () => {
await submitRevoke(row.id)
confirmDialog.destroy()
},
onClose: () => confirmDialog.destroy(),
})
}
async function submitRevoke(userId: string) {
try {
await revokeSystemAdmin(userId)
MessagePlugin.success('已撤销系统管理员权限')
await loadAdmins()
} catch (err: any) {
// 同 submitPromote 的注释 —— 后端 last-admin / self-revoke 保护
// 走 400 + {error: "..."},被拦截器抬到 err.message。
const msg = err?.message || err?.error || '撤销失败'
MessagePlugin.error(msg)
}
}
onMounted(() => {
loadAdmins()
})
</script>
<style scoped>
.system-admins {
max-width: 1100px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
}
.page-title {
margin: 0 0 6px;
font-size: 22px;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
.page-desc {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--td-text-color-secondary, #666);
max-width: 720px;
}
.admins-card {
margin-top: 8px;
}
.pagination-bar {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.dialog-hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--td-text-color-secondary, #666);
line-height: 1.6;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<template>
<!--
SystemLayout top-level shell for the platform-wide administration
area (/platform/system/*). Gated by meta.requiresSystemAdmin in the
router; reaching this component means the caller is an authenticated
SystemAdmin.
Sidebar is intentionally simple for the P0 milestone: only the
"Administrators" page is wired. Future P1+ pages (global settings,
built-in models, audit log, tenants overview) plug in here as new
sidebar items + child routes.
-->
<div class="system-layout">
<aside class="system-sidebar">
<div class="system-sidebar-header">
<h2 class="system-sidebar-title">系统管理</h2>
<div class="system-sidebar-subtitle">SystemAdmin</div>
</div>
<nav class="system-nav">
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
v-slot="{ isActive, navigate }"
custom
>
<div
:class="['system-nav-item', { active: isActive }]"
@click="navigate"
>
<t-icon :name="item.icon" class="system-nav-icon" />
<span class="system-nav-label">{{ item.label }}</span>
</div>
</router-link>
</nav>
</aside>
<main class="system-content">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
// Nav items are declared here (not in a separate config file) because
// the list is short and tightly coupled to which child routes exist.
// When a new child route is added in router/index.ts under /platform/system,
// add a matching entry here.
const navItems = [
{ name: 'systemSettings', label: '全局设置', icon: 'setting' },
{ name: 'systemAdmins', label: '系统管理员', icon: 'user-shield' },
]
</script>
<style scoped>
.system-layout {
display: flex;
height: 100%;
width: 100%;
background: var(--td-bg-color-page, #f5f5f5);
}
.system-sidebar {
width: 240px;
flex-shrink: 0;
background: var(--td-bg-color-container, #fff);
border-right: 1px solid var(--td-border-level-1-color, #e7e7e7);
padding: 20px 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.system-sidebar-header {
padding: 0 12px 12px;
border-bottom: 1px solid var(--td-border-level-1-color, #e7e7e7);
}
.system-sidebar-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
.system-sidebar-subtitle {
font-size: 11px;
color: var(--td-text-color-placeholder, #999);
margin-top: 4px;
letter-spacing: 0.5px;
}
.system-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.system-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: var(--td-text-color-primary, #333);
transition: background-color 0.15s;
}
.system-nav-item:hover {
background: var(--td-bg-color-component-hover, #f3f3f3);
}
.system-nav-item.active {
background: var(--td-brand-color-light, #e0eaff);
color: var(--td-brand-color, #0052d9);
font-weight: 500;
}
.system-nav-icon {
font-size: 16px;
}
.system-content {
flex: 1;
overflow: auto;
padding: 24px 32px;
}
</style>
@@ -0,0 +1,475 @@
<template>
<!--
SystemSettings platform-wide tunables (system_settings table) for
SystemAdmin. Gated server-side by RequireSystemAdmin middleware;
the route also has meta.requiresSystemAdmin so non-admins never
reach this component (see frontend/src/router/index.ts).
UI principle: each row is independently editable + savable. We don't
show a global "Save all" button because backend Update is per-key
(the audit log records each change individually) and the
"save-and-see-effect" loop is friendlier per-field. Validation is
client-side first (matching value_type), server-side strict.
-->
<div class="system-settings">
<div class="page-header">
<div>
<h1 class="page-title">全局设置</h1>
<p class="page-desc">
平台级运行时配置。修改保存后立即生效(不需要重启服务)。
所有变更会写入审计日志。
</p>
</div>
<t-button variant="text" @click="loadSettings" :loading="loading">
<template #icon><t-icon name="refresh" /></template>
刷新
</t-button>
</div>
<div v-if="loading && groupedSettings.length === 0" class="loading-state">
<t-loading text="加载中..." />
</div>
<div v-else-if="groupedSettings.length === 0" class="empty-state">
<t-icon name="info-circle" size="32px" />
<div>暂无可配置的系统设置</div>
</div>
<t-card
v-for="group in groupedSettings"
:key="group.category"
class="settings-group"
:bordered="false"
:header-bordered="true"
>
<template #title>
<div class="group-title">
<span class="group-title-text">{{ categoryLabel(group.category) }}</span>
<span class="group-title-count">{{ group.items.length }}</span>
</div>
</template>
<div
v-for="item in group.items"
:key="item.key"
class="setting-row"
>
<div class="setting-info">
<div class="setting-key">
<span class="setting-key-text">{{ item.key }}</span>
<t-tag
v-if="item.requires_restart"
theme="warning"
variant="light"
size="small"
>需重启</t-tag>
<t-tag
v-if="item.is_secret"
theme="primary"
variant="light"
size="small"
>敏感</t-tag>
</div>
<div v-if="item.description" class="setting-desc">{{ item.description }}</div>
<div class="setting-meta">
<span>类型: {{ item.value_type }}</span>
<span v-if="item.last_modified_by">· 最后修改: {{ item.last_modified_by.slice(0, 8) }}</span>
<span v-if="item.updated_at">· {{ formatDate(item.updated_at) }}</span>
</div>
</div>
<div class="setting-control">
<!--
Per-type input. value_type drives which control we render:
int → InputNumber
bool → Switch
string_list → TagInput (each entry as a tag)
string → InputNumber/Input/Select depending on enum
When `enum` is non-empty (regardless of declared type, but
only meaningful for string), we override to a Select.
-->
<t-select
v-if="hasEnum(item)"
v-model="editValues[item.key]"
:options="enumOptions(item)"
:disabled="savingKey === item.key"
class="setting-input"
/>
<t-input-number
v-else-if="item.value_type === 'int'"
v-model="editValues[item.key]"
:placeholder="String(item.value)"
:disabled="savingKey === item.key"
theme="normal"
:step="1"
:min="0"
class="setting-input"
/>
<t-switch
v-else-if="item.value_type === 'bool'"
v-model="editValues[item.key]"
:disabled="savingKey === item.key"
/>
<t-tag-input
v-else-if="item.value_type === 'string_list'"
v-model="editValues[item.key]"
:placeholder="emptyListPlaceholder"
:disabled="savingKey === item.key"
class="setting-input setting-input--wide"
clearable
/>
<t-input
v-else
v-model="editValues[item.key]"
:placeholder="String(item.value)"
:disabled="savingKey === item.key"
class="setting-input"
clearable
/>
<t-button
theme="primary"
size="small"
:loading="savingKey === item.key"
:disabled="!isDirty(item)"
@click="saveSetting(item)"
>
保存
</t-button>
</div>
</div>
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import {
listSystemSettings,
updateSystemSetting,
type SystemSettingItem,
} from '@/api/system'
// Friendly Chinese labels per category. Falls back to the raw category
// string when a new one shows up that we haven't translated yet, which
// is the right behaviour: a missing translation should be visible, not
// silently empty.
const CATEGORY_LABELS: Record<string, string> = {
limits: '上限',
agent: 'Agent',
auth: '认证',
security: '安全',
storage: '存储',
general: '通用',
}
function categoryLabel(c: string): string {
return CATEGORY_LABELS[c] || c
}
// Keys that change platform-wide trust boundaries. Saving these triggers
// an extra "are you sure?" dialog so a careless click can't, say, flip
// auth.registration_mode=self_serve and let the public spam the system.
// New high-impact keys can be added here when they ship.
const HIGH_RISK_KEYS = new Set<string>([
'ssrf.whitelist',
'auth.registration_mode',
])
// Friendly labels for enum options (drives the t-select dropdown text).
// We keep them inline rather than fetching from i18n because (a) the
// list is small, (b) the options are tied to backend constants and
// shouldn't drift, (c) translators don't typically need to see them.
const ENUM_LABELS: Record<string, Record<string, string>> = {
'auth.registration_mode': {
self_serve: '自助注册(任何人可注册)',
invite_only: '仅邀请(关闭公网注册)',
},
}
const emptyListPlaceholder = '回车添加条目,例:example.com / *.foo.com / 10.0.0.0/8'
const settings = ref<SystemSettingItem[]>([])
const loading = ref(false)
const savingKey = ref<string | null>(null)
// Reactive map of in-progress edits, keyed by setting key. We don't
// mutate the canonical `settings` array directly so a failed save
// leaves the original value visible until the user retries or refreshes.
// Initialised lazily in loadSettings; setting.value is the JSON-decoded
// form (number / boolean / string / string[]).
const editValues = reactive<Record<string, unknown>>({})
const groupedSettings = computed(() => {
const buckets: Record<string, SystemSettingItem[]> = {}
for (const item of settings.value) {
const c = item.category || 'general'
if (!buckets[c]) buckets[c] = []
buckets[c].push(item)
}
return Object.keys(buckets)
.sort()
.map((category) => ({ category, items: buckets[category] }))
})
function hasEnum(item: SystemSettingItem): boolean {
return Array.isArray(item.enum) && item.enum.length > 0
}
function enumOptions(item: SystemSettingItem): { label: string; value: string }[] {
const opts = item.enum ?? []
const labelMap = ENUM_LABELS[item.key] ?? {}
return opts.map((v) => ({ label: labelMap[v] ?? v, value: v }))
}
// isDirty drives the Save button's disabled state. Arrays need
// element-wise comparison; primitives use strict equality. We don't
// import a lodash isEqual to keep the bundle small — the comparison
// surface is intentionally narrow (4 value_types).
function isDirty(item: SystemSettingItem): boolean {
const cur = editValues[item.key]
const orig = item.value
if (Array.isArray(cur) && Array.isArray(orig)) {
if (cur.length !== orig.length) return true
for (let i = 0; i < cur.length; i++) {
if (cur[i] !== orig[i]) return true
}
return false
}
return cur !== orig
}
function formatDate(isoString: string): string {
try {
const d = new Date(isoString)
return d.toLocaleString('zh-CN', { hour12: false })
} catch {
return isoString
}
}
async function loadSettings() {
loading.value = true
try {
const list = await listSystemSettings()
settings.value = list
// Reset edit values to the canonical state on every load — no
// partial drafts survive a refresh, which avoids the "I came back
// and my unsaved edits look saved" trap.
for (const item of list) {
// Defensive copy for arrays so the t-tag-input doesn't mutate
// the canonical settings entry through the v-model binding.
editValues[item.key] = Array.isArray(item.value)
? [...(item.value as unknown[])]
: item.value
}
} catch (err: any) {
const msg = err?.message || '加载系统设置失败'
MessagePlugin.error(msg)
} finally {
loading.value = false
}
}
async function saveSetting(item: SystemSettingItem) {
if (!isDirty(item)) return // double-click guard
// High-risk keys get an extra confirmation. The dialog text is
// intentionally specific: a generic "are you sure?" trains people to
// click through. Mention the actual setting + new value so the user
// re-reads what they're about to do.
if (HIGH_RISK_KEYS.has(item.key)) {
const confirmed = await new Promise<boolean>((resolve) => {
const dlg = DialogPlugin.confirm({
header: '高危操作确认',
body: highRiskConfirmBody(item),
confirmBtn: { content: '确认保存', theme: 'danger' },
cancelBtn: '取消',
onConfirm: () => {
resolve(true)
dlg.destroy()
},
onClose: () => {
resolve(false)
dlg.destroy()
},
})
})
if (!confirmed) return
}
await persistSetting(item)
}
function highRiskConfirmBody(item: SystemSettingItem): string {
const newValue = editValues[item.key]
const renderedValue = Array.isArray(newValue)
? newValue.length === 0
? '(空)'
: newValue.join(', ')
: String(newValue)
switch (item.key) {
case 'auth.registration_mode':
return `即将把「自助注册模式」改为:${renderedValue}\n\n` +
`如果切到 self_serve,公网任何人都可以注册账号 — 务必确认是预期行为。`
case 'ssrf.whitelist':
return `即将把 SSRF 白名单改为:${renderedValue}\n\n` +
`白名单中的主机/IP/网段会绕过 SSRF 防护。错误配置可能让 Agent 访问内网服务。`
default:
return `即将把「${item.key}」改为:${renderedValue}`
}
}
async function persistSetting(item: SystemSettingItem) {
const newValue = editValues[item.key]
savingKey.value = item.key
try {
const updated = await updateSystemSetting(item.key, newValue)
// Replace the row in-place so the table stays at scroll position
// and other rows' edit state isn't disturbed.
const idx = settings.value.findIndex((s) => s.key === item.key)
if (idx >= 0) {
settings.value[idx] = updated
}
editValues[item.key] = Array.isArray(updated.value)
? [...(updated.value as unknown[])]
: updated.value
MessagePlugin.success(`已保存 ${item.key}`)
} catch (err: any) {
const msg = err?.message || '保存失败'
MessagePlugin.error(msg)
} finally {
savingKey.value = null
}
}
onMounted(() => {
loadSettings()
})
</script>
<style scoped>
.system-settings {
max-width: 980px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
}
.page-title {
margin: 0 0 6px;
font-size: 22px;
font-weight: 600;
color: var(--td-text-color-primary, #000);
}
.page-desc {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--td-text-color-secondary, #666);
max-width: 720px;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px 0;
color: var(--td-text-color-placeholder, #999);
}
.settings-group {
margin-bottom: 16px;
}
.group-title {
display: flex;
align-items: center;
gap: 8px;
}
.group-title-text {
font-size: 15px;
font-weight: 600;
}
.group-title-count {
font-size: 12px;
color: var(--td-text-color-placeholder, #999);
background: var(--td-bg-color-component, #f5f5f5);
padding: 1px 8px;
border-radius: 10px;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 14px 0;
border-bottom: 1px solid var(--td-border-level-1-color, #eee);
}
.setting-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.setting-info {
flex: 1;
min-width: 0;
}
.setting-key {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.setting-key-text {
font-size: 14px;
font-weight: 500;
color: var(--td-text-color-primary, #000);
font-family: var(--td-font-family-mono, monospace);
}
.setting-desc {
font-size: 13px;
line-height: 1.6;
color: var(--td-text-color-secondary, #666);
margin-bottom: 4px;
}
.setting-meta {
font-size: 11px;
color: var(--td-text-color-placeholder, #999);
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.setting-control {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.setting-input {
width: 220px;
}
.setting-input--wide {
width: 420px;
}
</style>
@@ -0,0 +1,76 @@
package repository
import (
"context"
"errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// systemSettingRepository implements interfaces.SystemSettingRepository
// against the system_settings table (migration 000053). The table is
// system-scoped (no tenant_id column) and intentionally tiny — single-
// digit rows in P1 — so List does not paginate.
type systemSettingRepository struct {
db *gorm.DB
}
// NewSystemSettingRepository wires the repo into the dig container.
// Receives the shared *gorm.DB; no other deps.
func NewSystemSettingRepository(db *gorm.DB) interfaces.SystemSettingRepository {
return &systemSettingRepository{db: db}
}
// Get fetches a system setting by key. Returns (nil, nil) when the row
// does not exist — the resolver service treats "missing" as "fall back
// to ENV / default", so a 404 here is a normal control-flow signal,
// not an error. Real DB errors (connection lost, etc.) surface up.
func (r *systemSettingRepository) Get(ctx context.Context, key string) (*types.SystemSetting, error) {
var s types.SystemSetting
err := r.db.WithContext(ctx).Where("key = ?", key).First(&s).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &s, nil
}
// List returns every system_settings row, ordered by category then key
// for stable management-UI rendering. No pagination — see type comment.
func (r *systemSettingRepository) List(ctx context.Context) ([]*types.SystemSetting, error) {
var rows []*types.SystemSetting
err := r.db.WithContext(ctx).Order("category ASC, key ASC").Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
// Upsert writes the row keyed by Key. We use ON CONFLICT (key) DO UPDATE
// rather than the naive Save() because (a) the natural key is `key`, not
// `id`, and (b) the seeded migration row already has an id; a Save with
// id=0 would re-insert and trip the UNIQUE constraint. Updating only
// the mutable columns prevents the migration's seeded id/created_at
// from being overwritten.
func (r *systemSettingRepository) Upsert(ctx context.Context, s *types.SystemSetting) error {
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{
"value",
"value_type",
"category",
"description",
"is_secret",
"requires_restart",
"last_modified_by",
"updated_at",
}),
}).
Create(s).Error
}
+33
View File
@@ -127,6 +127,39 @@ func (r *userRepository) ListUsers(ctx context.Context, offset, limit int) ([]*t
return users, nil
}
// ListSystemAdmins lists users where is_system_admin = true.
//
// Walks idx_users_is_system_admin (created in migration 000052), so the
// query stays cheap even on a large users table — only the small subset
// of system admins is scanned. Returns total count alongside the page so
// the management UI can render pagination without a second roundtrip.
//
// Ordered by created_at DESC for stable, newest-first listing; ties are
// further broken by id to keep paging deterministic across boundaries.
// limit <= 0 means "no limit" (matches ListUsers semantics); callers in
// production pass a sane page size.
func (r *userRepository) ListSystemAdmins(ctx context.Context, offset, limit int) ([]*types.User, int64, error) {
var users []*types.User
var total int64
base := r.db.WithContext(ctx).Model(&types.User{}).Where("is_system_admin = ?", true)
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
query := base.Order("created_at DESC, id ASC")
if limit > 0 {
query = query.Limit(limit)
}
if offset > 0 {
query = query.Offset(offset)
}
if err := query.Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// SearchUsers searches users by username or email
func (r *userRepository) SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error) {
var users []*types.User
@@ -0,0 +1,936 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/internal/utils"
)
// pubsubChannelBase is the Redis channel base for system_settings change
// notifications. Mirrors the convention from approval/gate.go: optional
// suffix WEKNORA_REDIS_NAMESPACE so two deployments sharing one Redis
// instance don't cross-talk.
const pubsubChannelBase = "weknora:system_settings:changed"
// pubsubChannel resolves the effective channel name (with optional
// namespace suffix). Called both at publish time and inside the
// subscriber loop — keep it pure.
func pubsubChannel() string {
if ns := strings.TrimSpace(os.Getenv("WEKNORA_REDIS_NAMESPACE")); ns != "" {
return pubsubChannelBase + ":" + ns
}
return pubsubChannelBase
}
// changeMessage is the JSON payload published whenever a setting is
// updated. OriginID lets the publishing replica skip its own message
// (it already updated its local cache inline) — without it every
// publish would trigger a redundant DB roundtrip per replica.
type changeMessage struct {
Key string `json:"key"`
OriginID string `json:"origin_id"`
}
// settingSpec is the in-code registry entry for a known system setting.
// The registry serves as the **only** authority on which keys are legal
// + what type they hold + what their ENV-fallback name is + what the
// built-in default is. Adding a new tunable is a matter of:
// 1. Adding an entry here.
// 2. (Optional) adding a SQL seed row in a new migration so the UI
// shows the row even before any operator hits Update.
// 3. Replacing existing os.Getenv() reads with calls into the
// service.
//
// Update rejects any key not in this registry — so the UI cannot inject
// arbitrary keys into the DB, even with an attacker-controlled body.
type settingSpec struct {
// Type is one of "int" | "string" | "bool" | "string_list". Update
// validates the payload's Go type against this; reads decode accordingly.
Type string
// EnvName is the legacy environment variable consulted when the DB
// row is absent. Empty string means "no ENV fallback for this key"
// (the caller passes the desired default explicitly via the GetXxx
// def parameter — useful when the cfg already coerced it at startup).
EnvName string
// Default is the built-in fallback used when both DB and ENV miss.
// Type must match the Type field (int → int64, string → string,
// bool → bool, string_list → []string); the typed Get* methods cast
// accordingly. Currently unused by the resolver (callers pass def
// inline) but kept for future cfg-less callsites.
Default any
// Enum, when non-empty, restricts Update to values in this set.
// Only meaningful for Type=="string". Other types ignore it.
// Empty/nil means no restriction (free-form string).
Enum []string
// Category drives UI grouping. Stored on the row at first write;
// the seed migration sets it explicitly so management UI can
// render even before any Update.
Category string
// Description is shown in the UI under the key. Stored on the row
// at first write (mirrors Category).
Description string
}
// registry pins the set of legal keys. Expanding it is a deliberate,
// reviewable operation — the implicit contract is "every key here is
// safely runtime-tunable (no startup caching that would not honour
// the new value, no in-memory state bound at init time we cannot
// re-derive)".
var registry = map[string]settingSpec{
"file.max_size_mb": {
Type: "int",
EnvName: "MAX_FILE_SIZE_MB",
Default: int64(50),
},
"ssrf.whitelist": {
Type: "string_list",
EnvName: "SSRF_WHITELIST",
Default: []string{},
Category: "security",
Description: "SSRF 防护白名单。可填入 example.com / *.foo.com / 10.0.0.0/8 / 2001:db8::1。" +
"修改后立即生效。SSRF_WHITELIST_EXTRA 环境变量仍由部署方维护,不在此处覆盖。",
},
"auth.registration_mode": {
Type: "string",
EnvName: "", // No env fallback — handler passes cfg.Auth.RegistrationMode as default
Default: "self_serve",
Enum: []string{"self_serve", "invite_only"},
Category: "auth",
Description: "自助注册模式。self_serve = 任何人可注册账号;invite_only = 关闭公网注册," +
"仅 Owner/Admin 可邀请。修改后立即生效,但谨慎对待 self_serve(公网会接受 spam)。",
},
}
// systemSettingService wires the repository, audit log, and (P2)
// the Redis client + an in-memory cache. Cache strategy is "preload
// at boot, invalidate via pubsub":
//
// - On startup we async-load every row into `cache` (best-effort —
// a DB hiccup just means a slower warmup, not a fatal error).
// - GetXxx reads from cache (microsecond latency).
// - Update writes DB → updates local cache → publishes a change
// notification to Redis.
// - Subscribers on every replica read the notification and re-fetch
// the row from DB (NOT from the message payload — the message only
// carries the key, never the value, so we never trust pubsub-as-
// transport with config bytes).
// - The publishing replica skips its own messages by matching
// OriginID against its instanceID.
//
// When Redis is nil (lite mode / REDIS_ADDR unset), every code path
// degrades back to P1 behaviour: no cache invalidation, but local
// edits still take effect (since Update does write the local cache
// inline). This is the right behaviour for single-replica deployments.
type systemSettingService struct {
repo interfaces.SystemSettingRepository
audit interfaces.AuditLogService
rdb *redis.Client // may be nil in lite mode
// instanceID disambiguates this replica from its peers in the
// pubsub stream. Generated once at construction; never changes.
instanceID string
// cache holds every known setting indexed by key. Populated by
// loadCache (preload + after every pubsub message). All access
// goes through `mu`. A nil entry means "we know there's no row
// and the resolver should fall through to ENV/default".
mu sync.RWMutex
cache map[string]*types.SystemSetting
// loaded flips true once the initial preload finishes. Reads
// before this point fall through to the DB so the very first
// hot request after boot doesn't get a default-valued surprise.
loaded atomic.Bool
// subOnce guarantees SubscribeRedis can be called multiple times
// without spawning duplicate goroutines (defensive — main only
// calls it once).
subOnce sync.Once
}
// NewSystemSettingService is the dig provider. audit may be nil
// (matches the tenantMemberService convention — tests that don't care
// about audit can pass nil and emitAudit no-ops). rdb may also be nil
// when REDIS_ADDR is unset — the service degrades gracefully to the
// P1 "no cache, every read hits DB" path.
func NewSystemSettingService(
repo interfaces.SystemSettingRepository,
audit interfaces.AuditLogService,
rdb *redis.Client,
) interfaces.SystemSettingService {
s := &systemSettingService{
repo: repo,
audit: audit,
rdb: rdb,
instanceID: uuid.NewString(),
cache: make(map[string]*types.SystemSetting),
}
// Async preload — don't block container build / handler readiness
// on a slow DB. The first few requests may miss cache and hit the
// DB directly via the resolver fallback; that's a few ms each and
// completes long before the cache is full.
go s.preload(context.Background())
return s
}
// preload populates the cache with every row from the system_settings
// table. Best-effort: a DB error here is logged and silently swallowed,
// because the resolver's DB-fallback path will still serve correct
// values (just slower). Logging the count gives operators a single line
// in the startup log they can grep for ("how many keys did P2 load?").
func (s *systemSettingService) preload(ctx context.Context) {
rows, err := s.repo.List(ctx)
if err != nil {
logger.Warnf(ctx, "[system_settings] preload failed, falling back to per-request DB reads: %v", err)
return
}
s.mu.Lock()
for _, row := range rows {
s.cache[row.Key] = row
}
s.mu.Unlock()
// Backfill: any registry key that doesn't yet have a DB row gets
// inserted now with its built-in default. This makes the in-code
// `registry` map the single source of truth — adding a new tunable
// is a code change, no migration required, and the management UI
// surfaces it the next time the server boots. Idempotent: existing
// rows are never touched (Upsert would, but we skip when present).
s.seedMissingFromRegistry(ctx)
s.loaded.Store(true)
s.mu.RLock()
loadedCount := len(s.cache)
s.mu.RUnlock()
logger.Infof(ctx, "[system_settings] cache loaded %d keys (instance=%s)", loadedCount, s.instanceID[:8])
// Side-effect bridges: any setting whose live value affects an
// in-process subsystem needs to be pushed there after preload, so
// the subsystem doesn't lag the cache by a full request cycle.
// Add new bridges here as more env vars get migrated.
s.applySSRFWhitelist(ctx)
}
// seedMissingFromRegistry inserts a default row for every registry key
// that doesn't already exist in the DB. Called from preload after the
// initial List, so that:
//
// - New deployments (empty table) get every key seeded automatically.
// - Existing deployments where a new key was added in code (without a
// migration) automatically pick it up on the next server start.
// - Hand-deleted rows are restored on next start (mild self-healing).
//
// Critically, this DOES NOT touch existing rows — operator edits via UI
// are preserved. Errors per-key are logged but never block other keys
// or fail the boot. The s.cache mutation runs under the write lock so a
// reader landing in the middle of seeding still sees a consistent view.
func (s *systemSettingService) seedMissingFromRegistry(ctx context.Context) {
for key, spec := range registry {
s.mu.RLock()
_, exists := s.cache[key]
s.mu.RUnlock()
if exists {
continue
}
encoded, err := encodeDefault(spec)
if err != nil {
logger.Warnf(ctx, "[system_settings] cannot encode default for %q: %v", key, err)
continue
}
category := spec.Category
if category == "" {
category = "general"
}
row := &types.SystemSetting{
Key: key,
Value: encoded,
ValueType: spec.Type,
Category: category,
Description: spec.Description,
IsSecret: false, // P3+ may flip via spec; today every seed row is non-secret
RequiresRestart: false,
LastModifiedBy: "", // empty = "seeded by system"
}
if err := s.repo.Upsert(ctx, row); err != nil {
logger.Warnf(ctx, "[system_settings] seed %q failed: %v", key, err)
continue
}
// Read back so we see DB-assigned id / timestamps (and so the
// cache entry round-trips through the same JSON shape as a
// hand-edited row would).
persisted, err := s.repo.Get(ctx, key)
if err != nil || persisted == nil {
persisted = row
}
s.mu.Lock()
s.cache[key] = persisted
s.mu.Unlock()
logger.Infof(ctx, "[system_settings] seeded missing key %q (type=%s, category=%s)",
key, spec.Type, category)
}
}
// encodeDefault produces the JSONB encoding for a spec's built-in
// default. Mirrors encodeForType but operates on already-typed Go
// values from registry so we never have to round-trip through `any`
// type assertions on the seed path. Returns an error when spec.Default
// is missing or its Go type doesn't match spec.Type — that's a code
// bug in the registry entry, surface it loudly rather than silently
// seeding the wrong shape.
func encodeDefault(spec settingSpec) (types.JSON, error) {
switch spec.Type {
case "int":
var n int64
switch v := spec.Default.(type) {
case int:
n = int64(v)
case int64:
n = v
case float64:
n = int64(v)
default:
return nil, fmt.Errorf("registry spec for int has wrong default type %T", spec.Default)
}
b, _ := json.Marshal(n)
return types.JSON(b), nil
case "string":
v, ok := spec.Default.(string)
if !ok {
return nil, fmt.Errorf("registry spec for string has wrong default type %T", spec.Default)
}
b, _ := json.Marshal(v)
return types.JSON(b), nil
case "bool":
v, ok := spec.Default.(bool)
if !ok {
return nil, fmt.Errorf("registry spec for bool has wrong default type %T", spec.Default)
}
b, _ := json.Marshal(v)
return types.JSON(b), nil
case "string_list":
switch v := spec.Default.(type) {
case []string:
if v == nil {
v = []string{}
}
b, _ := json.Marshal(v)
return types.JSON(b), nil
case nil:
return types.JSON(`[]`), nil
default:
return nil, fmt.Errorf("registry spec for string_list has wrong default type %T", spec.Default)
}
default:
return nil, errors.New("unknown declared type: " + spec.Type)
}
}
// reload re-fetches a single key from DB and updates the cache. Called
// from the pubsub subscriber loop after another replica publishes a
// change. A repo.Get(nil) result removes the entry — the row must have
// been deleted by an out-of-band tool (P1 has no Delete endpoint, but
// hand-edits still work).
func (s *systemSettingService) reload(ctx context.Context, key string) {
row, err := s.repo.Get(ctx, key)
if err != nil {
logger.Warnf(ctx, "[system_settings] reload %q failed: %v", key, err)
return
}
s.mu.Lock()
if row == nil {
delete(s.cache, key)
} else {
s.cache[key] = row
}
s.mu.Unlock()
// Push any side-effect bridges for the changed key. Bridges are
// idempotent — calling them on every reload (even when the change
// is to a different key) is fine and lets us avoid plumbing a
// per-key dispatch table.
s.dispatchSideEffects(ctx, key)
}
// dispatchSideEffects fans out post-Update / post-reload work to
// subsystems whose state depends on a system_setting. Each bridge
// looks up its own keys and decides whether to act — this keeps the
// dispatcher trivial as we add more.
func (s *systemSettingService) dispatchSideEffects(ctx context.Context, changedKey string) {
switch changedKey {
case "ssrf.whitelist":
s.applySSRFWhitelist(ctx)
}
}
// applySSRFWhitelist resolves the active ssrf.whitelist via the 3-tier
// resolver and pushes the result (merged with SSRF_WHITELIST_EXTRA)
// to utils.SetSSRFWhitelistFromRaw. SSRF_WHITELIST_EXTRA stays env-only:
// it's typically set by docker-compose / k8s for sidecar service names
// and shouldn't be subject to UI accidents.
//
// Called at preload (initial sync), after Update (this replica's edit),
// and after reload (peer's edit via pubsub).
func (s *systemSettingService) applySSRFWhitelist(ctx context.Context) {
list := s.GetStringList(ctx, "ssrf.whitelist", "SSRF_WHITELIST", []string{})
primary := strings.Join(list, ",")
extra := strings.TrimSpace(os.Getenv("SSRF_WHITELIST_EXTRA"))
merged := primary
if extra != "" {
if merged == "" {
merged = extra
} else {
merged = merged + "," + extra
}
}
utils.SetSSRFWhitelistFromRaw(merged)
logger.Infof(ctx, "[system_settings] SSRF whitelist applied (%d primary entries, extra=%v)",
len(list), extra != "")
}
// publishChange fans the change out to peers. Best-effort: a Redis
// outage logs a warning but does not fail the Update — the DB write
// already succeeded and our local cache is up-to-date. Other replicas
// will pick up the new value on their next preload (e.g. restart) or
// when their own resolver detects a stale cache via fallback.
func (s *systemSettingService) publishChange(ctx context.Context, key string) {
if s.rdb == nil {
return
}
payload, err := json.Marshal(changeMessage{Key: key, OriginID: s.instanceID})
if err != nil {
logger.Warnf(ctx, "[system_settings] marshal change for %q: %v", key, err)
return
}
pubCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := s.rdb.Publish(pubCtx, pubsubChannel(), payload).Err(); err != nil {
logger.Warnf(ctx, "[system_settings] publish %q: %v", key, err)
}
}
// NewSystemSettingService is the dig provider. audit may be nil
// (matches the tenantMemberService convention — tests that don't care
// about audit can pass nil and emitAudit no-ops).
//
// Compatibility shim: the real ctor lives above (with rdb). Keeping this
// alternate signature would break dig (two providers for one type), so
// it is intentionally NOT exported separately. Tests that don't have a
// Redis client should pass nil — the service detects nil and degrades
// to the P1 "no cache, no pubsub" path.
// resolveRaw runs the 3-tier fallback ladder for an arbitrary key and
// returns either the raw DB value bytes (when present), or nil with
// the boolean fromDB=false to signal the caller to consult ENV / default.
//
// P2: cache-first. If the preload finished and the cache has an entry
// for this key, return it. Cache misses (key absent) are AUTHORITATIVE
// when loaded.IsTrue — preload populated every existing row, and any
// subsequent Update would have updated the cache inline. So a miss
// after preload means the row genuinely doesn't exist and we should
// skip the DB query entirely. Before preload finishes we still consult
// the DB to avoid a "cold-start serves defaults" surprise.
//
// Errors at the DB layer degrade to ENV/default with a warning log —
// upstream business code (file upload, etc.) gets a usable answer
// instead of a 500. This is the deliberate degradation policy spelled
// out in the interface comment.
func (s *systemSettingService) resolveRaw(ctx context.Context, key string) (raw types.JSON, fromDB bool) {
if s.loaded.Load() {
s.mu.RLock()
row, ok := s.cache[key]
s.mu.RUnlock()
if ok && row != nil {
return row.Value, true
}
// Cache populated and key not present → authoritative miss.
return nil, false
}
// Pre-warmup path: hit the DB so a request that lands in the
// startup window doesn't get the env/default surprise.
row, err := s.repo.Get(ctx, key)
if err != nil {
logger.Warnf(ctx, "[system_settings] resolve %q failed, falling through to env/default: %v", key, err)
return nil, false
}
if row == nil {
return nil, false
}
return row.Value, true
}
// GetInt resolves an int64 setting. Priority: DB > ENV > def. Returns
// def on every error path so business code never has to handle the
// "the settings store is broken" case.
func (s *systemSettingService) GetInt(ctx context.Context, key string, envName string, def int64) int64 {
if raw, ok := s.resolveRaw(ctx, key); ok {
// Try canonical number form first.
var n int64
if err := json.Unmarshal(raw, &n); err == nil {
return n
}
// Tolerate `"42"` so hand-edited rows still work.
var quoted string
if err := json.Unmarshal(raw, &quoted); err == nil {
if v, err := strconv.ParseInt(quoted, 10, 64); err == nil {
return v
}
}
logger.Warnf(ctx, "[system_settings] %q: cannot parse %s as int, falling back", key, string(raw))
}
if envName != "" {
if v := os.Getenv(envName); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
}
return def
}
// GetString resolves a string setting. Same priority + degradation as GetInt.
func (s *systemSettingService) GetString(ctx context.Context, key string, envName string, def string) string {
if raw, ok := s.resolveRaw(ctx, key); ok {
var v string
if err := json.Unmarshal(raw, &v); err == nil {
return v
}
logger.Warnf(ctx, "[system_settings] %q: cannot parse %s as string, falling back", key, string(raw))
}
if envName != "" {
if v := os.Getenv(envName); v != "" {
return v
}
}
return def
}
// GetBool resolves a bool setting. Tolerates legacy ENV values like
// "1", "0", "yes", "no" via strconv.ParseBool. Same priority + degradation.
func (s *systemSettingService) GetBool(ctx context.Context, key string, envName string, def bool) bool {
if raw, ok := s.resolveRaw(ctx, key); ok {
var v bool
if err := json.Unmarshal(raw, &v); err == nil {
return v
}
logger.Warnf(ctx, "[system_settings] %q: cannot parse %s as bool, falling back", key, string(raw))
}
if envName != "" {
if v := os.Getenv(envName); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
}
return def
}
// GetStringList resolves a []string setting. Priority: DB > ENV > def.
//
// At the ENV level the value is parsed as a comma-separated string
// (matches the legacy SSRF_WHITELIST format and means operators don't
// have to learn a new convention to migrate). Whitespace around each
// entry is trimmed; empty entries are dropped. The returned slice is
// always non-nil so callers can iterate without a nil check.
//
// Same degradation policy as the other Get*: a DB-layer error logs a
// warning and falls through to ENV/default, so consumer paths
// (SSRF check, etc.) never have to handle "settings store broken".
func (s *systemSettingService) GetStringList(ctx context.Context, key string, envName string, def []string) []string {
if raw, ok := s.resolveRaw(ctx, key); ok {
var v []string
if err := json.Unmarshal(raw, &v); err == nil {
if v == nil {
v = []string{}
}
return v
}
logger.Warnf(ctx, "[system_settings] %q: cannot parse %s as string_list, falling back", key, string(raw))
}
if envName != "" {
if raw := os.Getenv(envName); raw != "" {
out := make([]string, 0, 4)
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry != "" {
out = append(out, entry)
}
}
return out
}
}
if def == nil {
return []string{}
}
return def
}
// List returns all rows for the management UI. Pass-through to repo,
// then enriched with the in-code registry's `Enum` so the UI can render
// a select. Rows whose key isn't in the registry (out-of-band hand-edits)
// pass through untouched — UI will fall back to a free-form input.
func (s *systemSettingService) List(ctx context.Context) ([]*types.SystemSetting, error) {
rows, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
for _, r := range rows {
if spec, ok := registry[r.Key]; ok {
r.Enum = spec.Enum
}
}
return rows, nil
}
// Get returns one row by key. Used by the management UI's "load before
// edit" pattern. Returns (nil, nil) when missing (unknown-key handling
// is done at the handler layer for nicer 404 vs 200-with-default UX).
//
// Enriches the row with registry-side `Enum` for the same UI reason
// as List.
func (s *systemSettingService) Get(ctx context.Context, key string) (*types.SystemSetting, error) {
spec, ok := registry[key]
if !ok {
return nil, fmt.Errorf("unknown setting key %q", key)
}
row, err := s.repo.Get(ctx, key)
if err != nil {
return nil, err
}
if row != nil {
row.Enum = spec.Enum
}
return row, nil
}
// Update validates and persists a new value. Steps:
// 1. Look up the registry spec — reject unknown keys with 400 semantics.
// 2. Coerce + validate the rawValue against spec.Type. Numeric inputs
// from JSON unmarshalling arrive as float64; we accept both int64
// and float64 for "int" and round-trip through strconv to surface
// rejection of e.g. floats like 3.14 cleanly.
// 3. Build the SystemSetting row, write via repo.Upsert.
// 4. Emit an audit log carrying old + new values for forensics.
//
// Returns the persisted row (re-read from DB so updated_at /
// last_modified_by are fresh).
func (s *systemSettingService) Update(ctx context.Context, key string, rawValue any) (*types.SystemSetting, error) {
spec, ok := registry[key]
if !ok {
return nil, fmt.Errorf("unknown setting key %q", key)
}
encoded, err := encodeForType(spec.Type, rawValue)
if err != nil {
return nil, fmt.Errorf("invalid value for %q (expected %s): %w", key, spec.Type, err)
}
// Enum check: only meaningful for "string". Compare the decoded
// string against the registry-declared whitelist. Done after
// encodeForType so we know the raw value passed type validation.
if len(spec.Enum) > 0 && spec.Type == "string" {
s, _ := rawValue.(string)
allowed := false
for _, opt := range spec.Enum {
if s == opt {
allowed = true
break
}
}
if !allowed {
return nil, fmt.Errorf("invalid value for %q: %q not in %v", key, s, spec.Enum)
}
}
// Capture pre-image for the audit log — pulled fresh, not from
// any cache, so concurrent admin edits race-fairly (last writer
// wins, audit reflects what was actually replaced).
prev, _ := s.repo.Get(ctx, key)
var oldValue types.JSON
var category, description string
var isSecret, requiresRestart bool
if prev != nil {
oldValue = prev.Value
category = prev.Category
description = prev.Description
isSecret = prev.IsSecret
requiresRestart = prev.RequiresRestart
} else {
// First-write path: derive category/description from registry
// so the row matches the seeded migration shape. Operators can
// hand-edit description in the DB if they want richer copy.
category = spec.Category
if category == "" {
category = "general"
}
description = spec.Description
}
row := &types.SystemSetting{
Key: key,
Value: encoded,
ValueType: spec.Type,
Category: category,
Description: description,
IsSecret: isSecret,
RequiresRestart: requiresRestart,
LastModifiedBy: auditActor(ctx),
}
if err := s.repo.Upsert(ctx, row); err != nil {
return nil, fmt.Errorf("upsert system setting %q: %w", key, err)
}
// Re-read so caller sees DB-side defaults (id, updated_at) populated.
persisted, err := s.repo.Get(ctx, key)
if err != nil || persisted == nil {
// Don't fail the operation just because the read-back hiccuped —
// the upsert already succeeded. Return the optimistic value.
persisted = row
}
// Update local cache inline so this replica's next read sees the
// new value without waiting for the pubsub roundtrip. Other replicas
// pick it up via publishChange below.
s.mu.Lock()
s.cache[key] = persisted
s.mu.Unlock()
// Push to side-effect bridges (e.g. utils.SetSSRFWhitelistFromRaw).
s.dispatchSideEffects(ctx, key)
s.publishChange(ctx, key)
s.emitChangeAudit(ctx, key, spec.Type, oldValue, encoded)
return persisted, nil
}
// SubscribeRedis starts a single goroutine that subscribes to the
// pubsub channel and refreshes the local cache when peers publish
// changes. Idempotent (subOnce). When Redis is nil (lite mode) returns
// nil immediately — single-replica deployments don't need pubsub
// because Update already writes the local cache inline.
//
// The subscriber loop runs until ctx is cancelled (server shutdown).
// On Redis disconnection we reconnect with exponential backoff up to
// 30s, mirroring the approval/gate.go convention so operators see the
// same recovery behaviour across pubsub-using subsystems.
func (s *systemSettingService) SubscribeRedis(ctx context.Context) error {
if s.rdb == nil {
logger.Infof(ctx, "[system_settings] Redis not configured, skipping pubsub (single-replica mode)")
return nil
}
s.subOnce.Do(func() {
go s.runSubscribeLoop(ctx)
})
return nil
}
// runSubscribeLoop is the long-running goroutine spawned by
// SubscribeRedis. Reconnects on transient errors; exits on ctx.Done().
func (s *systemSettingService) runSubscribeLoop(ctx context.Context) {
channel := pubsubChannel()
logger.Infof(ctx, "[system_settings] subscribed to %s (instance=%s)", channel, s.instanceID[:8])
const maxBackoff = 30 * time.Second
backoff := time.Second
for {
// ctx may already be cancelled (server shutting down before
// pubsub became active).
if ctx.Err() != nil {
return
}
sub := s.rdb.Subscribe(ctx, channel)
// Verify the subscription is active so a publish-and-disconnect
// race doesn't silently drop the first message.
if _, err := sub.Receive(ctx); err != nil {
logger.Warnf(ctx, "[system_settings] subscribe %s: %v (retry in %s)", channel, err, backoff)
_ = sub.Close()
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
continue
}
backoff = time.Second // reset after a healthy connection
ch := sub.Channel()
s.consumeMessages(ctx, ch)
_ = sub.Close()
// consumeMessages returns either because ctx is done or the
// subscription was torn down; loop back and try again.
}
}
// consumeMessages drains the pubsub channel, dispatching to reload()
// for every key the peer says changed. Returns when the channel
// closes (Redis disconnect) or ctx is done.
func (s *systemSettingService) consumeMessages(ctx context.Context, ch <-chan *redis.Message) {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
var m changeMessage
if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
logger.Warnf(ctx, "[system_settings] bad pubsub payload: %v", err)
continue
}
// Skip our own publish — the local cache is already fresh
// (Update wrote it inline). Without this every Update would
// trigger a redundant DB roundtrip on the publishing replica.
if m.OriginID == s.instanceID {
continue
}
s.reload(ctx, m.Key)
}
}
}
// emitChangeAudit writes one audit row per successful Update. Best-
// effort — a nil audit service or a write failure does not bubble up.
// This mirrors tenantMemberService.emitAudit's failure semantics: the
// business op (config update) succeeds even if audit is broken.
func (s *systemSettingService) emitChangeAudit(
ctx context.Context, key, valueType string, oldValue, newValue types.JSON,
) {
if s.audit == nil {
return
}
details, _ := json.Marshal(map[string]any{
"key": key,
"value_type": valueType,
"old_value": json.RawMessage(oldValue),
"new_value": json.RawMessage(newValue),
})
_ = s.audit.Log(ctx, &types.AuditLog{
// tenant_id=0 marks the row as system-scope (the audit_logs
// table itself is tenant-scoped; 0 is the convention for
// platform-wide events).
TenantID: 0,
ActorUserID: auditActor(ctx),
ActorRole: "system_admin",
Action: types.AuditActionSystemSettingChanged,
TargetType: "system_setting",
TargetID: key,
Outcome: types.AuditOutcomeSuccess,
Details: types.JSON(details),
})
}
// encodeForType validates rawValue against the declared type and
// returns the canonical JSON encoding for the DB. Rejects type
// mismatches (e.g. passing "abc" for an int field) with a clear error
// the handler can surface to the UI.
func encodeForType(declared string, rawValue any) (types.JSON, error) {
switch declared {
case "int":
var n int64
switch v := rawValue.(type) {
case int:
n = int64(v)
case int32:
n = int64(v)
case int64:
n = v
case float64:
// JSON unmarshalling delivers numbers as float64; reject
// non-integer floats (e.g. 3.14) cleanly rather than
// silently truncating.
if v != float64(int64(v)) {
return nil, fmt.Errorf("expected integer, got %v", v)
}
n = int64(v)
case string:
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("expected integer, got %q", v)
}
n = parsed
default:
return nil, fmt.Errorf("expected integer, got %T", rawValue)
}
b, _ := json.Marshal(n)
return types.JSON(b), nil
case "string":
v, ok := rawValue.(string)
if !ok {
return nil, fmt.Errorf("expected string, got %T", rawValue)
}
b, _ := json.Marshal(v)
return types.JSON(b), nil
case "bool":
v, ok := rawValue.(bool)
if !ok {
return nil, fmt.Errorf("expected bool, got %T", rawValue)
}
b, _ := json.Marshal(v)
return types.JSON(b), nil
case "string_list":
// Accept either a JSON array of strings (the canonical UI shape
// — t-tag-input emits string[]) or a single comma-separated
// string (operator pasting from a legacy ENV value). Reject
// arrays containing non-strings to avoid silently coercing
// `[1, 2]` into `["1", "2"]` — that hides typos.
var entries []string
switch v := rawValue.(type) {
case []any:
entries = make([]string, 0, len(v))
for i, item := range v {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("expected string at index %d, got %T", i, item)
}
s = strings.TrimSpace(s)
if s != "" {
entries = append(entries, s)
}
}
case []string:
entries = make([]string, 0, len(v))
for _, s := range v {
s = strings.TrimSpace(s)
if s != "" {
entries = append(entries, s)
}
}
case string:
for _, s := range strings.Split(v, ",") {
s = strings.TrimSpace(s)
if s != "" {
entries = append(entries, s)
}
}
if entries == nil {
entries = []string{}
}
default:
return nil, fmt.Errorf("expected string array, got %T", rawValue)
}
b, _ := json.Marshal(entries)
return types.JSON(b), nil
default:
return nil, errors.New("unknown declared type: " + declared)
}
}
+9
View File
@@ -501,6 +501,15 @@ func (s *userService) UpdateUser(ctx context.Context, user *types.User) error {
return s.userRepo.UpdateUser(ctx, user)
}
// ListSystemAdmins lists users with IsSystemAdmin=true. Thin pass-through
// to the repository; the handler enforces SystemAdmin gating, so the
// service does not duplicate the role check here.
func (s *userService) ListSystemAdmins(
ctx context.Context, offset, limit int,
) ([]*types.User, int64, error) {
return s.userRepo.ListSystemAdmins(ctx, offset, limit)
}
// UpdateUserPreferences applies a partial update over the user's
// preferences blob. PATCH semantics: only keys present in `patch`
// (non-nil pointer fields) replace the existing value; everything else
+2
View File
@@ -147,6 +147,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(repository.NewModelRepository))
must(container.Provide(repository.NewUserRepository))
must(container.Provide(repository.NewAuthTokenRepository))
must(container.Provide(repository.NewSystemSettingRepository))
must(container.Provide(neo4jRepo.NewNeo4jRepository))
must(container.Provide(memoryRepo.NewMemoryRepository))
must(container.Provide(repository.NewMCPServiceRepository))
@@ -188,6 +189,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(service.NewDatasetService))
must(container.Provide(service.NewEvaluationService))
must(container.Provide(service.NewUserService))
must(container.Provide(service.NewSystemSettingService))
must(container.Provide(service.NewWeKnoraCloudService))
// Extract services - register individual extracters with names
+53 -18
View File
@@ -23,19 +23,28 @@ import (
// Provides functionality for user registration, login, logout, and token management
// through the REST API endpoints
type AuthHandler struct {
userService interfaces.UserService
tenantService interfaces.TenantService
configInfo *config.Config
userService interfaces.UserService
tenantService interfaces.TenantService
configInfo *config.Config
systemSettingSvc interfaces.SystemSettingService
}
// NewAuthHandler creates a new auth handler instance with the provided services
// Parameters:
// - userService: An implementation of the UserService interface for business logic
// - tenantService: An implementation of the TenantService interface for tenant management
// - systemSettingSvc: 3-tier resolver for runtime-tunable settings such as
// auth.registration_mode (P3). When DB has a row, it overrides cfg's
// startup value; otherwise we fall back to cfg.Auth.RegistrationMode
// (which already accounted for the legacy DISABLE_REGISTRATION env coerce
// during config load). Mismatch impossible by construction since the
// handler always passes cfg's value as the def parameter to GetString.
//
// Returns a pointer to the newly created AuthHandler
func NewAuthHandler(configInfo *config.Config,
userService interfaces.UserService, tenantService interfaces.TenantService) *AuthHandler {
userService interfaces.UserService, tenantService interfaces.TenantService,
systemSettingSvc interfaces.SystemSettingService,
) *AuthHandler {
// Boot-time guard: a nil-or-empty Auth section silently disables the
// invite_only gate (see Register below). Emit a loud one-shot log
// pointing at the misconfiguration so operators notice on startup
@@ -47,12 +56,40 @@ func NewAuthHandler(configInfo *config.Config,
configInfo)
}
return &AuthHandler{
configInfo: configInfo,
userService: userService,
tenantService: tenantService,
configInfo: configInfo,
userService: userService,
tenantService: tenantService,
systemSettingSvc: systemSettingSvc,
}
}
// resolveRegistrationMode returns the currently active registration mode.
// Priority: DB system_settings > cfg (which already absorbed the legacy
// DISABLE_REGISTRATION env coerce at startup) > "self_serve" hard default.
//
// Centralised here so /auth/register and /auth/config stay in lock-step —
// otherwise a SystemAdmin's UI edit could affect one path and not the other.
func (h *AuthHandler) resolveRegistrationMode(ctx context.Context) string {
// cfg-derived default: empty is impossible after applyAuthAndTenantDefaults,
// but be defensive in case AuthHandler was constructed before that ran
// (the NewAuthHandler guard already logged in that case).
def := config.AuthRegistrationModeSelfServe
if h.configInfo != nil && h.configInfo.Auth != nil {
if m := strings.TrimSpace(h.configInfo.Auth.RegistrationMode); m != "" {
def = m
}
}
if h.systemSettingSvc == nil {
return def
}
// envName = "" because DISABLE_REGISTRATION is a boolean and
// auth.registration_mode is a string — the legacy env was already
// coerced into `def` above. Mixing the two semantics at the resolver
// layer would mean a UI delete (DB row absent) silently flipped to
// the legacy boolean read again, which is surprising.
return h.systemSettingSvc.GetString(ctx, "auth.registration_mode", "", def)
}
// Register godoc
// @Summary 用户注册
// @Description 注册新用户账号
@@ -70,11 +107,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
logger.Info(ctx, "Start user registration")
// 当 auth.registration_mode=invite_only 时,public 注册被关闭。
// 新成员只能由 Owner 通过 /tenants/:id/members 添加(PR 3 of #1303
// 前端在 PR 1 已经会读 /auth/config 隐藏注册入口;这里是直接 API 调用的兜底。
// 历史变量 DISABLE_REGISTRATION=true 在 config 启动阶段已被等价提升为
// invite_only,因此这里只剩一条 gate。
if h.configInfo != nil && h.configInfo.Auth != nil && h.configInfo.Auth.IsInviteOnly() {
// 优先级:DB system_settings > cfg.Auth.RegistrationMode > "self_serve"
// SystemAdmin 通过「全局设置」UI 实时切换 self_serve / invite_only,立即
// 生效,不需要重启服务。历史变量 DISABLE_REGISTRATION=true 在 config
// 启动阶段被等价提升为 invite_onlyapplyAuthAndTenantDefaults),
// 作为 cfg-default 进入 resolveRegistrationMode。
if h.resolveRegistrationMode(ctx) == config.AuthRegistrationModeInviteOnly {
logger.Warn(ctx, "Registration rejected: auth.registration_mode=invite_only")
appErr := errors.NewForbiddenError("Registration is invite-only")
c.Error(appErr)
@@ -612,12 +650,9 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
// only what the UI strictly needs (registration_mode); other config
// stays internal.
func (h *AuthHandler) GetAuthConfig(c *gin.Context) {
mode := config.AuthRegistrationModeSelfServe
if h.configInfo != nil && h.configInfo.Auth != nil {
if m := strings.TrimSpace(h.configInfo.Auth.RegistrationMode); m != "" {
mode = m
}
}
// Same source-of-truth as Register's gate, so the UI hide-the-button
// signal can never disagree with the API enforcement signal.
mode := h.resolveRegistrationMode(c.Request.Context())
c.JSON(http.StatusOK, gin.H{
"success": true,
"registration_mode": mode,
@@ -89,7 +89,7 @@ func TestRegister_InviteOnlyRejects(t *testing.T) {
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeInviteOnly},
}, us, nil)
}, us, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusForbidden {
@@ -114,7 +114,7 @@ func TestRegister_SelfServeAllowsRegistration(t *testing.T) {
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeSelfServe},
}, us, nil)
}, us, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
@@ -135,7 +135,7 @@ func TestRegister_NilAuthConfigDoesNotPanic(t *testing.T) {
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{}, us, nil)
h := NewAuthHandler(&config.Config{}, us, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
+7 -3
View File
@@ -61,6 +61,7 @@ type InitializationHandler struct {
ollamaService *ollama.OllamaService
documentReader interfaces.DocumentReader
pooler embedding.EmbedderPooler
systemSettingSvc interfaces.SystemSettingService
}
// NewInitializationHandler 创建初始化处理器
@@ -74,6 +75,7 @@ func NewInitializationHandler(
ollamaService *ollama.OllamaService,
documentReader interfaces.DocumentReader,
pooler embedding.EmbedderPooler,
systemSettingSvc interfaces.SystemSettingService,
) *InitializationHandler {
return &InitializationHandler{
config: config,
@@ -85,6 +87,7 @@ func NewInitializationHandler(
ollamaService: ollamaService,
documentReader: documentReader,
pooler: pooler,
systemSettingSvc: systemSettingSvc,
}
}
@@ -2120,11 +2123,12 @@ func (h *InitializationHandler) TestMultimodalFunction(c *gin.Context) {
return
}
// 验证文件大小 (default 50MB, configurable via MAX_FILE_SIZE_MB)
maxSize := utils.GetMaxFileSize()
// 验证文件大小 — 走 system_settings 三级 resolverDB > ENV > 50 默认)
maxSizeMB := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
maxSize := maxSizeMB * 1024 * 1024
if header.Size > maxSize {
logger.Error(ctx, "File size too large")
c.Error(errors.NewBadRequestError(fmt.Sprintf("图片文件大小不能超过%dMB", utils.GetMaxFileSizeMB())))
c.Error(errors.NewBadRequestError(fmt.Sprintf("图片文件大小不能超过%dMB", maxSizeMB)))
return
}
logger.Infof(ctx, "Processing image: %s", utils.SanitizeForLog(header.Filename))
+11 -3
View File
@@ -34,6 +34,9 @@ type KnowledgeHandler struct {
kbShareService interfaces.KBShareService
agentShareService interfaces.AgentShareService
asynqClient interfaces.TaskEnqueuer
// systemSettingSvc consults the platform-wide system_settings table
// for runtime tunables (file size limit etc.), with ENV/default fallback.
systemSettingSvc interfaces.SystemSettingService
}
// NewKnowledgeHandler creates a new knowledge handler instance
@@ -43,6 +46,7 @@ func NewKnowledgeHandler(
kbShareService interfaces.KBShareService,
agentShareService interfaces.AgentShareService,
asynqClient interfaces.TaskEnqueuer,
systemSettingSvc interfaces.SystemSettingService,
) *KnowledgeHandler {
return &KnowledgeHandler{
kgService: kgService,
@@ -50,6 +54,7 @@ func NewKnowledgeHandler(
kbShareService: kbShareService,
agentShareService: agentShareService,
asynqClient: asynqClient,
systemSettingSvc: systemSettingSvc,
}
}
@@ -266,11 +271,14 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
return
}
// Validate file size (configurable via MAX_FILE_SIZE_MB)
maxSize := secutils.GetMaxFileSize()
// Validate file size — 3-tier resolver (DB > ENV > 50 MB default).
// SystemAdmin can update this via the global-settings UI and the
// new value applies on the very next upload (no restart needed).
maxSizeMB := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
maxSize := maxSizeMB * 1024 * 1024
if file.Size > maxSize {
logger.Error(ctx, "File size too large")
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", secutils.GetMaxFileSizeMB())))
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", maxSizeMB)))
return
}
+3
View File
@@ -28,6 +28,7 @@ type Handler struct {
fileService interfaces.FileService // Service for file storage (image uploads)
modelService interfaces.ModelService // Service for model management (VLM access)
userService interfaces.UserService // Service for resolving per-user preferences (e.g. enable_memory default)
systemSettingSvc interfaces.SystemSettingService // 3-tier resolver for runtime tunables (file size limit etc.)
attachmentProcessor *AttachmentProcessor // Processor for file attachments
}
@@ -47,6 +48,7 @@ func NewHandler(
userService interfaces.UserService,
documentReader interfaces.DocumentReader,
imageResolver *docparser.ImageResolver,
systemSettingSvc interfaces.SystemSettingService,
) *Handler {
return &Handler{
sessionService: sessionService,
@@ -61,6 +63,7 @@ func NewHandler(
fileService: fileService,
modelService: modelService,
userService: userService,
systemSettingSvc: systemSettingSvc,
attachmentProcessor: NewAttachmentProcessor(
fileService,
documentReader,
+5 -2
View File
@@ -170,11 +170,14 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
if len(request.AttachmentUploads) > 0 {
logger.Infof(ctx, "[%s] processing %d attachment(s)", logPrefix, len(request.AttachmentUploads))
maxSize := secutils.GetMaxFileSize()
// 3-tier resolver: DB > ENV > 50MB. Edits via the system-admin
// settings UI take effect on the very next request.
maxSizeMB := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
maxSize := maxSizeMB * 1024 * 1024
for i, upload := range request.AttachmentUploads {
if upload.FileSize > maxSize {
return nil, nil, errors.NewBadRequestError(
fmt.Sprintf("attachment %d exceeds size limit of %dMB", i+1, secutils.GetMaxFileSizeMB()))
fmt.Sprintf("attachment %d exceeds size limit of %dMB", i+1, maxSizeMB))
}
}
+337 -8
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/application/service"
@@ -25,10 +27,12 @@ import (
// SystemHandler handles system-related requests
type SystemHandler struct {
cfg *config.Config
neo4jDriver neo4j.Driver
documentReader interfaces.DocumentReader
tenantSvc interfaces.TenantService
cfg *config.Config
neo4jDriver neo4j.Driver
documentReader interfaces.DocumentReader
tenantSvc interfaces.TenantService
userSvc interfaces.UserService
systemSettingSvc interfaces.SystemSettingService
}
// NewSystemHandler creates a new system handler
@@ -36,12 +40,16 @@ func NewSystemHandler(cfg *config.Config,
neo4jDriver neo4j.Driver,
documentReader interfaces.DocumentReader,
tenantSvc interfaces.TenantService,
userSvc interfaces.UserService,
systemSettingSvc interfaces.SystemSettingService,
) *SystemHandler {
return &SystemHandler{
cfg: cfg,
neo4jDriver: neo4jDriver,
documentReader: documentReader,
tenantSvc: tenantSvc,
cfg: cfg,
neo4jDriver: neo4jDriver,
documentReader: documentReader,
tenantSvc: tenantSvc,
userSvc: userSvc,
systemSettingSvc: systemSettingSvc,
}
}
@@ -1006,3 +1014,324 @@ func (h *SystemHandler) ResolveDocumentReader(ctx context.Context, addr string)
}
return reader
}
// PromoteUserToSystemAdminRequest defines the request for promoting a user to system admin
type PromoteUserToSystemAdminRequest struct {
UserID string `json:"user_id" binding:"required"`
}
// PromoteUserToSystemAdmin godoc
// @Summary Promote a user to system administrator
// @Description Grant system administrator privileges to a user (SystemAdmin only).
// @Description Idempotent: re-promoting an existing system admin returns 200 with no DB write.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body PromoteUserToSystemAdminRequest true "User promotion request"
// @Success 200 {object} types.UserInfo "User promoted successfully"
// @Failure 400 {object} map[string]interface{} "Bad request"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/promote [post]
func (h *SystemHandler) PromoteUserToSystemAdmin(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req PromoteUserToSystemAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
user, err := h.userSvc.GetUserByID(ctx, req.UserID)
if err != nil {
logger.Errorf(ctx, "Error fetching user %s: %v", req.UserID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user.IsSystemAdmin {
// Idempotent: re-promoting an existing system admin is a no-op success.
c.JSON(http.StatusOK, user.ToUserInfo())
return
}
user.IsSystemAdmin = true
if err := h.userSvc.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "Error promoting user %s to system admin: %v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user"})
return
}
logger.Infof(ctx, "User %s (ID: %s) promoted to system admin", user.Username, user.ID)
c.JSON(http.StatusOK, user.ToUserInfo())
}
// RevokeSystemAdminRequest defines the request for revoking system admin privileges
type RevokeSystemAdminRequest struct {
UserID string `json:"user_id" binding:"required"`
}
// RevokeSystemAdmin godoc
// @Summary Revoke system administrator privileges from a user
// @Description Remove system administrator privileges from a user (SystemAdmin only).
// @Description Two safety guards: the caller cannot revoke their own privileges,
// @Description and revoking the last remaining system admin is rejected — both
// @Description prevent a SystemAdmin from accidentally locking the platform out
// @Description of system-level administration. Idempotent on already-non-admin users.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body RevokeSystemAdminRequest true "User revocation request"
// @Success 200 {object} types.UserInfo "Privileges revoked successfully"
// @Failure 400 {object} map[string]interface{} "Bad request / would remove last admin / self-revoke"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/revoke [post]
func (h *SystemHandler) RevokeSystemAdmin(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req RevokeSystemAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Self-revoke guard. Without it, a single careless click could leave a
// deployment with zero system admins and no UI path to recover —
// operators would have to set the env-var bootstrap or hand-edit the DB.
if callerID, _ := types.UserIDFromContext(ctx); callerID == req.UserID {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot revoke your own system admin privileges",
})
return
}
user, err := h.userSvc.GetUserByID(ctx, req.UserID)
if err != nil {
logger.Errorf(ctx, "Error fetching user %s: %v", req.UserID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if !user.IsSystemAdmin {
// Idempotent: revoking from a non-admin is a no-op success.
c.JSON(http.StatusOK, user.ToUserInfo())
return
}
// Last-admin guard. ListSystemAdmins is bounded to a single row here
// because we only need the total count; this stays O(1) on the
// is_system_admin index. Combined with the self-revoke guard above,
// these two checks make it impossible for a SystemAdmin to lock
// themselves out of system-level administration via this endpoint.
_, total, err := h.userSvc.ListSystemAdmins(ctx, 0, 1)
if err != nil {
logger.Errorf(ctx, "Error counting system admins for last-admin check: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify admin count"})
return
}
if total <= 1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot revoke the last remaining system administrator",
})
return
}
user.IsSystemAdmin = false
if err := h.userSvc.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "Error revoking system admin from user %s: %v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke system admin privileges"})
return
}
logger.Infof(ctx, "System admin privileges revoked from user %s (ID: %s)", user.Username, user.ID)
c.JSON(http.StatusOK, user.ToUserInfo())
}
// ListSystemAdminsResponse defines the response structure for listing system admins.
// Total reflects the underlying COUNT(*), not just the page size, so the front
// end can render pagination metadata without a follow-up call.
type ListSystemAdminsResponse struct {
Total int64 `json:"total"`
Admins []*types.UserInfo `json:"admins"`
}
// ListSystemAdmins godoc
// @Summary List all system administrators
// @Description Retrieve a paginated list of users with system administrator
// @Description privileges (SystemAdmin only). Supports `offset` (default 0)
// @Description and `limit` (default 50, max 200) query parameters. Walks the
// @Description partial-friendly idx_users_is_system_admin index.
// @Tags System Admin
// @Produce json
// @Param offset query int false "Page offset" default(0)
// @Param limit query int false "Page size (max 200)" default(50)
// @Success 200 {object} ListSystemAdminsResponse "System admins retrieved successfully"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/list [get]
func (h *SystemHandler) ListSystemAdmins(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
// Best-effort pagination parsing — a malformed `limit=foo` falls back
// to defaults rather than 400-ing, since the call is still safe and a
// failed-page is more user-hostile than a soft default.
offset := 0
limit := 50
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// Cap so a client can't ask for the entire table.
if limit > 200 {
limit = 200
}
users, total, err := h.userSvc.ListSystemAdmins(ctx, offset, limit)
if err != nil {
logger.Errorf(ctx, "Error listing system admins: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system admins"})
return
}
// Always emit a non-nil slice so the JSON serialises to `[]` rather
// than `null` for an empty page — front-end iteration is safer.
infos := make([]*types.UserInfo, 0, len(users))
for _, u := range users {
infos = append(infos, u.ToUserInfo())
}
c.JSON(http.StatusOK, ListSystemAdminsResponse{
Total: total,
Admins: infos,
})
}
// ============================================================================
// System Settings (P1)
// ----------------------------------------------------------------------------
// Endpoints below are mounted under /api/v1/system/admin/settings*, all
// gated to SystemAdmin via the route group's middleware. Every response
// is the raw model — no `gin.H{"data": ...}` wrapping — to match the
// project's axios interceptor contract (response.data is unwrapped at the
// HTTP layer; see frontend/src/utils/request.ts:97). The P0 ListSystemAdmins
// already follows this; do not break the convention.
// ============================================================================
// ListSystemSettings godoc
// @Summary List all system settings
// @Description Return every row in the system_settings table (system-scope,
// @Description not tenant-scope). SystemAdmin only.
// @Tags System Admin
// @Produce json
// @Success 200 {array} types.SystemSetting "list of settings"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/settings [get]
func (h *SystemHandler) ListSystemSettings(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
rows, err := h.systemSettingSvc.List(ctx)
if err != nil {
logger.Errorf(ctx, "list system settings failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system settings"})
return
}
if rows == nil {
// Always emit a non-nil array so the JSON serialises to `[]`
// rather than `null` on an empty table — front-end iteration
// is safer.
rows = []*types.SystemSetting{}
}
c.JSON(http.StatusOK, rows)
}
// GetSystemSetting godoc
// @Summary Get a single system setting by key
// @Description Returns the row matching :key. 404 when the key is unknown
// @Description to the registry; 200 with the row when known.
// @Tags System Admin
// @Produce json
// @Param key path string true "Setting key (e.g. file.max_size_mb)"
// @Success 200 {object} types.SystemSetting "the setting row"
// @Failure 400 {object} map[string]interface{} "Unknown key"
// @Failure 404 {object} map[string]interface{} "Key registered but DB row absent"
// @Router /system/admin/settings/{key} [get]
func (h *SystemHandler) GetSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
row, err := h.systemSettingSvc.Get(ctx, key)
if err != nil {
// Service-layer "unknown key" surfaces as a generic error here;
// distinguish via the error string rather than typed errors so
// we don't grow a sentinel package for a single error class.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "setting not yet persisted"})
return
}
c.JSON(http.StatusOK, row)
}
// UpdateSystemSettingRequest is the body for PUT /system/admin/settings/:key.
// `value` carries the new value as raw JSON — int / string / bool depending
// on the registry-declared value_type. The service validates the type
// strictly and rejects mismatches with 400.
type UpdateSystemSettingRequest struct {
// Value is intentionally `any` (decoded as float64 / string / bool /
// etc. by the JSON unmarshaller). Service.encodeForType normalises
// these against the registry's declared type and rejects mismatches.
Value any `json:"value"`
}
// UpdateSystemSetting godoc
// @Summary Update a system setting value
// @Description Persist a new value for :key. Service validates the
// @Description rawValue against the registry's declared value_type and
// @Description rejects mismatches with 400. SystemAdmin only. Emits an
// @Description audit row (action=system.setting_changed) on success.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param key path string true "Setting key"
// @Param request body UpdateSystemSettingRequest true "New value"
// @Success 200 {object} types.SystemSetting "the updated row"
// @Failure 400 {object} map[string]interface{} "Bad request / unknown key / type mismatch"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/settings/{key} [put]
func (h *SystemHandler) UpdateSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
var req UpdateSystemSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if req.Value == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "value is required"})
return
}
row, err := h.systemSettingSvc.Update(ctx, key, req.Value)
if err != nil {
// Whether this is "unknown key" / "type mismatch" / "DB error"
// is encoded in the error message at the service layer; surface
// it verbatim. UI captures it as the toast text.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
+4
View File
@@ -160,12 +160,14 @@ func Auth(
c.Set(types.UserContextKey.String(), user)
c.Set(types.UserIDContextKey.String(), user.ID)
c.Set(types.TenantRoleContextKey.String(), role)
c.Set(types.SystemAdminContextKey.String(), user.IsSystemAdmin)
ctx := c.Request.Context()
ctx = context.WithValue(ctx, types.TenantIDContextKey, targetTenantID)
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
ctx = context.WithValue(ctx, types.UserContextKey, user)
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, role)
ctx = context.WithValue(ctx, types.SystemAdminContextKey, user.IsSystemAdmin)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
@@ -237,9 +239,11 @@ func Auth(
c.Set(types.UserContextKey.String(), user)
c.Set(types.UserIDContextKey.String(), user.ID)
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin)
c.Set(types.SystemAdminContextKey.String(), user.IsSystemAdmin)
ctx = context.WithValue(ctx, types.UserContextKey, user)
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
ctx = context.WithValue(ctx, types.SystemAdminContextKey, user.IsSystemAdmin)
c.Request = c.Request.WithContext(ctx)
c.Next()
+48
View File
@@ -103,6 +103,54 @@ func RequireRole(min types.TenantRole, cfg *config.Config) gin.HandlerFunc {
}
}
// RequireSystemAdmin returns a gin middleware that aborts the request with
// HTTP 403 unless the caller is a system administrator
// (User.IsSystemAdmin = true).
//
// System administrators operate independently of tenant-scoped roles and
// are not bound by the per-tenant RBAC matrix. Use this guard for
// platform-wide administrative endpoints (managing other system admins,
// editing global settings, cross-tenant operations) where the per-tenant
// Owner/Admin/Contributor/Viewer ladder does not apply.
//
// When cfg.Tenant.EnableRBAC is false, the middleware logs the would-be
// rejection but lets the request through — preserving backward
// compatibility during rollout. Once operators flip the flag to true,
// the same code paths start rejecting unauthorised callers. SystemAdmin
// rides on the same RBAC kill-switch deliberately: ops should be able to
// disable BOTH per-tenant RBAC and system-admin gating during an
// emergency without juggling two independent flags.
func RequireSystemAdmin(cfg *config.Config) gin.HandlerFunc {
warnOnNilConfig(cfg)
return func(c *gin.Context) {
ctx := c.Request.Context()
if types.IsSystemAdminFromContext(ctx) {
c.Next()
return
}
uid, _ := types.UserIDFromContext(ctx)
if !rbacEnforcementEnabled(cfg) {
logger.Warnf(ctx,
"[rbac] system admin required (logged but not enforced): user=%s path=%s",
uid, c.Request.URL.Path)
c.Next()
return
}
logger.Warnf(ctx,
"[rbac] system admin required: user=%s path=%s",
uid, c.Request.URL.Path)
// Durable audit row for the reject — same dedup as RequireRole.
if svc := AuditServiceFromContext(c); svc != nil {
tenantID, _ := types.TenantIDFromContext(ctx)
_ = svc.LogDenied(ctx, c, tenantID, uid, "user", "system_admin")
}
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: system administrator required",
})
c.Abort()
}
}
// RequireOwnershipOrRole guards endpoints whose access is allowed for
// either (a) callers whose role is at least min, or (b) the original
// creator of the resource being touched.
+4
View File
@@ -198,6 +198,10 @@ func (g *rbacGuards) Owner() gin.HandlerFunc {
return middleware.RequireRole(types.TenantRoleOwner, g.cfg)
}
func (g *rbacGuards) SystemAdmin() gin.HandlerFunc {
return middleware.RequireSystemAdmin(g.cfg)
}
// Ownership-or-role guards. Required role here is the privilege level
// that bypasses the ownership check; Contributors ALWAYS pass when they
// own the resource.
+33
View File
@@ -189,6 +189,7 @@ func NewRouter(params RouterParams) *gin.Engine {
RegisterEvaluationRoutes(v1, params.EvaluationHandler, rbacGuards)
RegisterInitializationRoutes(v1, params.InitializationHandler, rbacGuards)
RegisterSystemRoutes(v1, params.SystemHandler, rbacGuards)
RegisterSystemAdminRoutes(v1, params.SystemHandler, rbacGuards)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler, params.MCPCredentialsHandler, rbacGuards)
RegisterWebSearchRoutes(v1, params.WebSearchHandler, rbacGuards)
RegisterWebSearchProviderRoutes(v1, params.WebSearchProviderHandler, params.WebSearchCredentialsHandler, rbacGuards)
@@ -722,6 +723,38 @@ func RegisterSystemRoutes(r *gin.RouterGroup, handler *handler.SystemHandler, g
// the agent permission to execute side-effecting external commands.
// Credential subresource writes are Admin+ as well since secrets are
// tenant-scoped.
// RegisterSystemAdminRoutes registers system administration routes.
//
// All endpoints under this group are gated to SystemAdmin users (i.e.
// User.IsSystemAdmin == true). These are platform-wide operations
// independent of per-tenant Owner/Admin/Contributor/Viewer roles —
// they let org-level superusers grant/revoke system-admin status and,
// in later milestones, will host global settings, built-in models, and
// cross-tenant observability.
//
// Mounted under /api/v1/system/admin/* so the URL scheme stays aligned
// with the existing /api/v1/system/info family. Front-end clients live
// in frontend/src/api/system/index.ts.
func RegisterSystemAdminRoutes(r *gin.RouterGroup, handler *handler.SystemHandler, g *rbacGuards) {
// Apply SystemAdmin() at the group level — every route below inherits
// the guard, so adding new endpoints can't accidentally drop the gate.
adminRoutes := r.Group("/system/admin", g.SystemAdmin())
{
// P0: SystemAdmin role management
adminRoutes.POST("/promote", handler.PromoteUserToSystemAdmin)
adminRoutes.POST("/revoke", handler.RevokeSystemAdmin)
adminRoutes.GET("/list", handler.ListSystemAdmins)
// P1: platform-wide system settings (DB-backed runtime tunables).
// Reads return raw model rows / arrays (no `gin.H{"data":...}`
// wrapping), matching the project's axios interceptor convention
// — see frontend/src/utils/request.ts:97.
adminRoutes.GET("/settings", handler.ListSystemSettings)
adminRoutes.GET("/settings/:key", handler.GetSystemSetting)
adminRoutes.PUT("/settings/:key", handler.UpdateSystemSetting)
}
}
func RegisterMCPServiceRoutes(
r *gin.RouterGroup,
handler *handler.MCPServiceHandler,
+9
View File
@@ -89,6 +89,15 @@ const (
// initiates a _reindex (sync or async). Details payload: source
// KB id, target KB id, sync-or-async, doc count if known.
AuditActionOpenSearchReindexExecuted AuditAction = "opensearch.reindex_executed"
// AuditActionSystemSettingChanged fires when a SystemAdmin updates
// a row in the platform-wide system_settings table via
// PUT /api/v1/system/admin/settings/:key. Details payload carries
// {key, value_type, old_value, new_value} — sensitive values are
// redacted server-side before logging when is_secret=true (P3+;
// for now no setting is marked secret). Audit rows always have
// tenant_id=0 because the change is system-scope, not tenant-scope.
AuditActionSystemSettingChanged AuditAction = "system.setting_changed"
)
// AuditOutcome distinguishes successful mutations from middleware-level
+2
View File
@@ -31,6 +31,8 @@ const (
// request lifecycle. Defined here (not inside the langfuse package) so
// that logger.CloneContext can preserve it without importing langfuse.
LangfuseTraceContextKey ContextKey = "LangfuseTrace"
// SystemAdminContextKey is the context key indicating whether the user is a system administrator
SystemAdminContextKey ContextKey = "SystemAdmin"
)
// String returns the string representation of the context key
+10
View File
@@ -93,6 +93,16 @@ func TenantRoleFromContext(ctx context.Context) TenantRole {
return v
}
// IsSystemAdminFromContext extracts the system admin flag from ctx.
// Returns false (fail-closed) when the key is absent.
func IsSystemAdminFromContext(ctx context.Context) bool {
v, ok := ctx.Value(SystemAdminContextKey).(bool)
if !ok {
return false
}
return v
}
// SessionTenantIDFromContext extracts the session-owner tenant ID from ctx.
// Falls back to TenantIDFromContext when the session key is absent.
func SessionTenantIDFromContext(ctx context.Context) (uint64, bool) {
@@ -0,0 +1,81 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// SystemSettingRepository is the storage layer for the platform-wide
// system_settings table. All methods are system-scoped — there is no
// tenant_id; rows are global to the deployment.
type SystemSettingRepository interface {
// Get fetches a row by key. Returns (nil, nil) when the key is not
// present — callers fall back to ENV / default at the service layer.
Get(ctx context.Context, key string) (*types.SystemSetting, error)
// List returns every row. Used by the management UI to render the
// settings page; no pagination yet (the registry is small — single
// digits in P1, expected to stay double-digits long-term).
List(ctx context.Context) ([]*types.SystemSetting, error)
// Upsert writes a row keyed by Key. Insert if missing, update if
// present. Used by SystemSettingService.Update on every save.
Upsert(ctx context.Context, s *types.SystemSetting) error
}
// SystemSettingService exposes both the 3-tier resolver (used by
// production code paths that consume settings) and the management CRUD
// (used by the SystemAdmin UI).
//
// 3-tier resolver priority: DB > ENV > built-in default. The service
// owns the registry of legal keys; reading or writing an unknown key
// returns an error from Update / falls through to default for Get.
//
// P1 ships with no in-memory cache: every GetXxx hits the DB. The
// callers (file-upload size check, etc.) are not on a hot path so the
// extra ~1ms is negligible. P2 may add Redis pubsub + a TTL cache;
// the SubscribeRedis hook below is a placeholder for that.
type SystemSettingService interface {
// GetInt returns the resolved int64 value for `key`.
//
// envName is the legacy environment-variable name to consult when
// the DB row is absent ("" means the key has no ENV fallback).
// def is the built-in default used when both DB and ENV miss.
//
// Errors at the DB layer degrade gracefully: the function logs a
// warning and falls through to ENV / default rather than returning
// an error to upstream business code (which would have to bubble
// it through every caller — we'd rather mis-serve a request with
// the default than 500 a file upload).
GetInt(ctx context.Context, key string, envName string, def int64) int64
GetString(ctx context.Context, key string, envName string, def string) string
GetBool(ctx context.Context, key string, envName string, def bool) bool
// GetStringList resolves a comma-separated list of strings. envName
// is treated as a comma-separated string at the ENV level (mirrors
// the legacy SSRF_WHITELIST format). The slice returned is always
// non-nil so callers can iterate without a nil check.
GetStringList(ctx context.Context, key string, envName string, def []string) []string
// List, Get, Update are the management-CRUD surface called by the
// SystemAdmin handlers (gated to user.is_system_admin = true at the
// router layer). Update emits an audit log on success.
List(ctx context.Context) ([]*types.SystemSetting, error)
Get(ctx context.Context, key string) (*types.SystemSetting, error)
// Update writes a new value for `key`, validating that:
// 1. key is in the in-code registry (rejects 400 otherwise — UI
// cannot inject arbitrary keys),
// 2. rawValue's Go type matches the registry's expected type
// (e.g. int64 / float64 for "int", string for "string", bool
// for "bool"),
// 3. the actor is captured from ctx (UserIDFromContext) and
// written to last_modified_by + the audit log.
// Returns the persisted row on success.
Update(ctx context.Context, key string, rawValue any) (*types.SystemSetting, error)
// SubscribeRedis is a P2 hook: when implemented, it will subscribe
// to a "weknora:system_settings:changed" channel and invalidate any
// in-memory cache on receipt. P1 implementations may return a no-op
// because there is no cache yet. Keeping the method in the interface
// now means P2 can drop in a real implementation without changing
// the container wiring.
SubscribeRedis(ctx context.Context) error
}
+10
View File
@@ -65,6 +65,11 @@ type UserService interface {
GetCurrentUser(ctx context.Context) (*types.User, error)
// SearchUsers searches users by username or email
SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error)
// ListSystemAdmins lists users with IsSystemAdmin=true.
// Returns the page of admins plus the total count (for pagination UI);
// callers pass offset/limit to page through results. Used by the
// /api/v1/system/admin/list endpoint, gated to SystemAdmin callers.
ListSystemAdmins(ctx context.Context, offset, limit int) ([]*types.User, int64, error)
// UpdateUserPreferences partially updates the calling user's
// preferences blob (PATCH semantics: only keys present in `patch`
// overwrite existing values). Returns the updated, persisted prefs.
@@ -92,6 +97,11 @@ type UserRepository interface {
DeleteUser(ctx context.Context, id string) error
// ListUsers lists users with pagination
ListUsers(ctx context.Context, offset, limit int) ([]*types.User, error)
// ListSystemAdmins lists users where is_system_admin = true.
// Walks the partial-friendly idx_users_is_system_admin index. Returns
// the slice plus the total count for pagination metadata. Used by
// the system-admin management endpoint.
ListSystemAdmins(ctx context.Context, offset, limit int) ([]*types.User, int64, error)
// SearchUsers searches users by username or email
SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error)
}
+146
View File
@@ -0,0 +1,146 @@
package types
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
)
// SystemSetting is a platform-wide (NOT tenant-scoped) tunable that
// SystemAdmins can edit at runtime via the management UI without
// restarting the service. Persisted in the system_settings table
// (migration 000053).
//
// The 3-tier resolver in service.SystemSettingService reads in priority
// order: DB row > os.Getenv(EnvName) > built-in default. The Service
// owns the registry of legal keys + their default values + their ENV
// names; this type is just the on-disk shape.
//
// Value is stored as JSONB so the same column can hold ints / strings /
// booleans / arrays. ValueType ("int" | "string" | "bool") tells callers
// (and the AsXxx helpers below) how to decode the raw bytes. Booleans
// roundtrip as `true`/`false`, ints as `42`, strings as `"foo"`.
type SystemSetting struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Key string `gorm:"type:varchar(128);uniqueIndex;not null" json:"key"`
Value JSON `gorm:"type:jsonb;not null" json:"value"`
// ValueType is one of "int", "string", "bool". Service layer rejects
// updates whose payload type does not match; UI uses it to pick
// InputNumber vs Input vs Switch.
ValueType string `gorm:"type:varchar(16);not null" json:"value_type"`
// Category groups settings in the management UI ("limits", "agent",
// "auth", ...). Free-form string so adding a new category is a
// data-only change.
Category string `gorm:"type:varchar(32);not null" json:"category"`
Description string `gorm:"type:text;not null;default:''" json:"description"`
// IsSecret reserves UI affordances for P3 (mask + reveal-with-confirm).
// In P1 every row is is_secret=false; service Update accepts the
// column but does not yet enforce special handling.
IsSecret bool `gorm:"not null;default:false" json:"is_secret"`
// RequiresRestart reserves UI affordances for P3 (banner "this
// change won't take effect until the next restart"). In P1 the
// only seeded key is per-request, so always false.
RequiresRestart bool `gorm:"not null;default:false" json:"requires_restart"`
LastModifiedBy string `gorm:"type:varchar(36);not null;default:''" json:"last_modified_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Enum is populated by the service layer (NOT persisted) from the
// in-code registry. Empty/nil means "free-form input"; non-empty
// means the UI should render a select with these options. Tagged
// `gorm:"-"` so GORM never tries to read/write the column.
Enum []string `gorm:"-" json:"enum,omitempty"`
}
// TableName pins the schema to migration 000053 — GORM's default
// pluralisation would yield "system_settings" anyway, but spelling it
// out shields against future renames.
func (SystemSetting) TableName() string {
return "system_settings"
}
// AsInt decodes the raw JSON value as an int64. Returns an error if
// ValueType is not "int" or the JSON does not parse as a number.
//
// Accepts both `42` (number literal) and `"42"` (quoted string) so
// hand-edited DB rows are tolerated. The service-layer Update path
// always writes a number literal.
func (s *SystemSetting) AsInt() (int64, error) {
if s.ValueType != "int" {
return 0, fmt.Errorf("system_setting %q: value_type=%q, not int", s.Key, s.ValueType)
}
if len(s.Value) == 0 {
return 0, fmt.Errorf("system_setting %q: empty value", s.Key)
}
// Try number first (canonical form).
var n int64
if err := json.Unmarshal(s.Value, &n); err == nil {
return n, nil
}
// Fall back to quoted string ("42") — tolerate hand-edited rows.
var raw string
if err := json.Unmarshal(s.Value, &raw); err == nil {
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
return v, nil
}
}
return 0, fmt.Errorf("system_setting %q: cannot parse %s as int", s.Key, string(s.Value))
}
// AsString decodes the raw JSON value as a string. Requires ValueType
// to be "string"; the JSON must be a JSON string ("foo"), not a number
// or object.
func (s *SystemSetting) AsString() (string, error) {
if s.ValueType != "string" {
return "", fmt.Errorf("system_setting %q: value_type=%q, not string", s.Key, s.ValueType)
}
if len(s.Value) == 0 {
return "", nil
}
var v string
if err := json.Unmarshal(s.Value, &v); err != nil {
return "", fmt.Errorf("system_setting %q: %w", s.Key, err)
}
return v, nil
}
// AsBool decodes the raw JSON value as a bool. Requires ValueType to
// be "bool"; the JSON must be `true` or `false`.
func (s *SystemSetting) AsBool() (bool, error) {
if s.ValueType != "bool" {
return false, fmt.Errorf("system_setting %q: value_type=%q, not bool", s.Key, s.ValueType)
}
if len(s.Value) == 0 {
return false, errors.New("empty value")
}
var v bool
if err := json.Unmarshal(s.Value, &v); err != nil {
return false, fmt.Errorf("system_setting %q: %w", s.Key, err)
}
return v, nil
}
// AsStringList decodes the raw JSON value as []string. Requires
// ValueType to be "string_list"; the JSON must be a JSON array of
// strings, e.g. `["example.com", "*.foo.bar", "10.0.0.0/8"]`.
//
// Returns an empty (non-nil) slice for an empty list, so callers can
// treat the absence of items uniformly and still iterate without nil checks.
func (s *SystemSetting) AsStringList() ([]string, error) {
if s.ValueType != "string_list" {
return nil, fmt.Errorf("system_setting %q: value_type=%q, not string_list", s.Key, s.ValueType)
}
if len(s.Value) == 0 {
return []string{}, nil
}
var v []string
if err := json.Unmarshal(s.Value, &v); err != nil {
return nil, fmt.Errorf("system_setting %q: %w", s.Key, err)
}
if v == nil {
v = []string{}
}
return v, nil
}
+4
View File
@@ -92,6 +92,8 @@ type User struct {
IsActive bool `json:"is_active" gorm:"default:true"`
// Whether the user can access all tenants (cross-tenant access)
CanAccessAllTenants bool `json:"can_access_all_tenants" gorm:"default:false"`
// Whether the user is a system administrator (independent of tenant roles)
IsSystemAdmin bool `json:"is_system_admin" gorm:"default:false;index"`
// Per-user UI/feature preferences (memory toggle, future knobs).
// Stored as JSON (jsonb on Postgres, TEXT on SQLite) via the
// driver.Valuer / sql.Scanner methods on UserPreferences.
@@ -219,6 +221,7 @@ type UserInfo struct {
TenantID uint64 `json:"tenant_id"`
IsActive bool `json:"is_active"`
CanAccessAllTenants bool `json:"can_access_all_tenants"`
IsSystemAdmin bool `json:"is_system_admin"`
Preferences UserPreferences `json:"preferences"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -234,6 +237,7 @@ func (u *User) ToUserInfo() *UserInfo {
TenantID: u.TenantID,
IsActive: u.IsActive,
CanAccessAllTenants: u.CanAccessAllTenants,
IsSystemAdmin: u.IsSystemAdmin,
Preferences: u.Preferences,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
+19 -1
View File
@@ -7,6 +7,23 @@ import (
// GetMaxFileSize returns the maximum file upload size in bytes.
// Default is 50MB, can be configured via MAX_FILE_SIZE_MB environment variable.
//
// This is the legacy ENV-only entry point. Handlers wired with
// interfaces.SystemSettingService should prefer reading the 3-tier
// resolver directly:
//
// mb := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
// maxBytes := mb * 1024 * 1024
//
// That way SystemAdmin's UI override (DB row in system_settings) takes
// precedence over the ENV value. This function is kept for unit tests
// and any non-handler call sites that don't have the service injected;
// it never sees DB values, only ENV.
//
// We deliberately don't expose a "service-aware" wrapper here because
// internal/types depends on internal/utils — wrapping it would create
// an import cycle. Call sites that have a SystemSettingService should
// just use it directly (it's only one extra line).
func GetMaxFileSize() int64 {
if sizeStr := os.Getenv("MAX_FILE_SIZE_MB"); sizeStr != "" {
if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil && size > 0 {
@@ -16,7 +33,8 @@ func GetMaxFileSize() int64 {
return 50 * 1024 * 1024 // default 50MB
}
// GetMaxFileSizeMB returns the maximum file upload size in MB.
// GetMaxFileSizeMB returns the maximum file upload size in MB. Same
// caveat as GetMaxFileSize — handlers should prefer SystemSettingService.GetInt.
func GetMaxFileSizeMB() int64 {
if sizeStr := os.Getenv("MAX_FILE_SIZE_MB"); sizeStr != "" {
if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil && size > 0 {
+101 -36
View File
@@ -12,6 +12,7 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
@@ -863,8 +864,24 @@ func SSRFSafeDialContext(ctx context.Context, network, addr string) (net.Conn, e
// Whitelisted entries bypass the normal SSRF checks performed by isSSRFSafeURL.
var (
// ssrfWhitelistOnce protects the cold-start ENV-only path. Once
// SystemSettingService has called SetSSRFWhitelistFromRaw, the
// atomic pointer below takes over and this Once is never observed
// again — we keep it for tests (resetSSRFWhitelistForTest) and the
// rare deployment that runs without DB-backed system_settings.
ssrfWhitelistOnce sync.Once
ssrfWhitelist *ssrfWhitelistConfig
// ssrfWhitelistAtomic is the runtime-tunable whitelist source.
// SystemSettingService writes here at preload, on every Update,
// and on every pubsub-driven reload (multi-replica fan-out). When
// non-nil, it takes precedence over the ENV-only Once-cached
// `ssrfWhitelist`. nil means "service hasn't pushed yet"; the
// loadSSRFWhitelist fallback then reads ENV directly.
//
// We use atomic.Pointer so reads on the SSRF hot path
// (ValidateURLForSSRF, called for every outgoing URL) are lock-free.
ssrfWhitelistAtomic atomic.Pointer[ssrfWhitelistConfig]
)
type ssrfWhitelistConfig struct {
@@ -873,54 +890,101 @@ type ssrfWhitelistConfig struct {
cidrNets []*net.IPNet // CIDR ranges
}
// loadSSRFWhitelist parses the SSRF_WHITELIST environment variable once.
// loadSSRFWhitelist returns the active whitelist config. Resolution
// order:
// 1. ssrfWhitelistAtomic — set by SystemSettingService whenever DB
// ssrf.whitelist changes. This is the runtime-tunable path.
// 2. ENV fallback — sync.Once-cached parse of SSRF_WHITELIST and
// SSRF_WHITELIST_EXTRA. Used during the startup window before
// the service has finished its preload, and on deployments that
// don't run system_settings (lite mode).
func loadSSRFWhitelist() *ssrfWhitelistConfig {
if cur := ssrfWhitelistAtomic.Load(); cur != nil {
return cur
}
ssrfWhitelistOnce.Do(func() {
ssrfWhitelist = &ssrfWhitelistConfig{
exactHosts: make(map[string]bool),
}
raw := os.Getenv("SSRF_WHITELIST")
// SSRF_WHITELIST_EXTRA is merged in addition to SSRF_WHITELIST so that
// deployment-managed defaults (e.g. docker-compose injected sidecar host
// names like "searxng") aren't accidentally clobbered when an operator
// overrides SSRF_WHITELIST in their .env.
extra := os.Getenv("SSRF_WHITELIST_EXTRA")
if raw == "" && extra == "" {
return
}
if extra != "" {
if raw == "" {
raw = extra
} else {
raw = raw + "," + extra
}
}
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// CIDR range
if strings.Contains(entry, "/") {
_, ipNet, err := net.ParseCIDR(entry)
if err == nil {
ssrfWhitelist.cidrNets = append(ssrfWhitelist.cidrNets, ipNet)
continue
}
}
// Wildcard domain: *.example.com
if strings.HasPrefix(entry, "*.") {
suffix := strings.ToLower(entry[1:]) // ".example.com"
ssrfWhitelist.suffixHosts = append(ssrfWhitelist.suffixHosts, suffix)
continue
}
// Exact host or IP
ssrfWhitelist.exactHosts[strings.ToLower(entry)] = true
}
ssrfWhitelist = parseSSRFWhitelistRaw(mergeSSRFWhitelistRaws(raw, extra))
})
return ssrfWhitelist
}
// SetSSRFWhitelistFromRaw atomically replaces the active SSRF whitelist
// with the parse of `raw` (comma-separated entries, same syntax as
// the SSRF_WHITELIST env var). The new whitelist takes effect for every
// subsequent ValidateURLForSSRF call across all goroutines without
// additional synchronisation.
//
// Called by SystemSettingService at preload, after each Update, and
// after each pubsub-driven peer change. Empty `raw` clears the whitelist
// (only built-in private-IP rejection remains in effect).
//
// Note: this replaces the ENV-only fallback completely. If you want
// SSRF_WHITELIST_EXTRA to keep being merged, the caller must do the
// merge before calling this — see service.systemSettingService.
// applySSRFWhitelist for the canonical merge logic.
func SetSSRFWhitelistFromRaw(raw string) {
ssrfWhitelistAtomic.Store(parseSSRFWhitelistRaw(raw))
}
// parseSSRFWhitelistRaw parses a comma-separated whitelist string into
// a config struct. Pure function; no env reads. Always returns a
// non-nil pointer so callers can blindly Load.
func parseSSRFWhitelistRaw(raw string) *ssrfWhitelistConfig {
cfg := &ssrfWhitelistConfig{
exactHosts: make(map[string]bool),
}
if raw == "" {
return cfg
}
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// CIDR range
if strings.Contains(entry, "/") {
_, ipNet, err := net.ParseCIDR(entry)
if err == nil {
cfg.cidrNets = append(cfg.cidrNets, ipNet)
continue
}
}
// Wildcard domain: *.example.com
if strings.HasPrefix(entry, "*.") {
suffix := strings.ToLower(entry[1:]) // ".example.com"
cfg.suffixHosts = append(cfg.suffixHosts, suffix)
continue
}
// Exact host or IP
cfg.exactHosts[strings.ToLower(entry)] = true
}
return cfg
}
// mergeSSRFWhitelistRaws joins two comma-separated raw strings, dropping
// the comma when one side is empty. Exposed for the service layer's
// "merge SSRF_WHITELIST_EXTRA into the DB-backed list" code path.
func mergeSSRFWhitelistRaws(primary, extra string) string {
primary = strings.TrimSpace(primary)
extra = strings.TrimSpace(extra)
switch {
case primary == "" && extra == "":
return ""
case primary == "":
return extra
case extra == "":
return primary
default:
return primary + "," + extra
}
}
// IsSSRFWhitelisted checks whether the given hostname (or IP string) is
// covered by the SSRF_WHITELIST environment variable.
func IsSSRFWhitelisted(hostname string) bool {
@@ -978,6 +1042,7 @@ func IsSSRFWhitelisted(hostname string) bool {
func ResetSSRFWhitelistForTest() {
ssrfWhitelistOnce = sync.Once{}
ssrfWhitelist = nil
ssrfWhitelistAtomic.Store(nil)
}
// FormatSSRFError takes the error returned by ValidateURLForSSRF and wraps
@@ -0,0 +1,9 @@
-- Rollback: drop users.is_system_admin column and its index
DO $$ BEGIN RAISE NOTICE '[Migration 000052 DOWN] Dropping users.is_system_admin...'; END $$;
DROP INDEX IF EXISTS idx_users_is_system_admin;
ALTER TABLE users DROP COLUMN IF EXISTS is_system_admin;
DO $$ BEGIN RAISE NOTICE '[Migration 000052 DOWN] Done.'; END $$;
@@ -0,0 +1,17 @@
-- Migration: 000052_user_system_admin
-- Adds system-level administrator flag to users table. System admins operate
-- independently of tenant-scoped roles and have platform-wide privileges.
-- This enables organization-level superuser management, separate from per-tenant
-- admin/owner roles. The IsSystemAdmin flag is indexed for efficient queries
-- on privilege checks and admin listing.
DO $$ BEGIN RAISE NOTICE '[Migration 000052] Adding users.is_system_admin column...'; END $$;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_system_admin BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_users_is_system_admin ON users (is_system_admin);
COMMENT ON COLUMN users.is_system_admin IS 'Whether the user is a system administrator (independent of tenant roles)';
DO $$ BEGIN RAISE NOTICE '[Migration 000052] Done.'; END $$;
@@ -0,0 +1,6 @@
-- Rollback: drop system_settings table
DO $$ BEGIN RAISE NOTICE '[Migration 000053 DOWN] Dropping table: system_settings'; END $$;
DROP TABLE IF EXISTS system_settings CASCADE;
DO $$ BEGIN RAISE NOTICE '[Migration 000053 DOWN] Done.'; END $$;
@@ -0,0 +1,70 @@
-- Migration: 000053_system_settings
-- Adds a system-scoped (NOT tenant-scoped) settings table for platform-wide
-- runtime tunables, gated by SystemAdmin in P1.
--
-- Scope:
-- - P1 ships the schema, the 3-tier resolver (DB > ENV > built-in default),
-- and a single seeded key (file.max_size_mb) as a worked example. Adding
-- more keys is purely a service-layer registry change — no further
-- migrations needed.
-- - is_secret / requires_restart columns exist now but are wired to
-- `false` for every P1 row. P3 turns them into real semantics
-- (mask + reveal flow / "needs restart" UI badge).
--
-- Why JSONB for `value`?
-- We want to support int / string / bool / arrays / objects under one
-- schema without a separate table per type. The `value_type` column tells
-- the service layer how to parse the raw JSON. Booleans roundtrip as
-- `true`/`false`, ints as `42`, strings as `"foo"`.
--
-- Indexes:
-- - UNIQUE on (key) — primary lookup pattern, every Get hits this
-- - (category) — for the management UI's grouped list view
DO $$ BEGIN RAISE NOTICE '[Migration 000053] Creating table: system_settings'; END $$;
CREATE TABLE IF NOT EXISTS system_settings (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(128) NOT NULL UNIQUE,
value JSONB NOT NULL,
value_type VARCHAR(16) NOT NULL,
category VARCHAR(32) NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_secret BOOLEAN NOT NULL DEFAULT false,
requires_restart BOOLEAN NOT NULL DEFAULT false,
last_modified_by VARCHAR(36) NOT NULL DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_system_settings_category
ON system_settings (category);
-- Seed the P1 / P3 worked examples. ON CONFLICT DO NOTHING so re-running
-- the migration on an instance where the operator already tweaked the
-- value via UI doesn't reset it.
--
-- Categories drive the management UI grouping:
-- limits = quota / size knobs
-- security = SSRF whitelist, future ACLs
-- auth = registration mode etc.
INSERT INTO system_settings (key, value, value_type, category, description)
VALUES
('file.max_size_mb',
'50',
'int',
'limits',
'上传文件大小上限(MB)。修改后立即对下次上传生效,无需重启服务。'),
('ssrf.whitelist',
'[]',
'string_list',
'security',
'SSRF 防护白名单。可填入 example.com / *.foo.com / 10.0.0.0/8 / 2001:db8::1。修改后立即生效。SSRF_WHITELIST_EXTRA 环境变量仍由部署方维护,不在此处覆盖。'),
('auth.registration_mode',
'"self_serve"',
'string',
'auth',
'自助注册模式。self_serve = 任何人可注册账号;invite_only = 关闭公网注册,仅 Owner/Admin 可邀请。修改后立即生效。')
ON CONFLICT (key) DO NOTHING;
DO $$ BEGIN RAISE NOTICE '[Migration 000053] system_settings table ready'; END $$;