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.
This commit is contained in:
wizardchen
2026-05-13 19:47:48 +08:00
parent 588394a8e6
commit 3c4cdd707f
26 changed files with 1295 additions and 46 deletions

View File

@@ -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.

View File

@@ -175,6 +175,29 @@ export async function getOIDCConfig(): Promise<OIDCConfigResponse> {
}
}
/**
* 获取认证配置(仅返回前端渲染需要的公开字段,例如注册模式)。
*
* 后端通过 `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<AuthConfigResponse> {
try {
const response = await get('/api/v1/auth/config')
return response as unknown as AuthConfigResponse
} catch {
return { success: false, registration_mode: 'self_serve' }
}
}
/**
* 用户注册
*/

View File

@@ -27,6 +27,11 @@ export const useAuthStore = defineStore('auth', () => {
const selectedTenantId = ref<number | null>(null)
const selectedTenantName = ref<string | null>(null)
const allTenants = ref<TenantInfoFromAPI[]>([])
// 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<Array<{ tenant_id: number; tenant_name?: string; role: string }>>([])
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,

View File

@@ -229,7 +229,7 @@
{{ loading ? $t('auth.loggingIn') : $t('auth.login') }}
</t-button>
<div class="form-footer login-form-footer">
<div class="form-footer login-form-footer" v-if="registrationEnabled">
<span>{{ $t('auth.noAccount') }}</span>
<a href="#" @click.prevent="toggleMode" class="link-button">
{{ $t('auth.registerNow') }}
@@ -273,7 +273,7 @@
</div>
<!-- Register Card -->
<div class="form-card" v-if="isRegisterMode">
<div class="form-card" v-if="isRegisterMode && registrationEnabled">
<div class="form-header">
<h2 class="form-title">{{ $t('auth.createAccount') }}</h2>
<p class="form-subtitle">{{ $t('auth.registerSubtitle') }}</p>
@@ -377,7 +377,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 } from '@/api/auth'
import { login, register, getOIDCAuthorizationURL, getOIDCConfig, autoSetup, getAuthConfig } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
@@ -423,6 +423,10 @@ const isRegisterMode = ref(false)
const showLanguageMenu = ref(false)
const oidcEnabled = ref(false)
const oidcProviderName = ref('')
// registrationEnabled defaults to true so that on first paint the Register
// link is visible; the actual mode is fetched from /auth/config in onMounted.
// In invite_only mode the link/card are hidden.
const registrationEnabled = ref(true)
// Language options
const languageOptions = [
@@ -543,13 +547,18 @@ onBeforeUnmount(() => {
})
const persistLoginResponse = async (response: any) => {
if (response.user && response.tenant && response.token) {
// Backend renamed `tenant` to `active_tenant` and added `memberships`
// when tenant-level RBAC landed (issue #1303). The two are otherwise
// identical — `active_tenant` is the tenant whose ID is encoded in the
// JWT, defaulting to the user's home tenant on a fresh login.
const activeTenant = response.active_tenant || response.tenant
if (response.user && activeTenant && 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) || '',
tenant_id: String(activeTenant.id) || '',
can_access_all_tenants: response.user.can_access_all_tenants || false,
created_at: response.user.created_at || new Date().toISOString(),
updated_at: response.user.updated_at || new Date().toISOString()
@@ -559,13 +568,16 @@ const persistLoginResponse = async (response: any) => {
authStore.setRefreshToken(response.refresh_token)
}
authStore.setTenant({
id: String(response.tenant.id) || '',
name: response.tenant.name || '',
api_key: response.tenant.api_key || '',
id: String(activeTenant.id) || '',
name: activeTenant.name || '',
api_key: activeTenant.api_key || '',
owner_id: response.user.id || '',
created_at: response.tenant.created_at || new Date().toISOString(),
updated_at: response.tenant.updated_at || new Date().toISOString()
created_at: activeTenant.created_at || new Date().toISOString(),
updated_at: activeTenant.updated_at || new Date().toISOString()
})
if (Array.isArray(response.memberships)) {
authStore.setMemberships(response.memberships)
}
}
await nextTick()
@@ -585,6 +597,18 @@ const loadOIDCConfig = async () => {
}
}
// loadAuthConfig fetches /auth/config and caches whether self-service
// registration is allowed. Failures fall back to "enabled" so a transient
// network glitch doesn't lock new users out of an open deployment.
const loadAuthConfig = async () => {
try {
const response = await getAuthConfig()
registrationEnabled.value = response.registration_mode !== 'invite_only'
} catch {
registrationEnabled.value = true
}
}
const handleOIDCLogin = async () => {
try {
oidcLoading.value = true
@@ -692,6 +716,7 @@ onMounted(async () => {
}
loadOIDCConfig()
loadAuthConfig()
})
</script>

View File

@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS knowledge_bases (
pinned_at DATETIME NULL,
asr_config TEXT,
vector_store_id VARCHAR(36),
creator_id VARCHAR(36),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME

View File

@@ -0,0 +1,127 @@
package repository
import (
"context"
"errors"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"gorm.io/gorm"
)
// tenantMemberRepository implements interfaces.TenantMemberRepository.
type tenantMemberRepository struct {
db *gorm.DB
}
// NewTenantMemberRepository creates a new tenant member repository.
func NewTenantMemberRepository(db *gorm.DB) interfaces.TenantMemberRepository {
return &tenantMemberRepository{db: db}
}
// Create inserts a new active membership row. Status defaults to
// TenantMemberStatusActive when the caller leaves it blank, and JoinedAt
// defaults to the current time, matching service-layer expectations.
func (r *tenantMemberRepository) Create(ctx context.Context, member *types.TenantMember) error {
if member.Status == "" {
member.Status = types.TenantMemberStatusActive
}
if member.JoinedAt.IsZero() {
member.JoinedAt = time.Now()
}
return r.db.WithContext(ctx).Create(member).Error
}
// Get returns the active membership for (userID, tenantID), or (nil, nil)
// if no such row exists. Errors are propagated unchanged for any other case.
func (r *tenantMemberRepository) Get(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error) {
var member types.TenantMember
err := r.db.WithContext(ctx).
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
First(&member).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &member, nil
}
// ListByUser returns every active membership owned by the user, ordered
// by joined_at ascending so the home tenant (created at registration)
// naturally appears first.
func (r *tenantMemberRepository) ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error) {
var members []*types.TenantMember
err := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("joined_at ASC, id ASC").
Find(&members).Error
if err != nil {
return nil, err
}
return members, nil
}
// ListByTenant returns every active membership inside the tenant.
func (r *tenantMemberRepository) ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error) {
var members []*types.TenantMember
err := r.db.WithContext(ctx).
Where("tenant_id = ?", tenantID).
Order("joined_at ASC, id ASC").
Find(&members).Error
if err != nil {
return nil, err
}
return members, nil
}
// UpdateRole changes the role of an existing active membership.
func (r *tenantMemberRepository) UpdateRole(ctx context.Context, userID string, tenantID uint64, role types.TenantRole) error {
res := r.db.WithContext(ctx).
Model(&types.TenantMember{}).
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
Updates(map[string]any{
"role": role,
"updated_at": time.Now(),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// SoftDelete marks the membership row as deleted. GORM's soft-delete
// support populates DeletedAt automatically.
func (r *tenantMemberRepository) SoftDelete(ctx context.Context, userID string, tenantID uint64) error {
return r.db.WithContext(ctx).
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
Delete(&types.TenantMember{}).Error
}
// CountActiveOwners reports the number of active owner rows in the tenant.
func (r *tenantMemberRepository) CountActiveOwners(ctx context.Context, tenantID uint64) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&types.TenantMember{}).
Where("tenant_id = ? AND role = ? AND status = ?",
tenantID, types.TenantRoleOwner, types.TenantMemberStatusActive).
Count(&count).Error
return count, err
}
// HasAnyMembers reports whether the tenant has at least one active
// membership row.
func (r *tenantMemberRepository) HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&types.TenantMember{}).
Where("tenant_id = ? AND status = ?", tenantID, types.TenantMemberStatusActive).
Limit(1).
Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,188 @@
package service
import (
"context"
"errors"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// Sentinel errors returned by tenantMemberService. Callers compare with
// errors.Is to render appropriate HTTP responses (404 / 409 / 403).
var (
// ErrMembershipNotFound is returned when no active membership row
// matches the (user, tenant) pair the caller requested.
ErrMembershipNotFound = errors.New("tenant membership not found")
// ErrMembershipAlreadyExists is returned by AddMember when the
// (user, tenant) pair already has an active membership.
ErrMembershipAlreadyExists = errors.New("tenant membership already exists")
// ErrInvalidTenantRole is returned when the caller passes a role
// value that is not one of the four defined TenantRole constants.
ErrInvalidTenantRole = errors.New("invalid tenant role")
// ErrLastOwner is returned when an operation would leave the tenant
// without an active Owner. Demoting the last Owner or removing them
// is forbidden; an explicit ownership transfer must happen first.
ErrLastOwner = errors.New("cannot demote or remove the last active owner of the tenant")
)
// tenantMemberService implements interfaces.TenantMemberService.
type tenantMemberService struct {
repo interfaces.TenantMemberRepository
}
// NewTenantMemberService constructs the service. Wired up via the DI
// container alongside the other application services.
func NewTenantMemberService(repo interfaces.TenantMemberRepository) interfaces.TenantMemberService {
return &tenantMemberService{repo: repo}
}
// AddMember inserts a new active membership row. Returns
// ErrMembershipAlreadyExists if the user is already an active member of
// the tenant, and ErrInvalidTenantRole for unknown roles.
func (s *tenantMemberService) AddMember(
ctx context.Context,
userID string,
tenantID uint64,
role types.TenantRole,
invitedBy *string,
) (*types.TenantMember, error) {
if !role.IsValid() {
return nil, ErrInvalidTenantRole
}
existing, err := s.repo.Get(ctx, userID, tenantID)
if err != nil {
return nil, err
}
if existing != nil {
return nil, ErrMembershipAlreadyExists
}
member := &types.TenantMember{
UserID: userID,
TenantID: tenantID,
Role: role,
Status: types.TenantMemberStatusActive,
InvitedBy: invitedBy,
JoinedAt: time.Now(),
}
if err := s.repo.Create(ctx, member); err != nil {
return nil, err
}
return member, nil
}
// EnsureOwner is idempotent: if the user already has an active membership
// in the tenant it is returned unchanged; otherwise a new owner row is
// created. Used by Register/OIDC paths so re-running Register on an
// existing user (e.g. after a partial failure) does not double-insert.
func (s *tenantMemberService) EnsureOwner(
ctx context.Context,
userID string,
tenantID uint64,
) (*types.TenantMember, error) {
existing, err := s.repo.Get(ctx, userID, tenantID)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
member := &types.TenantMember{
UserID: userID,
TenantID: tenantID,
Role: types.TenantRoleOwner,
Status: types.TenantMemberStatusActive,
JoinedAt: time.Now(),
}
if err := s.repo.Create(ctx, member); err != nil {
return nil, err
}
logger.Infof(ctx, "Bootstrapped owner membership for user=%s tenant=%d", userID, tenantID)
return member, nil
}
// GetMembership returns the active membership or (nil, nil) when absent.
func (s *tenantMemberService) GetMembership(
ctx context.Context,
userID string,
tenantID uint64,
) (*types.TenantMember, error) {
return s.repo.Get(ctx, userID, tenantID)
}
// ListByUser proxies to the repository; included on the service so HTTP
// handlers depend only on the service interface.
func (s *tenantMemberService) ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error) {
return s.repo.ListByUser(ctx, userID)
}
// ListByTenant proxies to the repository.
func (s *tenantMemberService) ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error) {
return s.repo.ListByTenant(ctx, tenantID)
}
// HasAnyMembers proxies to the repository.
func (s *tenantMemberService) HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error) {
return s.repo.HasAnyMembers(ctx, tenantID)
}
// UpdateRole enforces the "cannot demote the last Owner" invariant before
// delegating to the repository. Re-promoting an existing Owner is a no-op
// from the invariant's perspective.
func (s *tenantMemberService) UpdateRole(
ctx context.Context,
userID string,
tenantID uint64,
newRole types.TenantRole,
) error {
if !newRole.IsValid() {
return ErrInvalidTenantRole
}
current, err := s.repo.Get(ctx, userID, tenantID)
if err != nil {
return err
}
if current == nil {
return ErrMembershipNotFound
}
if current.Role == newRole {
return nil
}
if current.Role == types.TenantRoleOwner && newRole != types.TenantRoleOwner {
owners, err := s.repo.CountActiveOwners(ctx, tenantID)
if err != nil {
return err
}
if owners <= 1 {
return ErrLastOwner
}
}
return s.repo.UpdateRole(ctx, userID, tenantID, newRole)
}
// RemoveMember enforces the "cannot remove the last Owner" invariant
// before soft-deleting the membership.
func (s *tenantMemberService) RemoveMember(ctx context.Context, userID string, tenantID uint64) error {
current, err := s.repo.Get(ctx, userID, tenantID)
if err != nil {
return err
}
if current == nil {
return ErrMembershipNotFound
}
if current.Role == types.TenantRoleOwner {
owners, err := s.repo.CountActiveOwners(ctx, tenantID)
if err != nil {
return err
}
if owners <= 1 {
return ErrLastOwner
}
}
return s.repo.SoftDelete(ctx, userID, tenantID)
}

