mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
@@ -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 的超管不受此限制。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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) || '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="用户 ID(UUID 形式)"
|
||||
: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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "ed); 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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_only(applyAuthAndTenantDefaults),
|
||||
// 作为 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 {
|
||||
|
||||
@@ -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 三级 resolver(DB > 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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 $$;
|
||||
Reference in New Issue
Block a user