mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
127
internal/application/repository/tenant_member.go
Normal file
127
internal/application/repository/tenant_member.go
Normal 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
|
||||
}
|
||||
188
internal/application/service/tenant_member.go
Normal file
188
internal/application/service/tenant_member.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 验证访问令牌是否有效
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
50
internal/types/interfaces/tenant_member.go
Normal file
50
internal/types/interfaces/tenant_member.go
Normal 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)
|
||||
}
|
||||
50
internal/types/interfaces/tenant_member_service.go
Normal file
50
internal/types/interfaces/tenant_member_service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
121
internal/types/tenant_member.go
Normal file
121
internal/types/tenant_member.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
migrations/versioned/000043_tenant_rbac.down.sql
Normal file
16
migrations/versioned/000043_tenant_rbac.down.sql
Normal 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 $$;
|
||||
107
migrations/versioned/000043_tenant_rbac.up.sql
Normal file
107
migrations/versioned/000043_tenant_rbac.up.sql
Normal 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 $$;
|
||||
Reference in New Issue
Block a user