View File

@@ -60,6 +60,7 @@ type userService struct {
userRepo interfaces.UserRepository
tokenRepo interfaces.AuthTokenRepository
tenantService interfaces.TenantService
memberService interfaces.TenantMemberService
config *config.Config
}
@@ -69,11 +70,13 @@ func NewUserService(
userRepo interfaces.UserRepository,
tokenRepo interfaces.AuthTokenRepository,
tenantService interfaces.TenantService,
memberService interfaces.TenantMemberService,
) interfaces.UserService {
return &userService{
userRepo: userRepo,
tokenRepo: tokenRepo,
tenantService: tenantService,
memberService: memberService,
config: configInfo,
}
}
@@ -137,6 +140,17 @@ func (s *userService) Register(ctx context.Context, req *types.RegisterRequest)
return nil, errors.New("failed to create user")
}
// Bootstrap an Owner membership so the registrant has full control over
// the tenant their account just created. Failure here only logs — the
// user record exists and the auth middleware's orphan-tenant recovery
// path will recreate the membership on next login.
if s.memberService != nil {
if _, err := s.memberService.EnsureOwner(ctx, user.ID, createdTenant.ID); err != nil {
logger.Errorf(ctx, "Failed to create owner membership for user %s tenant %d: %v",
user.ID, createdTenant.ID, err)
}
}
logger.Info(ctx, "User registered successfully")
return user, nil
}
@@ -201,17 +215,93 @@ func (s *userService) Login(ctx context.Context, req *types.LoginRequest) (*type
logger.Info(ctx, "Tenant information retrieved successfully")
}
memberships := s.buildMembershipsForUser(ctx, user, tenant)
logger.Info(ctx, "User logged in successfully")
return &types.LoginResponse{
Success: true,
Message: "Login successful",
User: user,
Tenant: tenant,
ActiveTenant: tenant,
Memberships: memberships,
Token: accessToken,
RefreshToken: refreshToken,
}, nil
}
// buildMembershipsForUser returns the user's tenant memberships projected
// into the login-response shape. activeTenant (if non-nil and matching one
// of the rows) is used to reuse its already-fetched name without a second
// DB lookup; other tenants are looked up individually. Errors are logged
// but never propagated — a missing memberships array degrades gracefully
// to length 0 rather than failing the whole login.
//
// When the membership service is unavailable (e.g. in tests that wire only
// part of the dependency graph), this falls back to a single synthesized
// row built from User.TenantID + the active tenant so callers always get
// at least one entry.
func (s *userService) buildMembershipsForUser(
ctx context.Context,
user *types.User,
activeTenant *types.Tenant,
) []types.Membership {
if user == nil {
return nil
}
if s.memberService == nil {
return synthFallbackMembership(user, activeTenant)
}
rows, err := s.memberService.ListByUser(ctx, user.ID)
if err != nil {
logger.Warnf(ctx, "Failed to list memberships for user %s: %v", user.ID, err)
return synthFallbackMembership(user, activeTenant)
}
if len(rows) == 0 {
return synthFallbackMembership(user, activeTenant)
}
out := make([]types.Membership, 0, len(rows))
for _, m := range rows {
if m == nil || m.Status != types.TenantMemberStatusActive {
continue
}
name := ""
if activeTenant != nil && m.TenantID == activeTenant.ID {
name = activeTenant.Name
} else if t, err := s.tenantService.GetTenantByID(ctx, m.TenantID); err == nil && t != nil {
name = t.Name
}
out = append(out, types.Membership{
TenantID: m.TenantID,
TenantName: name,
Role: m.Role,
})
}
if len(out) == 0 {
return synthFallbackMembership(user, activeTenant)
}
return out
}
// synthFallbackMembership returns a single-row membership list inferred
// from User.TenantID. Used when the membership table has not been
// populated yet (e.g. during the rollout window where the migration has
// run but the auth middleware's auto-promotion hasn't fired) so the
// response shape stays consistent.
func synthFallbackMembership(user *types.User, activeTenant *types.Tenant) []types.Membership {
if user == nil || user.TenantID == 0 {
return nil
}
name := ""
if activeTenant != nil && activeTenant.ID == user.TenantID {
name = activeTenant.Name
}
return []types.Membership{{
TenantID: user.TenantID,
TenantName: name,
Role: types.TenantRoleOwner, // safe default for legacy single-tenant users
}}
}
// GetOIDCAuthorizationURL builds the OIDC authorization URL.
func (s *userService) GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error) {
cfg, err := s.getOIDCConfig(ctx)
@@ -381,16 +471,31 @@ func (s *userService) ValidatePassword(ctx context.Context, userID string, passw
return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
}
// GenerateTokens generates access and refresh tokens for user
// GenerateTokens generates access and refresh tokens for user. The
// access token's tenant_id claim is set to user.TenantID — i.e. login
// always lands in the user's home tenant. SwitchTenant is the tool for
// pointing tokens at a different membership.
func (s *userService) GenerateTokens(
ctx context.Context,
user *types.User,
) (accessToken, refreshToken string, err error) {
return s.generateTokensForTenant(ctx, user, user.TenantID)
}
// generateTokensForTenant is the shared implementation behind
// GenerateTokens and SwitchTenant. It encodes activeTenantID into the
// access token's tenant_id claim so the auth middleware scopes future
// requests there.
func (s *userService) generateTokensForTenant(
ctx context.Context,
user *types.User,
activeTenantID uint64,
) (accessToken, refreshToken string, err error) {
// Generate access token (expires in 24 hours)
accessClaims := jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"tenant_id": user.TenantID,
"tenant_id": activeTenantID,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"type": "access",
@@ -443,6 +548,75 @@ func (s *userService) GenerateTokens(
return accessToken, refreshToken, nil
}
// SwitchTenant verifies that user has an active membership in
// targetTenantID and issues a new token pair scoped to that tenant.
// The previous refresh token (if provided) is revoked so the old session
// can no longer roll forward into the source tenant.
//
// Returns ErrMembershipNotFound when the user is not a member of the
// target tenant. Cross-tenant superuser access (CanAccessAllTenants)
// is allowed without a membership row, mirroring the auth middleware's
// resolveTenantRole behaviour.
func (s *userService) SwitchTenant(
ctx context.Context,
user *types.User,
targetTenantID uint64,
currentRefreshToken string,
) (*types.LoginResponse, error) {
if user == nil {
return nil, errors.New("user is required")
}
if targetTenantID == 0 {
return nil, errors.New("target tenant ID is required")
}
// Verify membership unless the caller is a cross-tenant superuser
// switching outside their home tenant.
if !user.CanAccessAllTenants || targetTenantID == user.TenantID {
if s.memberService == nil {
return nil, errors.New("tenant membership service unavailable")
}
member, err := s.memberService.GetMembership(ctx, user.ID, targetTenantID)
if err != nil {
return nil, fmt.Errorf("lookup membership: %w", err)
}
if member == nil || member.Status != types.TenantMemberStatusActive {
return nil, ErrMembershipNotFound
}
}
tenant, err := s.tenantService.GetTenantByID(ctx, targetTenantID)
if err != nil {
return nil, fmt.Errorf("load target tenant: %w", err)
}
accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, targetTenantID)
if err != nil {
return nil, fmt.Errorf("generate tokens: %w", err)
}
// Best-effort revoke of the previous refresh token. Failure is
// logged but not fatal — the new tokens are already issued and the
// old refresh token will expire naturally.
if strings.TrimSpace(currentRefreshToken) != "" {
if err := s.RevokeToken(ctx, currentRefreshToken); err != nil {
logger.Warnf(ctx, "Failed to revoke previous refresh token during tenant switch: %v", err)
}
}
memberships := s.buildMembershipsForUser(ctx, user, tenant)
return &types.LoginResponse{
Success: true,
Message: "Tenant switched",
User: user,
ActiveTenant: tenant,
Memberships: memberships,
Token: accessToken,
RefreshToken: refreshToken,
}, nil
}
// ValidateToken validates an access token
func (s *userService) ValidateToken(ctx context.Context, tokenString string) (*types.User, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {

View File

@@ -20,6 +20,7 @@ type Config struct {
Server *ServerConfig `yaml:"server" json:"server"`
KnowledgeBase *KnowledgeBaseConfig `yaml:"knowledge_base" json:"knowledge_base"`
Tenant *TenantConfig `yaml:"tenant" json:"tenant"`
Auth *AuthConfig `yaml:"auth" json:"auth"`
OIDCAuth *OIDCAuthConfig `yaml:"oidc_auth" json:"oidc_auth"`
Models []ModelConfig `yaml:"models" json:"models"`
VectorDatabase *VectorDatabaseConfig `yaml:"vector_database" json:"vector_database"`
@@ -173,6 +174,40 @@ type TenantConfig struct {
DefaultSessionDescription string `yaml:"default_session_description" json:"default_session_description"`
// EnableCrossTenantAccess enables cross-tenant access for users with permission
EnableCrossTenantAccess bool `yaml:"enable_cross_tenant_access" json:"enable_cross_tenant_access"`
// EnableRBAC turns on tenant-level role enforcement (issue #1303).
// Default false — the schema and tenant_members rows ship in a release
// where role lookups are observed but not enforced; flip to true once
// role assignments have been audited.
EnableRBAC bool `yaml:"enable_rbac" json:"enable_rbac"`
}
// AuthConfig governs the user authentication entry points.
type AuthConfig struct {
// RegistrationMode controls who may call POST /auth/register.
// "self_serve" (default) — anyone may register; a new tenant is
// auto-created and the registrant becomes
// its Owner. Preserves existing behaviour.
// "invite_only" — public registration is rejected; new
// users only enter through the invitation
// flow added in PR 3.
RegistrationMode string `yaml:"registration_mode" json:"registration_mode"`
}
// AuthRegistrationMode constants used by handlers and middleware.
const (
AuthRegistrationModeSelfServe = "self_serve"
AuthRegistrationModeInviteOnly = "invite_only"
)
// IsInviteOnly returns true when registration is gated behind invitations.
// Treats nil receiver and empty/unknown values as "not invite-only" so the
// default keeps current behaviour even if the section is missing from the
// config file.
func (c *AuthConfig) IsInviteOnly() bool {
if c == nil {
return false
}
return c.RegistrationMode == AuthRegistrationModeInviteOnly
}
type OIDCUserInfoMapping struct {
@@ -434,6 +469,7 @@ func LoadConfig() (*Config, error) {
// Validate configuration values
applyOIDCEnvOverrides(&cfg)
applyAgentEnvOverrides(&cfg)
applyAuthAndTenantDefaults(&cfg)
if err := ValidateConfig(&cfg); err != nil {
return nil, err
@@ -460,6 +496,14 @@ func ValidateConfig(cfg *Config) error {
}
}
if cfg.Auth != nil {
mode := strings.TrimSpace(cfg.Auth.RegistrationMode)
if mode != "" && mode != AuthRegistrationModeSelfServe && mode != AuthRegistrationModeInviteOnly {
errs = append(errs, fmt.Sprintf("auth.registration_mode must be %q or %q, got %q",
AuthRegistrationModeSelfServe, AuthRegistrationModeInviteOnly, mode))
}
}
if cfg.Conversation != nil {
if cfg.Conversation.EmbeddingTopK < 0 {
errs = append(errs, "conversation.embedding_top_k must be >= 0")
@@ -584,7 +628,37 @@ func applyAgentEnvOverrides(cfg *Config) {
}
}
// backfillConversationDefaults resolves prompt template ID references
// applyAuthAndTenantDefaults fills in defaults for the Auth and Tenant
// config sections and applies env-var overrides that operators commonly use
// to enable RBAC or switch registration mode without editing config.yaml.
//
// Defaults:
// - auth.registration_mode -> "self_serve" (preserves pre-RBAC behaviour)
// - tenant.enable_rbac -> false (rolled out in two steps; flip later)
//
// Env overrides (when set and non-empty):
// - WEKNORA_AUTH_REGISTRATION_MODE ("self_serve" or "invite_only")
// - WEKNORA_TENANT_ENABLE_RBAC ("true"/"false", case-insensitive)
func applyAuthAndTenantDefaults(cfg *Config) {
if cfg.Auth == nil {
cfg.Auth = &AuthConfig{}
}
if cfg.Tenant == nil {
cfg.Tenant = &TenantConfig{}
}
if value := strings.TrimSpace(os.Getenv("WEKNORA_AUTH_REGISTRATION_MODE")); value != "" {
cfg.Auth.RegistrationMode = value
}
if strings.TrimSpace(cfg.Auth.RegistrationMode) == "" {
cfg.Auth.RegistrationMode = AuthRegistrationModeSelfServe
}
if value := strings.TrimSpace(os.Getenv("WEKNORA_TENANT_ENABLE_RBAC")); value != "" {
cfg.Tenant.EnableRBAC = strings.EqualFold(value, "true")
}
}
// into actual prompt text content. Only xxx_id fields are used;
// no fallback to default templates.
func backfillConversationDefaults(cfg *Config) {

View File

@@ -135,6 +135,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
// Data repositories layer
logger.Debugf(ctx, "[Container] Registering repositories...")
must(container.Provide(repository.NewTenantRepository))
must(container.Provide(repository.NewTenantMemberRepository))
must(container.Provide(repository.NewKnowledgeBaseRepository))
must(container.Provide(repository.NewKnowledgeRepository))
must(container.Provide(repository.NewChunkRepository))
@@ -168,6 +169,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
// Business service layer
logger.Debugf(ctx, "[Container] Registering business services...")
must(container.Provide(service.NewTenantService))
must(container.Provide(service.NewTenantMemberService))
must(container.Provide(service.NewKnowledgeBaseService))
must(container.Provide(service.NewOrganizationService))
must(container.Provide(service.NewKBShareService)) // KBShareService must be registered before KnowledgeService and KnowledgeTagService

View File

@@ -506,6 +506,79 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
})
}
// GetAuthConfig godoc
// @Summary 获取认证配置
// @Description 返回当前部署的注册模式等公开认证配置,供前端决定是否展示注册入口
// @Tags 认证
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "认证配置"
// @Router /auth/config [get]
//
// GetAuthConfig is intentionally a no-auth endpoint: the frontend reads
// it on app load to decide whether to show the Register tab. We expose
// 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
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"registration_mode": mode,
})
}
// SwitchTenant godoc
// @Summary 切换激活租户
// @Description 为当前用户在目标租户重新签发访问令牌;要求该用户在目标租户存在 active 成员关系
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body object{tenant_id=integer,refresh_token=string} true "切换请求"
// @Success 200 {object} types.LoginResponse
// @Failure 400 {object} errors.AppError "参数错误"
// @Failure 403 {object} errors.AppError "无该租户成员关系"
// @Security Bearer
// @Router /auth/switch-tenant [post]
//
// SwitchTenant is the v1 backend hook for the tenant-switcher UI added
// in PR 3. The current PR ships the endpoint so multi-tenant tests can
// exercise the membership flow end-to-end before the frontend lands.
func (h *AuthHandler) SwitchTenant(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
TenantID uint64 `json:"tenant_id" binding:"required"`
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
appErr := errors.NewValidationError("Invalid switch-tenant request").WithDetails(err.Error())
c.Error(appErr)
return
}
user, err := h.userService.GetCurrentUser(ctx)
if err != nil || user == nil {
appErr := errors.NewUnauthorizedError("not authenticated")
c.Error(appErr)
return
}
resp, err := h.userService.SwitchTenant(ctx, user, req.TenantID, req.RefreshToken)
if err != nil {
logger.Errorf(ctx, "SwitchTenant failed user=%s target=%d: %v", user.ID, req.TenantID, err)
appErr := errors.NewForbiddenError("switch tenant failed").WithDetails(err.Error())
c.Error(appErr)
return
}
c.JSON(http.StatusOK, resp)
}
// AutoSetup godoc
// @Summary 自动初始化Lite 桌面版)
// @Description Lite 版专用:首次启动时自动创建默认用户和租户并返回令牌,后续启动直接签发令牌,免除手动注册/登录流程
@@ -573,12 +646,27 @@ func (h *AuthHandler) AutoSetup(c *gin.Context) {
Success: true,
Message: "Auto-setup successful",
User: user,
Tenant: tenant,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: user.TenantID,
TenantName: tenantNameOrEmpty(tenant),
Role: types.TenantRoleOwner,
}},
Token: accessToken,
RefreshToken: refreshToken,
})
}
// tenantNameOrEmpty returns t.Name when t is non-nil, "" otherwise.
// Used by AutoSetup to populate Membership.TenantName without crashing
// if the tenant lookup failed.
func tenantNameOrEmpty(t *types.Tenant) string {
if t == nil {
return ""
}
return t.Name
}
// ValidateToken godoc
// @Summary 验证令牌
// @Description 验证访问令牌是否有效

