diff --git a/.env.example b/.env.example index a4694cad..eef39600 100644 --- a/.env.example +++ b/.env.example @@ -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 的超管不受此限制。 diff --git a/cmd/server/bootstrap.go b/cmd/server/bootstrap.go new file mode 100644 index 00000000..0fe6066b --- /dev/null +++ b/cmd/server/bootstrap.go @@ -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) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index bc801bf8..1e6789ab 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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() { diff --git a/docker-compose.yml b/docker-compose.yml index ff04f0af..1a734d14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 72a2731e..92d15308 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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) || '', diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index 12b812a4..d5d4ef4e 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -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 diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index efc33d3e..540c0b36 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -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` annotation would + * compile (sometimes — vue-tsc is inconsistent on AxiosResponse vs + * inline interface assignability) but is fragile. + */ +export async function promoteUserToSystemAdmin(userId: string): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + const response = await put( + `/api/v1/system/admin/settings/${encodeURIComponent(key)}`, + { value }, + ) + return response as unknown as SystemSettingItem +} diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 93385b86..ea9e3b6e 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -110,6 +110,20 @@ {{ $t('general.allSettings') }} + +