From 3c4cdd707f4783c9749ee8ab20c69aae07dc0d83 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Wed, 13 May 2026 19:47:48 +0800 Subject: [PATCH] feat(rbac): add tenant_members schema and auth wiring (PR 1 of 3, #1303) Lay the foundation for tenant-level RBAC without enforcing it yet. Schema, types, middleware lookup, and login-flow shape changes ship now; RequireRole/route annotations and member-management endpoints follow in PR 2 and PR 3. Highlights: - New tenant_members table (Postgres + SQLite) with (user_id, tenant_id) unique key and (owner|admin|contributor|viewer) role values. Migration 000043 backfills one row per existing user, marking the earliest-created active user in each tenant as Owner and the rest as Contributor. - KnowledgeBase.creator_id and CustomAgent.runnable_by_viewer added so PR 2 can wire ownership-based and Viewer-runtime checks. - Auth middleware loads the active TenantMember row, sets a new TenantRoleContextKey, and falls back via three escape hatches: cross-tenant superusers (CanAccessAllTenants) get Admin in the target tenant; orphan tenants (zero members) auto-promote the first authenticating human to Owner; and when tenant.enable_rbac is false (the v1 default) unmatched lookups fail open with Admin to preserve current behaviour. - Register / OIDC provisioning now insert an Owner membership for the registrant via TenantMemberService.EnsureOwner. - LoginResponse renamed tenant -> active_tenant and gained a memberships array. POST /auth/switch-tenant issues a fresh token pair scoped to a target membership; GET /auth/config exposes auth.registration_mode for the frontend register-tab gating. - New config keys: auth.registration_mode (self_serve | invite_only, defaults to self_serve) and tenant.enable_rbac (defaults to false). Both have WEKNORA_* env overrides. - Frontend store persists memberships and exposes currentTenantRole; Login.vue calls /auth/config and hides the Register tab in invite_only mode. Go SDK types updated to match the new LoginResponse shape. --- client/auth.go | 26 ++- frontend/src/api/auth/index.ts | 23 +++ frontend/src/stores/auth.ts | 43 +++- frontend/src/views/auth/Login.vue | 45 ++++- .../repository/knowledgebase_sqlite_test.go | 1 + .../application/repository/tenant_member.go | 127 ++++++++++++ internal/application/service/tenant_member.go | 188 ++++++++++++++++++ internal/application/service/user.go | 180 ++++++++++++++++- internal/config/config.go | 76 ++++++- internal/container/container.go | 2 + internal/handler/auth.go | 90 ++++++++- internal/middleware/auth.go | 111 +++++++++-- internal/router/router.go | 5 +- internal/types/const.go | 4 + internal/types/context_helpers.go | 12 ++ internal/types/custom_agent.go | 5 + internal/types/interfaces/tenant_member.go | 50 +++++ .../types/interfaces/tenant_member_service.go | 50 +++++ internal/types/interfaces/user.go | 5 + internal/types/knowledgebase.go | 6 + internal/types/tenant_member.go | 121 +++++++++++ internal/types/user.go | 20 +- migrations/sqlite/000000_init.down.sql | 1 + migrations/sqlite/000000_init.up.sql | 27 +++ .../versioned/000043_tenant_rbac.down.sql | 16 ++ .../versioned/000043_tenant_rbac.up.sql | 107 ++++++++++ 26 files changed, 1295 insertions(+), 46 deletions(-) create mode 100644 internal/application/repository/tenant_member.go create mode 100644 internal/application/service/tenant_member.go create mode 100644 internal/types/interfaces/tenant_member.go create mode 100644 internal/types/interfaces/tenant_member_service.go create mode 100644 internal/types/tenant_member.go create mode 100644 migrations/versioned/000043_tenant_rbac.down.sql create mode 100644 migrations/versioned/000043_tenant_rbac.up.sql diff --git a/client/auth.go b/client/auth.go index 41a3884e9..9f8378c56 100644 --- a/client/auth.go +++ b/client/auth.go @@ -16,13 +16,27 @@ type LoginRequest struct { // LoginResponse is the body returned by POST /api/v1/auth/login. // // Token is the JWT access token; RefreshToken renews it via Auth.Refresh. +// ActiveTenant is the tenant whose ID is encoded in the JWT — every +// subsequent request is scoped to it until /auth/switch-tenant is called. +// Memberships lists every tenant the user can access along with their +// role in each, so callers can build a tenant switcher UI without a +// follow-up request. type LoginResponse struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - User *AuthUser `json:"user,omitempty"` - Tenant *AuthTenant `json:"tenant,omitempty"` - Token string `json:"token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + User *AuthUser `json:"user,omitempty"` + ActiveTenant *AuthTenant `json:"active_tenant,omitempty"` + Memberships []AuthMembership `json:"memberships,omitempty"` + Token string `json:"token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// AuthMembership pairs a tenant ID with the user's role in that tenant. +// Mirrors types.Membership on the server. +type AuthMembership struct { + TenantID uint64 `json:"tenant_id"` + TenantName string `json:"tenant_name,omitempty"` + Role string `json:"role"` } // AuthUser is the principal returned by /auth/login and /auth/me. diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index 7edbada07..2ec1ebcb4 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -175,6 +175,29 @@ export async function getOIDCConfig(): Promise { } } +/** + * 获取认证配置(仅返回前端渲染需要的公开字段,例如注册模式)。 + * + * 后端通过 `auth.registration_mode` 控制是否允许自助注册: + * - "self_serve" 保留现有自助注册入口(默认) + * - "invite_only" 关闭注册,要求管理员邀请 + * + * 失败时回落到 self_serve,避免接口异常导致注册入口直接消失。 + */ +export interface AuthConfigResponse { + success: boolean + registration_mode: 'self_serve' | 'invite_only' | string +} + +export async function getAuthConfig(): Promise { + try { + const response = await get('/api/v1/auth/config') + return response as unknown as AuthConfigResponse + } catch { + return { success: false, registration_mode: 'self_serve' } + } +} + /** * 用户注册 */ diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 0e7dc23b3..4f0d161e5 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -27,6 +27,11 @@ export const useAuthStore = defineStore('auth', () => { const selectedTenantId = ref(null) const selectedTenantName = ref(null) const allTenants = ref([]) + // memberships lists every tenant the user can authenticate into, + // along with their role in each. Populated from /auth/login response. + // v1 deployments will typically have length 1; the field is wired now + // so PR 3 can render a tenant-switcher UI without a store migration. + const memberships = ref>([]) const isLiteMode = ref(false) // 计算属性 @@ -50,6 +55,17 @@ export const useAuthStore = defineStore('auth', () => { return user.value?.can_access_all_tenants || false }) + // 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 + // this for menu/button visibility. + const currentTenantRole = computed(() => { + const tid = tenant.value?.id ? String(tenant.value.id) : '' + if (!tid) return '' + const match = memberships.value.find((m) => String(m.tenant_id) === tid) + return match?.role || '' + }) + const effectiveTenantId = computed(() => { // 如果选择了其他租户,使用选择的租户ID,否则使用用户默认租户ID return selectedTenantId.value || (tenant.value?.id ? Number(tenant.value.id) : null) @@ -115,6 +131,13 @@ export const useAuthStore = defineStore('auth', () => { allTenants.value = tenants } + const setMemberships = ( + list: Array<{ tenant_id: number; tenant_name?: string; role: string }> + ) => { + memberships.value = Array.isArray(list) ? list : [] + localStorage.setItem('weknora_memberships', JSON.stringify(memberships.value)) + } + const getSelectedTenant = () => { return selectedTenantId.value } @@ -139,6 +162,7 @@ export const useAuthStore = defineStore('auth', () => { selectedTenantId.value = null selectedTenantName.value = null allTenants.value = [] + memberships.value = [] // 清空localStorage localStorage.removeItem('weknora_user') @@ -149,6 +173,7 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem('weknora_current_kb') localStorage.removeItem('weknora_selected_tenant_id') localStorage.removeItem('weknora_selected_tenant_name') + localStorage.removeItem('weknora_memberships') localStorage.removeItem('weknora_lite_mode') isLiteMode.value = false try { @@ -225,6 +250,17 @@ export const useAuthStore = defineStore('auth', () => { } } + const storedMemberships = localStorage.getItem('weknora_memberships') + if (storedMemberships) { + try { + const parsed = JSON.parse(storedMemberships) + memberships.value = Array.isArray(parsed) ? parsed : [] + } catch (e) { + console.error('Failed to parse memberships', e) + memberships.value = [] + } + } + isLiteMode.value = localStorage.getItem('weknora_lite_mode') === 'true' } @@ -242,16 +278,18 @@ export const useAuthStore = defineStore('auth', () => { selectedTenantId, selectedTenantName, allTenants, - + memberships, + // 计算属性 isLoggedIn, hasValidTenant, currentTenantId, currentUserId, canAccessAllTenants, + currentTenantRole, effectiveTenantId, isLiteMode, - + // 方法 setUser, setTenant, @@ -261,6 +299,7 @@ export const useAuthStore = defineStore('auth', () => { setCurrentKnowledgeBase, setSelectedTenant, setAllTenants, + setMemberships, getSelectedTenant, setLiteMode, logout, diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue index 5674bca1e..ab404d243 100755 --- a/frontend/src/views/auth/Login.vue +++ b/frontend/src/views/auth/Login.vue @@ -229,7 +229,7 @@ {{ loading ? $t('auth.loggingIn') : $t('auth.login') }} -