585 lines
21 KiB
Go
585 lines
21 KiB
Go
package middleware
|
||
|
||
import (
|
||
"context"
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Tencent/WeKnora/internal/config"
|
||
"github.com/Tencent/WeKnora/internal/logger"
|
||
"github.com/Tencent/WeKnora/internal/types"
|
||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
)
|
||
|
||
const (
|
||
defaultExternalUserIDHeader = "X-External-User-ID"
|
||
defaultExternalUserTokenHeader = "X-External-User-Token"
|
||
maxExternalUserIDLen = 128
|
||
maxExternalUserTokenTTL = 24 * time.Hour
|
||
)
|
||
|
||
var (
|
||
errMissingDirectHeader = errors.New("missing external user id header")
|
||
errInvalidExternalUserID = errors.New("invalid external user id")
|
||
errInvalidExternalUserToken = errors.New("invalid external user token")
|
||
)
|
||
|
||
// 无需认证的API列表
|
||
var noAuthAPI = map[string][]string{
|
||
"/health": {"GET"},
|
||
"/api/v1/auth/register": {"POST"},
|
||
"/api/v1/auth/login": {"POST"},
|
||
"/api/v1/auth/auto-setup": {"POST"},
|
||
// Share-link surfaces accept a plaintext invite token from anonymous
|
||
// callers (an invitee who hasn't registered yet). They are registered
|
||
// as public routes in RegisterAuthRoutes and rate-limited by IP, so the
|
||
// global Auth middleware must let them through — otherwise opening a
|
||
// share link while logged out 401s and the frontend bounces the user to
|
||
// /login instead of the register page (issue #1617).
|
||
"/api/v1/auth/invitations/lookup": {"POST"},
|
||
"/api/v1/auth/register-by-invite": {"POST"},
|
||
"/api/v1/auth/config": {"GET"},
|
||
"/api/v1/auth/oidc/config": {"GET"},
|
||
"/api/v1/auth/oidc/url": {"GET"},
|
||
"/api/v1/auth/oidc/callback": {"GET"},
|
||
// MCP OAuth provider redirect: the third-party authorization server
|
||
// redirects the browser here without a WeKnora bearer token. The request
|
||
// is authenticated by the opaque, single-use `state` parameter instead.
|
||
"/api/v1/mcp-oauth/callback": {"GET"},
|
||
"/api/v1/auth/refresh": {"POST"},
|
||
// IM platforms (Feishu, Slack, etc.) commonly issue a HEAD request
|
||
// before GET to validate Content-Type / Content-Length when rendering
|
||
// image previews — both verbs must be allowed for image links to work.
|
||
"/api/v1/files/presigned": {"GET", "HEAD"},
|
||
}
|
||
|
||
// 检查请求是否在无需认证的API列表中
|
||
func isNoAuthAPI(path string, method string) bool {
|
||
for api, methods := range noAuthAPI {
|
||
// 如果以*结尾,按照前缀匹配,否则按照全路径匹配
|
||
if strings.HasSuffix(api, "*") {
|
||
if strings.HasPrefix(path, strings.TrimSuffix(api, "*")) && slices.Contains(methods, method) {
|
||
return true
|
||
}
|
||
} else if path == api && slices.Contains(methods, method) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Auth 认证中间件
|
||
func Auth(
|
||
tenantService interfaces.TenantService,
|
||
userService interfaces.UserService,
|
||
memberService interfaces.TenantMemberService,
|
||
cfg *config.Config,
|
||
) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// ignore OPTIONS request
|
||
if c.Request.Method == "OPTIONS" {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 检查请求是否在无需认证的API列表中
|
||
if isNoAuthAPI(c.Request.URL.Path, c.Request.Method) {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 尝试JWT Token认证
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||
user, jwtTenantID, err := userService.ValidateToken(c.Request.Context(), token)
|
||
if err == nil && user != nil {
|
||
// JWT Token认证成功
|
||
// 默认 target = JWT 里的 tenant_id(来自登录或 /auth/switch-tenant),
|
||
// 兼容 ValidateToken 的 fallback:claim 缺失时 jwtTenantID == user.TenantID。
|
||
targetTenantID := jwtTenantID
|
||
if targetTenantID == 0 {
|
||
targetTenantID = user.TenantID
|
||
}
|
||
crossTenantSwitch := targetTenantID != user.TenantID
|
||
tenantHeader := c.GetHeader("X-Tenant-ID")
|
||
if tenantHeader != "" {
|
||
// 解析目标租户ID。畸形 / 零值必须显式拒绝:静默忽略会让坏掉的
|
||
// 前端/SDK 悄悄写错租户,反而看不到问题。与 RequirePathTenantMatch
|
||
// 中对 :id 的校验保持一致(非空、可解析、>0)。
|
||
parsedTenantID, err := strconv.ParseUint(tenantHeader, 10, 64)
|
||
if err != nil || parsedTenantID == 0 {
|
||
logger.Warnf(c.Request.Context(),
|
||
"Invalid X-Tenant-ID header from user=%s: %q (err=%v)",
|
||
user.ID, tenantHeader, err)
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"error": "Invalid X-Tenant-ID header",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
// 检查用户是否有权限访问目标租户:自家租户、跨租户超管、或
|
||
// 有 active membership 行——三选一,由 IsTenantAccessible
|
||
// 统一判定。
|
||
if IsTenantAccessible(c.Request.Context(), user, parsedTenantID, memberService, cfg) {
|
||
// 验证目标租户是否存在
|
||
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)
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"error": "Invalid target tenant ID",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
} else {
|
||
// 用户没有权限访问目标租户
|
||
log.Printf("User %s attempted to access tenant %d without permission", user.ID, parsedTenantID)
|
||
c.JSON(http.StatusForbidden, gin.H{
|
||
"error": "Forbidden: insufficient permissions to access target tenant",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 获取租户信息(使用目标租户ID)
|
||
tenant, err := tenantService.GetTenantByID(c.Request.Context(), targetTenantID)
|
||
if err != nil {
|
||
log.Printf("Error getting tenant by ID: %v, tenantID: %d, userID: %s", err, targetTenantID, user.ID)
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"error": "Unauthorized: invalid tenant",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 解析当前租户内的角色 (issue #1303)
|
||
role, ok := resolveTenantRole(c.Request.Context(), memberService, user, targetTenantID, crossTenantSwitch, cfg)
|
||
if !ok {
|
||
// 强制 RBAC 时,缺少 active membership 即拒绝;fail-open 路径已在
|
||
// resolveTenantRole 内部处理。
|
||
logger.Warnf(c.Request.Context(),
|
||
"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
|
||
}
|
||
|
||
// 存储用户和租户信息到上下文
|
||
logger.Infof(c.Request.Context(),
|
||
"[auth] resolved role=%s for user=%s in tenant=%d (jwt_tenant=%d, header=%q, cross_switch=%v)",
|
||
role, user.ID, targetTenantID, jwtTenantID, tenantHeader, crossTenantSwitch)
|
||
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.Set(types.TenantRoleContextKey.String(), role)
|
||
c.Set(types.SystemAdminContextKey.String(), user.IsSystemAdmin)
|
||
ctx := c.Request.Context()
|
||
ctx = context.WithValue(ctx, types.TenantIDContextKey, targetTenantID)
|
||
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
|
||
ctx = context.WithValue(ctx, types.UserContextKey, user)
|
||
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
|
||
principal := types.Principal{Type: types.PrincipalWebUser, ID: user.ID}
|
||
ctx = types.WithPrincipal(ctx, principal)
|
||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, role)
|
||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, user.IsSystemAdmin)
|
||
c.Set(types.PrincipalContextKey.String(), principal)
|
||
c.Request = c.Request.WithContext(ctx)
|
||
c.Next()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 尝试X-API-Key认证(兼容模式)
|
||
apiKey := c.GetHeader("X-API-Key")
|
||
if apiKey != "" {
|
||
// Get tenant information
|
||
tenantID, err := tenantService.ExtractTenantIDFromAPIKey(apiKey)
|
||
if err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"error": "Unauthorized: invalid API key format",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// Verify API key validity (matches the one in database)
|
||
t, err := tenantService.GetTenantByID(c.Request.Context(), tenantID)
|
||
if err != nil {
|
||
log.Printf("Error getting tenant by ID: %v, tenantID: %d", err, tenantID)
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"error": "Unauthorized: invalid API key",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
if t == nil || subtle.ConstantTimeCompare([]byte(t.APIKey), []byte(apiKey)) != 1 {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"error": "Unauthorized: invalid API key",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 存储租户和用户信息到上下文
|
||
c.Set(types.TenantIDContextKey.String(), tenantID)
|
||
c.Set(types.TenantInfoContextKey.String(), t)
|
||
|
||
ctx := context.WithValue(
|
||
context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID),
|
||
types.TenantInfoContextKey, t,
|
||
)
|
||
|
||
// 通过 TenantID 关联查询用户;找不到时构造系统虚拟用户,
|
||
// 确保所有依赖 UserContextKey 的下游 handler 正常工作。
|
||
user, err := userService.GetUserByTenantID(c.Request.Context(), tenantID)
|
||
if err != nil || user == nil {
|
||
// Synthetic user. The "system-<tenantID>" shape is recognised
|
||
// by types.IsSyntheticUserID, which RBAC service-layer code
|
||
// uses to skip recording these IDs as a resource creator.
|
||
// Do NOT change the prefix or numeric suffix without
|
||
// updating that helper, otherwise KB/Agent CreatorID will
|
||
// silently start pointing at the synthetic user again.
|
||
user = &types.User{
|
||
ID: fmt.Sprintf("system-%d", tenantID),
|
||
Username: fmt.Sprintf("system-%d", tenantID),
|
||
Email: fmt.Sprintf("system-%d@api-key.local", tenantID),
|
||
TenantID: tenantID,
|
||
IsActive: true,
|
||
}
|
||
log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID)
|
||
}
|
||
// API-Key 走的是程序化全租户访问,固定授予 Admin 角色:可以做几乎所有事情,
|
||
// 但保留 Owner-only 操作(删除租户、修改租户级配置)的边界。
|
||
//
|
||
// 显式拒绝 SystemAdmin:API key 通常被存放在 CI / IaC / sidecar 里,
|
||
// 泄露面比 JWT 大得多。即便 key 关联的 user 在 DB 里恰好是 SystemAdmin
|
||
// (例如部署里只有一个用户、自己创建了 tenant 又生成了 API key),
|
||
// 也绝不允许通过这条通道走平台级管理操作(promote/revoke、全局设置)。
|
||
// 平台管理必须走交互式 JWT 登录,留下可追责的人类身份。
|
||
c.Set(types.UserContextKey.String(), user)
|
||
c.Set(types.UserIDContextKey.String(), user.ID)
|
||
principal, principalErr := resolveAPIPrincipal(c.Request.Context(), t, c.Request.Header)
|
||
if principalErr != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"error": apiPrincipalAuthErrorMessage(principalErr),
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
c.Set(types.PrincipalContextKey.String(), principal)
|
||
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin)
|
||
c.Set(types.SystemAdminContextKey.String(), false)
|
||
ctx = context.WithValue(ctx, types.UserContextKey, user)
|
||
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
|
||
ctx = types.WithPrincipal(ctx, principal)
|
||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, false)
|
||
|
||
c.Request = c.Request.WithContext(ctx)
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 没有提供任何认证信息
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: missing authentication"})
|
||
c.Abort()
|
||
}
|
||
}
|
||
|
||
func resolveAPIPrincipal(ctx context.Context, tenant *types.Tenant, header http.Header) (types.Principal, error) {
|
||
tenantID := uint64(0)
|
||
if tenant != nil {
|
||
tenantID = tenant.ID
|
||
}
|
||
fallback := types.Principal{
|
||
Type: types.PrincipalAPITenant,
|
||
ID: strconv.FormatUint(tenantID, 10),
|
||
}
|
||
if tenant == nil || tenantID == 0 {
|
||
return fallback, nil
|
||
}
|
||
cfg := tenant.APIPrincipalConfig
|
||
if cfg == nil || cfg.Mode == "" || cfg.Mode == types.APIPrincipalModeTenant {
|
||
return fallback, nil
|
||
}
|
||
switch cfg.Mode {
|
||
case types.APIPrincipalModeDirect:
|
||
externalUserID := strings.TrimSpace(header.Get(defaultExternalUserIDHeader))
|
||
if externalUserID == "" {
|
||
if cfg.RequireDirectHeader {
|
||
return types.Principal{}, errMissingDirectHeader
|
||
}
|
||
return fallback, nil
|
||
}
|
||
if err := validateExternalUserID(externalUserID); err != nil {
|
||
return types.Principal{}, fmt.Errorf("%w: %v", errInvalidExternalUserID, err)
|
||
}
|
||
return types.Principal{
|
||
Type: types.PrincipalAPIExternalUser,
|
||
ID: strconv.FormatUint(tenantID, 10) + ":" + externalUserID,
|
||
}, nil
|
||
case types.APIPrincipalModeSignedToken:
|
||
externalUserID, err := verifyExternalUserJWT(header.Get(defaultExternalUserTokenHeader), tenantID, cfg.HMACSecret)
|
||
if err != nil || externalUserID == "" {
|
||
logger.Warnf(ctx, "invalid external user token for tenant=%d: %v", tenantID, err)
|
||
return types.Principal{}, fmt.Errorf("%w: %w", errInvalidExternalUserToken, err)
|
||
}
|
||
if err := validateExternalUserID(externalUserID); err != nil {
|
||
return types.Principal{}, fmt.Errorf("%w: %v", errInvalidExternalUserID, err)
|
||
}
|
||
return types.Principal{
|
||
Type: types.PrincipalAPIExternalUser,
|
||
ID: strconv.FormatUint(tenantID, 10) + ":" + externalUserID,
|
||
}, nil
|
||
default:
|
||
return fallback, nil
|
||
}
|
||
}
|
||
|
||
func verifyExternalUserJWT(tokenString string, tenantID uint64, secret string) (string, error) {
|
||
tokenString = strings.TrimSpace(tokenString)
|
||
secret = strings.TrimSpace(secret)
|
||
if tokenString == "" {
|
||
return "", errors.New("missing external user token")
|
||
}
|
||
if secret == "" {
|
||
return "", errors.New("external user token secret is not configured")
|
||
}
|
||
claims := jwt.MapClaims{}
|
||
parser := jwt.NewParser(
|
||
jwt.WithAudience("weknora"),
|
||
jwt.WithExpirationRequired(),
|
||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||
)
|
||
token, err := parser.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||
}
|
||
return []byte(secret), nil
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if token == nil || !token.Valid {
|
||
return "", errors.New("invalid external user token")
|
||
}
|
||
exp, err := claims.GetExpirationTime()
|
||
if err != nil || exp == nil {
|
||
return "", errors.New("missing expiration")
|
||
}
|
||
if time.Until(exp.Time) > maxExternalUserTokenTTL {
|
||
return "", fmt.Errorf("token lifetime exceeds %s", maxExternalUserTokenTTL)
|
||
}
|
||
if nbf, nbfErr := claims.GetNotBefore(); nbfErr == nil && nbf != nil && time.Now().Before(nbf.Time) {
|
||
return "", errors.New("token not yet valid")
|
||
}
|
||
if got := principalTenantIDFromClaims(claims); got != tenantID {
|
||
return "", fmt.Errorf("tenant mismatch: got %d want %d", got, tenantID)
|
||
}
|
||
sub, _ := claims["sub"].(string)
|
||
sub = strings.TrimSpace(sub)
|
||
if sub == "" {
|
||
return "", errors.New("missing subject")
|
||
}
|
||
return sub, nil
|
||
}
|
||
|
||
func validateExternalUserID(id string) error {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
return errors.New("empty external user id")
|
||
}
|
||
if len(id) > maxExternalUserIDLen {
|
||
return fmt.Errorf("external user id too long (max %d)", maxExternalUserIDLen)
|
||
}
|
||
for _, r := range id {
|
||
if r < 0x20 || r == 0x7f {
|
||
return errors.New("external user id contains invalid characters")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func apiPrincipalAuthErrorMessage(err error) string {
|
||
switch {
|
||
case errors.Is(err, errMissingDirectHeader):
|
||
return "Unauthorized: missing external user id header"
|
||
case errors.Is(err, errInvalidExternalUserID):
|
||
return "Unauthorized: invalid external user id"
|
||
case errors.Is(err, errInvalidExternalUserToken):
|
||
return "Unauthorized: invalid external user token"
|
||
default:
|
||
return "Unauthorized: invalid external user token"
|
||
}
|
||
}
|
||
|
||
func principalTenantIDFromClaims(claims jwt.MapClaims) uint64 {
|
||
v, ok := claims["tenant_id"]
|
||
if !ok {
|
||
return 0
|
||
}
|
||
switch t := v.(type) {
|
||
case float64:
|
||
if t <= 0 {
|
||
return 0
|
||
}
|
||
return uint64(t)
|
||
case int64:
|
||
if t <= 0 {
|
||
return 0
|
||
}
|
||
return uint64(t)
|
||
case uint64:
|
||
return t
|
||
case json.Number:
|
||
n, err := strconv.ParseUint(t.String(), 10, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
case string:
|
||
n, err := strconv.ParseUint(strings.TrimSpace(t), 10, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 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. Cross-tenant access
|
||
// is also never allowed to trigger the orphan-tenant auto-promotion
|
||
// below — a superuser only visits, never claims ownership.
|
||
// 3. No membership but the tenant currently has zero active members AND
|
||
// the caller is authenticating into their own home tenant (i.e.
|
||
// targetTenantID == user.TenantID and this is not a cross-tenant
|
||
// switch). This is the API-key-only orphan-tenant self-heal path:
|
||
// the registrant becomes Owner of the tenant their own user record
|
||
// points to. Any other path (cross-tenant switch, JWT minted for a
|
||
// foreign tenant, etc.) is intentionally excluded to avoid silent
|
||
// ownership grabs.
|
||
// 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 {
|
||
logger.Infof(ctx,
|
||
"[auth] resolveTenantRole step1 hit: user=%s tenant=%d row_role=%s row_status=%s",
|
||
user.ID, targetTenantID, member.Role, member.Status)
|
||
return member.Role, true
|
||
}
|
||
if err != nil {
|
||
logger.Warnf(ctx, "tenant_members lookup failed user=%s tenant=%d: %v",
|
||
user.ID, targetTenantID, err)
|
||
// Fall through; treat lookup errors the same as "no membership
|
||
// found" so a transient DB hiccup doesn't lock everyone out.
|
||
} else {
|
||
var statusInfo string
|
||
if member == nil {
|
||
statusInfo = "no_row"
|
||
} else {
|
||
statusInfo = "row_exists status=" + string(member.Status) + " role=" + string(member.Role)
|
||
}
|
||
logger.Warnf(ctx,
|
||
"[auth] resolveTenantRole step1 miss: user=%s tenant=%d (%s)",
|
||
user.ID, targetTenantID, statusInfo)
|
||
}
|
||
|
||
// 2. 跨租户超管直通:CanAccessAllTenants 用户切到别的租户时不强制要求 membership。
|
||
// 注意:这里只授予临时 Admin 角色,不写入 tenant_members,避免"看一眼别人租户"
|
||
// 意外升级为持久化所有权。
|
||
if crossTenantSwitch && user.CanAccessAllTenants {
|
||
logger.Infof(ctx,
|
||
"[auth] resolveTenantRole step2 (cross-tenant superuser) -> Admin: user=%s tenant=%d",
|
||
user.ID, targetTenantID)
|
||
return types.TenantRoleAdmin, true
|
||
}
|
||
|
||
// 3. 孤儿租户自愈:仅当用户登录的是自己的 home tenant、且该租户尚无任何活跃成员时
|
||
// 允许自动晋升为 Owner。跨租户 switch / JWT 指向他人租户的场景一律不进入此分支,
|
||
// 防止越权获得他人租户的 Owner 权限。
|
||
isHomeTenant := !crossTenantSwitch && targetTenantID == user.TenantID
|
||
if isHomeTenant {
|
||
hasAny, anyErr := memberService.HasAnyMembers(ctx, targetTenantID)
|
||
if anyErr == nil && !hasAny {
|
||
if _, e := memberService.AddMember(
|
||
ctx, user.ID, targetTenantID, types.TenantRoleOwner, nil,
|
||
); e == nil {
|
||
logger.Infof(ctx,
|
||
"[audit] Auto-promoted user %s to Owner of orphan tenant %d (home_tenant=true)",
|
||
user.ID, targetTenantID,
|
||
)
|
||
return types.TenantRoleOwner, true
|
||
} else {
|
||
logger.Warnf(ctx, "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.IsRBACEnforced() {
|
||
logger.Warnf(ctx,
|
||
"[auth] resolveTenantRole step4 fail-closed (EnableRBAC=true): user=%s tenant=%d",
|
||
user.ID, targetTenantID)
|
||
return "", false
|
||
}
|
||
logger.Warnf(ctx,
|
||
"[auth] resolveTenantRole step4 fail-open (EnableRBAC=false) -> Admin: user=%s tenant=%d",
|
||
user.ID, targetTenantID)
|
||
// 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)
|
||
if !ok {
|
||
return 0, errors.New("tenant ID not found in context")
|
||
}
|
||
return tenantID, nil
|
||
}
|