View File

@@ -23,6 +23,7 @@ var noAuthAPI = map[string][]string{
"/api/v1/auth/register": {"POST"},
"/api/v1/auth/login": {"POST"},
"/api/v1/auth/auto-setup": {"POST"},
"/api/v1/auth/config": {"GET"},
"/api/v1/auth/oidc/config": {"GET"},
"/api/v1/auth/oidc/url": {"GET"},
"/api/v1/auth/oidc/callback": {"GET"},
@@ -67,6 +68,7 @@ func canAccessTenant(user *types.User, targetTenantID uint64, cfg *config.Config
func Auth(
tenantService interfaces.TenantService,
userService interfaces.UserService,
memberService interfaces.TenantMemberService,
cfg *config.Config,
) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -91,6 +93,7 @@ func Auth(
// JWT Token认证成功
// 检查是否有跨租户访问请求
targetTenantID := user.TenantID
crossTenantSwitch := false
tenantHeader := c.GetHeader("X-Tenant-ID")
if tenantHeader != "" {
// 解析目标租户ID
@@ -102,6 +105,7 @@ func Auth(
targetTenant, err := tenantService.GetTenantByID(c.Request.Context(), parsedTenantID)
if err == nil && targetTenant != nil {
targetTenantID = parsedTenantID
crossTenantSwitch = parsedTenantID != user.TenantID
log.Printf("User %s switching to tenant %d", user.ID, targetTenantID)
} else {
log.Printf("Error getting target tenant by ID: %v, tenantID: %d", err, parsedTenantID)
@@ -134,23 +138,32 @@ func Auth(
return
}
// 解析当前租户内的角色 (issue #1303)
role, ok := resolveTenantRole(c.Request.Context(), memberService, user, targetTenantID, crossTenantSwitch, cfg)
if !ok {
// 强制 RBAC 时,缺少 active membership 即拒绝fail-open 路径已在
// resolveTenantRole 内部处理。
log.Printf("User %s has no active membership in tenant %d", user.ID, targetTenantID)
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: not a member of the target tenant",
})
c.Abort()
return
}
// 存储用户和租户信息到上下文
c.Set(types.TenantIDContextKey.String(), targetTenantID)
c.Set(types.TenantInfoContextKey.String(), tenant)
c.Set(types.UserContextKey.String(), user)
c.Set(types.UserIDContextKey.String(), user.ID)
c.Request = c.Request.WithContext(
context.WithValue(
context.WithValue(
context.WithValue(
context.WithValue(c.Request.Context(), types.TenantIDContextKey, targetTenantID),
types.TenantInfoContextKey, tenant,
),
types.UserContextKey, user,
),
types.UserIDContextKey, user.ID,
),
)
c.Set(types.TenantRoleContextKey.String(), role)
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)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}
@@ -210,12 +223,14 @@ func Auth(
}
log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID)
}
// API-Key 走的是程序化全租户访问,固定授予 Admin 角色:可以做几乎所有事情,
// 但保留 Owner-only 操作(删除租户、修改租户级配置)的边界。
c.Set(types.UserContextKey.String(), user)
c.Set(types.UserIDContextKey.String(), user.ID)
ctx = context.WithValue(
context.WithValue(ctx, types.UserContextKey, user),
types.UserIDContextKey, user.ID,
)
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin)
ctx = context.WithValue(ctx, types.UserContextKey, user)
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
c.Request = c.Request.WithContext(ctx)
c.Next()
@@ -228,6 +243,70 @@ func Auth(
}
}
// resolveTenantRole determines the caller's TenantRole inside targetTenantID.
//
// Order of resolution:
// 1. Active TenantMember row → return that role.
// 2. Cross-tenant superuser switch (X-Tenant-ID with CanAccessAllTenants=true)
// → grant Admin in the target tenant. Org admins are intentionally not
// promoted to Owner; tenant deletion / API-key rotation should always
// stay with a real Owner inside the target tenant.
// 3. No membership but the tenant currently has zero active members
// (an API-key-only orphan tenant gaining its first human member) →
// auto-promote the user to Owner.
// 4. Otherwise → return ok=false. Caller decides:
// - When EnableRBAC=true (or cfg unavailable): treat as 403.
// - When EnableRBAC=false: fail open with Admin so existing deployments
// don't break in the rollout window where memberships might lag user
// records.
//
// The boolean second return value reports whether enforcement should reject
// the request. It is true whenever a usable role was found OR fail-open
// applies; false only when we want callers to abort with 403.
func resolveTenantRole(
ctx context.Context,
memberService interfaces.TenantMemberService,
user *types.User,
targetTenantID uint64,
crossTenantSwitch bool,
cfg *config.Config,
) (types.TenantRole, bool) {
// 1. 正常成员关系
member, err := memberService.GetMembership(ctx, user.ID, targetTenantID)
if err == nil && member != nil && member.Status == types.TenantMemberStatusActive {
return member.Role, true
}
if err != nil {
log.Printf("tenant_members lookup failed user=%s tenant=%d: %v", user.ID, targetTenantID, err)
// Fall through to the fail-open branch below; treat lookup errors
// the same as "no membership found" so a transient DB hiccup
// doesn't lock everyone out.
}
// 2. 跨租户超管直通CanAccessAllTenants 用户切到别的租户时不强制要求 membership
if crossTenantSwitch && user.CanAccessAllTenants {
return types.TenantRoleAdmin, true
}
// 3. 孤儿租户自愈API-key-only 租户首个登录的人类用户自动成为 Owner
hasAny, anyErr := memberService.HasAnyMembers(ctx, targetTenantID)
if anyErr == nil && !hasAny {
if _, e := memberService.AddMember(ctx, user.ID, targetTenantID, types.TenantRoleOwner, nil); e == nil {
log.Printf("Auto-promoted user %s to Owner of orphan tenant %d", user.ID, targetTenantID)
return types.TenantRoleOwner, true
} else {
log.Printf("Failed to auto-promote user %s in tenant %d: %v", user.ID, targetTenantID, e)
}
}
// 4. 兜底:根据 EnableRBAC 决定 fail-closed 还是 fail-open
if cfg != nil && cfg.Tenant != nil && cfg.Tenant.EnableRBAC {
return "", false
}
// fail-open 期间保持现有行为(每个登录用户在自己租户里都是“管理员”)。
return types.TenantRoleAdmin, true
}
// GetTenantIDFromContext helper function to get tenant ID from context
func GetTenantIDFromContext(ctx context.Context) (uint64, error) {
tenantID, ok := ctx.Value("tenantID").(uint64)

View File

@@ -48,6 +48,7 @@ type RouterParams struct {
KnowledgeHandler *handler.KnowledgeHandler
TenantHandler *handler.TenantHandler
TenantService interfaces.TenantService
TenantMemberService interfaces.TenantMemberService
ChunkHandler *handler.ChunkHandler
SessionHandler *session.Handler
MessageHandler *handler.MessageHandler
@@ -118,7 +119,7 @@ func NewRouter(params RouterParams) *gin.Engine {
RegisterIMRoutes(r, params.IMHandler)
// 认证中间件
r.Use(middleware.Auth(params.TenantService, params.UserService, params.Config))
r.Use(middleware.Auth(params.TenantService, params.UserService, params.TenantMemberService, params.Config))
// 文件服务:统一代理本地/MinIO/COS/TOS存储后端需要认证
serveFiles(r, params.FileService)
@@ -431,6 +432,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler) {
r.POST("/auth/register", handler.Register)
r.POST("/auth/login", handler.Login)
r.POST("/auth/auto-setup", handler.AutoSetup)
r.GET("/auth/config", handler.GetAuthConfig)
r.POST("/auth/switch-tenant", handler.SwitchTenant)
r.GET("/auth/oidc/config", handler.GetOIDCConfig)
r.GET("/auth/oidc/url", handler.GetOIDCAuthorizationURL)
r.GET("/auth/oidc/callback", handler.OIDCRedirectCallback)

View File

@@ -16,6 +16,10 @@ const (
UserContextKey ContextKey = "User"
// UserIDContextKey is the context key for user ID
UserIDContextKey ContextKey = "UserID"
// TenantRoleContextKey is the context key for the caller's TenantRole
// in the currently active tenant (loaded by the auth middleware from
// the tenant_members table). See TenantRoleFromContext.
TenantRoleContextKey ContextKey = "TenantRole"
// SessionTenantIDContextKey is the context key for session owner's tenant ID.
// When set (e.g. in pipeline with shared agent), session/message lookups use this instead of TenantIDContextKey.
SessionTenantIDContextKey ContextKey = "SessionTenantID"

View File

@@ -54,6 +54,18 @@ func UserIDFromContext(ctx context.Context) (string, bool) {
return v, ok && v != ""
}
// TenantRoleFromContext extracts the caller's TenantRole in the currently
// active tenant. Returns TenantRoleViewer when the key is absent so that
// callers fail closed (least privilege) if the auth middleware did not
// populate the role for some reason.
func TenantRoleFromContext(ctx context.Context) TenantRole {
v, ok := ctx.Value(TenantRoleContextKey).(TenantRole)
if !ok || !v.IsValid() {
return TenantRoleViewer
}
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) {

View File

@@ -75,6 +75,11 @@ type CustomAgent struct {
TenantID uint64 `yaml:"tenant_id" json:"tenant_id" gorm:"primaryKey"`
// Created by user ID
CreatedBy string `yaml:"created_by" json:"created_by" gorm:"type:varchar(36)"`
// RunnableByViewer controls whether users with TenantRoleViewer can
// start chat sessions against this agent. Defaults to true so viewers
// can still consume published agents; admins toggle it off for agents
// whose tools should be restricted to contributors and above.
RunnableByViewer bool `yaml:"runnable_by_viewer" json:"runnable_by_viewer" gorm:"default:true"`
// Agent configuration
Config CustomAgentConfig `yaml:"config" json:"config" gorm:"type:json"`

View File

@@ -0,0 +1,50 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantMemberRepository persists (user, tenant) membership rows that
// carry the per-tenant TenantRole.
//
// All methods operate on active rows only (deleted_at IS NULL) unless the
// docstring explicitly says otherwise. Soft deletion is handled by GORM
// via the DeletedAt field on TenantMember.
type TenantMemberRepository interface {
// Create inserts a new active membership row. Caller is responsible
// for ensuring no other active row exists for the same (user, tenant)
// pair; the partial unique index will return a conflict error otherwise.
Create(ctx context.Context, member *types.TenantMember) error
// Get returns the active membership for the given (user, tenant) pair,
// or (nil, nil) if no such row exists.
Get(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error)
// ListByUser returns every active membership owned by the given user,
// ordered by joined_at ascending.
ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error)
// ListByTenant returns every active membership inside the given tenant,
// ordered by joined_at ascending.
ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error)
// UpdateRole changes the role of an existing active membership. Returns
// gorm.ErrRecordNotFound if no active row matches.
UpdateRole(ctx context.Context, userID string, tenantID uint64, role types.TenantRole) error
// SoftDelete marks the active membership as deleted. The user record
// itself is untouched.
SoftDelete(ctx context.Context, userID string, tenantID uint64) error
// CountActiveOwners reports how many active rows in the tenant carry
// the owner role. Used by service-layer invariant checks ("cannot
// remove the last owner").
CountActiveOwners(ctx context.Context, tenantID uint64) (int64, error)
// HasAnyMembers reports whether the tenant has at least one active
// membership. Used by the auth middleware to decide whether to
// auto-promote the first authenticating human in an API-key-only tenant.
HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error)
}

View File

@@ -0,0 +1,50 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantMemberService is the business-logic layer over TenantMemberRepository.
// It enforces tenant-RBAC invariants such as "every tenant with members must
// keep at least one active Owner". HTTP handlers and other services call this
// interface rather than the repository directly so the invariants cannot be
// silently bypassed.
type TenantMemberService interface {
// AddMember inserts a new active membership row. Returns an error if
// (user, tenant) already has an active membership.
AddMember(ctx context.Context, userID string, tenantID uint64, role types.TenantRole, invitedBy *string) (*types.TenantMember, error)
// EnsureOwner is an idempotent helper used by the registration flow:
// if the user already has an active membership in the tenant, return
// it; otherwise create one with role=owner. This is the common path
// for self-service registration where the registrant becomes the
// Owner of the tenant their account just created.
EnsureOwner(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error)
// GetMembership returns the active (user, tenant) membership, or
// (nil, nil) if no such row exists.
GetMembership(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error)
// ListByUser returns every active membership owned by the user.
ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error)
// ListByTenant returns every active membership inside the tenant.
ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error)
// HasAnyMembers reports whether the tenant has at least one active
// member. The auth middleware uses this to recover orphan tenants
// (e.g. API-key-only tenants that never had a human member): the
// first human authenticating into such a tenant is auto-promoted
// to Owner.
HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error)
// UpdateRole changes the role of an existing membership while
// enforcing the "cannot demote the last active Owner" invariant.
UpdateRole(ctx context.Context, userID string, tenantID uint64, newRole types.TenantRole) error
// RemoveMember soft-deletes the membership while enforcing the
// "cannot remove the last active Owner" invariant.
RemoveMember(ctx context.Context, userID string, tenantID uint64) error
}

View File

@@ -34,6 +34,11 @@ type UserService interface {
ValidatePassword(ctx context.Context, userID string, password string) error
// GenerateTokens generates access and refresh tokens for user
GenerateTokens(ctx context.Context, user *types.User) (accessToken, refreshToken string, err error)
// SwitchTenant issues a new token pair scoped to targetTenantID and
// returns the corresponding LoginResponse. The caller's previous
// refresh token (passed in for revocation) is invalidated. Membership
// is verified via the TenantMember service before tokens are issued.
SwitchTenant(ctx context.Context, user *types.User, targetTenantID uint64, currentRefreshToken string) (*types.LoginResponse, error)
// ValidateToken validates an access token
ValidateToken(ctx context.Context, token string) (*types.User, error)
// RefreshToken refreshes access token using refresh token

View File

@@ -51,6 +51,12 @@ type KnowledgeBase struct {
Description string `yaml:"description" json:"description"`
// Tenant ID
TenantID uint64 `yaml:"tenant_id" json:"tenant_id"`
// CreatorID records the user ID of whoever originally created the KB.
// Used by the tenant-level RBAC middleware to let Contributors edit
// their own KBs without granting them access to everyone else's.
// Nullable for backward compatibility with rows created before the
// RBAC migration backfilled the column to the tenant Owner.
CreatorID string `yaml:"creator_id" json:"creator_id" gorm:"type:varchar(36);index"`
// Chunking configuration
ChunkingConfig ChunkingConfig `yaml:"chunking_config" json:"chunking_config" gorm:"type:json"`
// Image processing configuration

View File

@@ -0,0 +1,121 @@
package types
import (
"time"
"gorm.io/gorm"
)
// TenantRole represents a user's role inside a single tenant.
//
// Tenant roles govern intra-tenant authority (who can create/edit/delete
// resources, manage tenant settings, etc.) and are orthogonal to the
// OrgMemberRole defined in organization.go, which governs cross-tenant
// sharing. A user may therefore carry different TenantRole values in
// different tenants (one TenantMember row per (user, tenant) pair).
type TenantRole string
const (
// TenantRoleOwner has full control over the tenant, including tenant
// deletion, ownership transfer, and rotating the tenant API key.
TenantRoleOwner TenantRole = "owner"
// TenantRoleAdmin manages users, integrations, and tenant-scoped
// configuration such as model providers, vector stores, MCP services
// and IM channels, but cannot delete the tenant or change Owners.
TenantRoleAdmin TenantRole = "admin"
// TenantRoleContributor can create knowledge bases and agents, and edit
// the ones they created. They have read access to everything else in
// the tenant.
TenantRoleContributor TenantRole = "contributor"
// TenantRoleViewer has read-only access to tenant resources and can
// run agents that are explicitly marked as runnable by viewers.
TenantRoleViewer TenantRole = "viewer"
)
// tenantRoleLevel maps each role to a numeric level used for hierarchy
// comparisons. Higher means more privileged. Levels are spaced by 10 so
// new roles can be inserted between existing ones if needed.
var tenantRoleLevel = map[TenantRole]int{
TenantRoleOwner: 40,
TenantRoleAdmin: 30,
TenantRoleContributor: 20,
TenantRoleViewer: 10,
}
// IsValid reports whether r is one of the four defined tenant roles.
func (r TenantRole) IsValid() bool {
_, ok := tenantRoleLevel[r]
return ok
}
// Level returns the numeric privilege level of the role. Unknown roles
// return 0, which is strictly less than any defined role.
func (r TenantRole) Level() int {
return tenantRoleLevel[r]
}
// HasPermission reports whether r is at least as privileged as required.
// Used by RequireRole-style middleware to gate endpoints.
func (r TenantRole) HasPermission(required TenantRole) bool {
return r.Level() >= required.Level()
}
// TenantMemberStatus enumerates the lifecycle states of a membership row.
type TenantMemberStatus string
const (
// TenantMemberStatusActive is the normal membership state; the user
// can authenticate into the tenant and is subject to their role.
TenantMemberStatusActive TenantMemberStatus = "active"
// TenantMemberStatusInvited represents a pending invitation that has
// not yet been accepted. The auth middleware treats this as "not a
// member" until the status flips to active.
TenantMemberStatusInvited TenantMemberStatus = "invited"
// TenantMemberStatusSuspended is an admin-revoked membership. The
// row is preserved for audit trail but the user cannot authenticate
// into the tenant.
TenantMemberStatusSuspended TenantMemberStatus = "suspended"
)
// TenantMember represents the (user, tenant) membership record that
// carries the user's TenantRole for that specific tenant.
//
// A user has one TenantMember row per tenant they belong to. The home
// tenant recorded on User.TenantID is always one of these rows; additional
// rows are created when an admin adds the user to another tenant.
type TenantMember struct {
// Surrogate primary key.
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
// UserID references users.id. Together with TenantID forms the logical
// key enforced by the partial unique index uniq_user_tenant.
UserID string `json:"user_id" gorm:"type:varchar(36);not null;index"`
// TenantID references tenants.id.
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
// Role held by the user inside this tenant.
Role TenantRole `json:"role" gorm:"type:varchar(20);not null;default:'contributor'"`
// Status controls whether this membership is honoured by the auth
// middleware; see TenantMemberStatus constants.
Status TenantMemberStatus `json:"status" gorm:"type:varchar(20);not null;default:'active'"`
// InvitedBy records the user ID of the admin who created this row via
// an invitation flow. Nil for rows created by self-service registration.
InvitedBy *string `json:"invited_by,omitempty" gorm:"type:varchar(36)"`
// JoinedAt is when the membership became active.
JoinedAt time.Time `json:"joined_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// TableName binds TenantMember to the tenant_members table.
func (TenantMember) TableName() string {
return "tenant_members"
}
// Membership is the login-response-friendly projection of a TenantMember
// joined with tenant name. Returned as part of LoginResponse so the
// frontend can render a tenant switcher and gate UI by role.
type Membership struct {
TenantID uint64 `json:"tenant_id"`
TenantName string `json:"tenant_name"`
Role TenantRole `json:"role"`
}

View File

@@ -103,12 +103,20 @@ type RegisterRequest struct {
// LoginResponse represents a login response
type LoginResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *User `json:"user,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *User `json:"user,omitempty"`
// ActiveTenant is the tenant whose ID is encoded in the issued JWT;
// future requests are scoped to it until the client calls /auth/switch-tenant.
// Defaults to the user's home tenant on a fresh login.
ActiveTenant *Tenant `json:"active_tenant,omitempty"`
// Memberships lists every tenant the user can authenticate into,
// along with their role in each. Always populated (length 1 for users
// who only belong to their home tenant) so frontends can render a
// tenant switcher without a follow-up request.
Memberships []Membership `json:"memberships,omitempty"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// RegisterResponse represents a registration response

View File

@@ -9,6 +9,7 @@ DROP TABLE IF EXISTS mcp_tool_approvals;
DROP TABLE IF EXISTS mcp_services;
DROP TABLE IF EXISTS knowledge_tags;
DROP TABLE IF EXISTS auth_tokens;
DROP TABLE IF EXISTS tenant_members;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS chunks;
DROP TABLE IF EXISTS messages;

View File

@@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS knowledge_bases (
pinned_at DATETIME NULL,
asr_config TEXT,
vector_store_id VARCHAR(36),
creator_id VARCHAR(36),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
@@ -76,6 +77,8 @@ CREATE TABLE IF NOT EXISTS knowledge_bases (
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_tenant_id ON knowledge_bases(tenant_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_tenant_vector_store
ON knowledge_bases(tenant_id, vector_store_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_tenant_creator
ON knowledge_bases(tenant_id, creator_id);
CREATE TABLE IF NOT EXISTS knowledges (
id VARCHAR(36) PRIMARY KEY,
@@ -245,6 +248,29 @@ CREATE INDEX IF NOT EXISTS idx_auth_tokens_token ON auth_tokens(token);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_token_type ON auth_tokens(token_type);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_expires_at ON auth_tokens(expires_at);
-- tenant_members carries the per-(user, tenant) TenantRole used by the
-- tenant-level RBAC introduced in #1303. SQLite does not support partial
-- indexes the same way Postgres does, so we use a plain unique index on
-- (user_id, tenant_id) — soft-deleted rows are filtered by the GORM scope.
CREATE TABLE IF NOT EXISTS tenant_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id VARCHAR(36) NOT NULL,
tenant_id INTEGER NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'contributor',
status VARCHAR(20) NOT NULL DEFAULT 'active',
invited_by VARCHAR(36),
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_members_user_tenant_unique
ON tenant_members(user_id, tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenant_members_tenant_role
ON tenant_members(tenant_id, role);
CREATE INDEX IF NOT EXISTS idx_tenant_members_user
ON tenant_members(user_id);
CREATE TABLE IF NOT EXISTS knowledge_tags (
id VARCHAR(36) PRIMARY KEY,
tenant_id INTEGER NOT NULL,
@@ -308,6 +334,7 @@ CREATE TABLE IF NOT EXISTS custom_agents (
is_builtin BOOLEAN NOT NULL DEFAULT 0,
tenant_id INTEGER NOT NULL,
created_by VARCHAR(36),
runnable_by_viewer BOOLEAN NOT NULL DEFAULT 1,
config TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,

View File

@@ -0,0 +1,16 @@
-- Migration 000043 down: revert tenant RBAC schema additions.
-- Note: this drops role information unrecoverably; only intended for
-- development rollbacks, not production.
DO $$ BEGIN RAISE NOTICE '[Migration 000043 down] Reverting tenant RBAC...'; END $$;
ALTER TABLE custom_agents DROP COLUMN IF EXISTS runnable_by_viewer;
DROP INDEX IF EXISTS idx_knowledge_bases_tenant_creator;
ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS creator_id;
DROP INDEX IF EXISTS idx_tenant_members_user;
DROP INDEX IF EXISTS idx_tenant_members_tenant_role;
DROP INDEX IF EXISTS idx_tenant_members_user_tenant_unique;
DROP TABLE IF EXISTS tenant_members;
DO $$ BEGIN RAISE NOTICE '[Migration 000043 down] tenant RBAC reverted'; END $$;

View File

@@ -0,0 +1,107 @@
-- Migration: 000043_tenant_rbac
-- Introduces tenant-level RBAC (issue #1303):
-- 1. tenant_members table holds the (user, tenant) role assignments that replace
-- the coarse "User.TenantID only" model. A user may now have rows in multiple
-- tenants with potentially different roles.
-- 2. knowledge_bases.creator_id records who created a KB so Contributors can edit
-- their own without full tenant-wide edit rights. custom_agents.created_by
-- already exists and is reused as-is.
-- 3. custom_agents.runnable_by_viewer controls whether TenantRoleViewer users
-- may start sessions against an agent.
--
-- Backfill policy (existing data):
-- - In each tenant, the earliest-created active user becomes 'owner'; any other
-- users become 'contributor'. This preserves today's "anyone can create KBs"
-- behaviour for non-first users while giving each tenant exactly one owner.
-- - knowledge_bases.creator_id is set to that tenant's owner, so Admins/Owners
-- keep full control and Contributors do not unexpectedly inherit ownership of
-- pre-existing resources.
-- - API-key-only tenants (tenants with no human users) get no membership rows;
-- the auth middleware auto-promotes the first human authenticating into such
-- a tenant to Owner.
DO $$ BEGIN RAISE NOTICE '[Migration 000043] Starting tenant RBAC setup...'; END $$;
-- 1. tenant_members table
DO $$ BEGIN RAISE NOTICE '[Migration 000043] Creating table: tenant_members'; END $$;
CREATE TABLE IF NOT EXISTS tenant_members (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
tenant_id INTEGER NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'contributor',
status VARCHAR(20) NOT NULL DEFAULT 'active',
invited_by VARCHAR(36),
joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Partial unique index: at most one non-deleted membership per (user, tenant).
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_members_user_tenant_unique
ON tenant_members(user_id, tenant_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_tenant_members_tenant_role
ON tenant_members(tenant_id, role)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_tenant_members_user
ON tenant_members(user_id)
WHERE deleted_at IS NULL;
-- 2. Backfill one membership row per existing active user.
-- Earliest-created active user per tenant => owner; others => contributor.
DO $$ BEGIN RAISE NOTICE '[Migration 000043] Backfilling tenant_members rows from users'; END $$;
INSERT INTO tenant_members (user_id, tenant_id, role, status, joined_at, created_at, updated_at)
SELECT u.id,
u.tenant_id,
CASE
WHEN u.id = (
SELECT u2.id FROM users u2
WHERE u2.tenant_id = u.tenant_id
AND u2.is_active = TRUE
AND u2.deleted_at IS NULL
ORDER BY u2.created_at ASC, u2.id ASC
LIMIT 1
) THEN 'owner'
ELSE 'contributor'
END AS role,
'active',
u.created_at,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM users u
WHERE u.deleted_at IS NULL
AND u.is_active = TRUE
AND u.tenant_id IS NOT NULL
AND u.tenant_id <> 0
ON CONFLICT DO NOTHING;
-- 3. knowledge_bases.creator_id
DO $$ BEGIN RAISE NOTICE '[Migration 000043] Adding creator_id to knowledge_bases'; END $$;
ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS creator_id VARCHAR(36);
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_tenant_creator
ON knowledge_bases(tenant_id, creator_id);
-- Backfill KB creator to the tenant's owner. Rows in tenants without any human
-- users (API-key-only) keep creator_id NULL; the application layer treats NULL
-- creator as "tenant-owned" and requires Admin+ to mutate.
UPDATE knowledge_bases kb
SET creator_id = (
SELECT tm.user_id
FROM tenant_members tm
WHERE tm.tenant_id = kb.tenant_id
AND tm.role = 'owner'
AND tm.status = 'active'
AND tm.deleted_at IS NULL
ORDER BY tm.joined_at ASC, tm.id ASC
LIMIT 1
)
WHERE kb.creator_id IS NULL;
-- 4. custom_agents.runnable_by_viewer
DO $$ BEGIN RAISE NOTICE '[Migration 000043] Adding runnable_by_viewer to custom_agents'; END $$;
ALTER TABLE custom_agents
ADD COLUMN IF NOT EXISTS runnable_by_viewer BOOLEAN NOT NULL DEFAULT TRUE;
DO $$ BEGIN RAISE NOTICE '[Migration 000043] tenant RBAC setup ready'; END $$;