mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-30 19:33:05 +08:00
refactor(auth): unify session-context attachment and split the Auth middleware
The five near-identical "c.Set + context.WithValue" blocks (JWT branch,
tenantless branch, both API-key attach helpers, EmbedAuth) now go through
one applyAuthSession helper, so the c.Keys surface and the request-context
surface can no longer drift apart. The 140-line JWT branch inside Auth is
split into authenticateJWTUser / resolveTargetTenant, which also removes a
duplicate GetTenantByID roundtrip on the X-Tenant-ID switch path.
Fixes folded in:
- delete GetTenantIDFromContext: it read ctx.Value("tenantID") while the
value is stored under types.TenantIDContextKey ("TenantID"), so it could
never succeed (dead, broken, uncalled)
- isPlatformTenantOptionalAPI: match "/api/v1/system/admin" exactly or as
a "/"-delimited prefix instead of a bare HasPrefix that would also admit
sibling paths sharing the prefix
- 401 for a presented-but-invalid Bearer token now says "invalid or
expired token" instead of the misleading "missing authentication"
- replace the remaining log.Printf calls in the auth path with the
request-scoped structured logger
New tests lock the both-surfaces invariant of applyAuthSession, the
tenantless "no tenant/role keys" contract, and bearerToken parsing.
This commit is contained in:
+225
-199
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -105,20 +104,24 @@ func isTenantOptionalAPI(path, method string) bool {
|
||||
}
|
||||
|
||||
func attachTenantlessUserContext(c *gin.Context, user *types.User) {
|
||||
principal := types.Principal{Type: types.PrincipalWebUser, ID: user.ID}
|
||||
c.Set(types.UserContextKey.String(), user)
|
||||
c.Set(types.UserIDContextKey.String(), user.ID)
|
||||
c.Set(types.SystemAdminContextKey.String(), user.IsSystemAdmin)
|
||||
c.Set(types.PrincipalContextKey.String(), principal)
|
||||
ctx := c.Request.Context()
|
||||
ctx = context.WithValue(ctx, types.UserContextKey, user)
|
||||
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, user.IsSystemAdmin)
|
||||
ctx = types.WithPrincipal(ctx, principal)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
applyAuthSession(c, authSession{
|
||||
User: user,
|
||||
Principal: types.Principal{Type: types.PrincipalWebUser, ID: user.ID},
|
||||
SystemAdmin: user.IsSystemAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// Auth 认证中间件
|
||||
// Auth 认证中间件。按顺序尝试三条通道:
|
||||
//
|
||||
// 1. 白名单(isNoAuthAPI)/ OPTIONS 预检 —— 直接放行;
|
||||
// 2. Bearer JWT —— 成功则走 authenticateJWTUser 完成空间/角色解析;
|
||||
// 校验失败不立即拒绝,继续尝试 X-API-Key(保持既有兼容行为:
|
||||
// 携带过期 JWT 但同时带有效 API key 的客户端仍可通过);
|
||||
// 3. X-API-Key —— authenticateAPIKeyRequest。
|
||||
//
|
||||
// 三条通道都未命中时返回 401;若调用方提交过 Bearer token,错误消息
|
||||
// 明确指出 token 无效而不是笼统的 "missing authentication",方便客户端
|
||||
// 区分「没登录」和「登录态过期」。
|
||||
func Auth(
|
||||
tenantService interfaces.TenantService,
|
||||
userService interfaces.UserService,
|
||||
@@ -128,7 +131,7 @@ func Auth(
|
||||
) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// ignore OPTIONS request
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -140,155 +143,199 @@ func Auth(
|
||||
}
|
||||
|
||||
// 尝试JWT Token认证
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
bearerPresented := false
|
||||
if token, ok := bearerToken(c); ok {
|
||||
bearerPresented = true
|
||||
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 workspace 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 workspace",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if targetTenantID == 0 {
|
||||
targetTenantID = resolveFirstMembershipTarget(c.Request.Context(), user, memberService, tenantService)
|
||||
crossTenantSwitch = targetTenantID != user.TenantID
|
||||
}
|
||||
|
||||
if targetTenantID == 0 {
|
||||
if isTenantOptionalAPI(c.Request.URL.Path, c.Request.Method) {
|
||||
attachTenantlessUserContext(c, user)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "Workspace required",
|
||||
"code": "TENANT_REQUIRED",
|
||||
})
|
||||
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 workspace",
|
||||
})
|
||||
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 workspace",
|
||||
})
|
||||
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 != "" {
|
||||
if apiKeyService != nil {
|
||||
if authenticateAPIKeyRequest(c, tenantService, userService, apiKeyService, apiKey) {
|
||||
if authenticateJWTUser(c, tenantService, memberService, cfg, user, jwtTenantID) {
|
||||
c.Next()
|
||||
}
|
||||
return
|
||||
}
|
||||
logger.Warnf(c.Request.Context(), "[auth] bearer token rejected: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: API key service is not configured"})
|
||||
c.Abort()
|
||||
// 尝试X-API-Key认证(兼容模式)
|
||||
if apiKey := c.GetHeader("X-API-Key"); apiKey != "" {
|
||||
if apiKeyService == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: API key service is not configured"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if authenticateAPIKeyRequest(c, tenantService, userService, apiKeyService, apiKey) {
|
||||
c.Next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 没有提供任何认证信息
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: missing authentication"})
|
||||
// 没有任何通道认证成功
|
||||
if bearerPresented {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid or expired token"})
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: missing authentication"})
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken extracts the Bearer token from the Authorization header.
|
||||
func bearerToken(c *gin.Context) (string, bool) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimPrefix(authHeader, "Bearer "), true
|
||||
}
|
||||
|
||||
// authenticateJWTUser finishes authentication for a validated JWT user:
|
||||
// it resolves the target tenant (X-Tenant-ID switch / JWT claim / first
|
||||
// active membership), resolves the caller's role inside that tenant, and
|
||||
// attaches the session context. Returns true when the request may proceed;
|
||||
// on false the response has already been written and the request aborted.
|
||||
func authenticateJWTUser(
|
||||
c *gin.Context,
|
||||
tenantService interfaces.TenantService,
|
||||
memberService interfaces.TenantMemberService,
|
||||
cfg *config.Config,
|
||||
user *types.User,
|
||||
jwtTenantID uint64,
|
||||
) bool {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
targetTenantID, tenant, crossTenantSwitch, ok := resolveTargetTenant(c, tenantService, memberService, cfg, user, jwtTenantID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if targetTenantID == 0 {
|
||||
// 无可用空间:身份级路由(/auth/me 等)放行为 tenantless 会话,
|
||||
// 其余路由返回 TENANT_REQUIRED 让前端引导用户创建/加入空间。
|
||||
if isTenantOptionalAPI(c.Request.URL.Path, c.Request.Method) {
|
||||
attachTenantlessUserContext(c, user)
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "Workspace required",
|
||||
"code": "TENANT_REQUIRED",
|
||||
})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取空间信息(X-Tenant-ID 切换路径已在 resolveTargetTenant 内取到,
|
||||
// 避免二次查库)。
|
||||
if tenant == nil {
|
||||
var err error
|
||||
tenant, err = tenantService.GetTenantByID(ctx, targetTenantID)
|
||||
if err != nil || tenant == nil {
|
||||
logger.Warnf(ctx, "[auth] tenant lookup failed: tenant=%d user=%s err=%v", targetTenantID, user.ID, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized: invalid workspace",
|
||||
})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 解析当前空间内的角色 (issue #1303)
|
||||
role, ok := resolveTenantRole(ctx, memberService, user, targetTenantID, crossTenantSwitch, cfg)
|
||||
if !ok {
|
||||
// 强制 RBAC 时,缺少 active membership 即拒绝;fail-open 路径已在
|
||||
// resolveTenantRole 内部处理。
|
||||
logger.Warnf(ctx, "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 workspace",
|
||||
})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
logger.Infof(ctx,
|
||||
"[auth] resolved role=%s for user=%s in tenant=%d (jwt_tenant=%d, header=%q, cross_switch=%v)",
|
||||
role, user.ID, targetTenantID, jwtTenantID, c.GetHeader("X-Tenant-ID"), crossTenantSwitch)
|
||||
applyAuthSession(c, authSession{
|
||||
User: user,
|
||||
Principal: types.Principal{Type: types.PrincipalWebUser, ID: user.ID},
|
||||
TenantID: targetTenantID,
|
||||
Tenant: tenant,
|
||||
Role: role,
|
||||
SystemAdmin: user.IsSystemAdmin,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveTargetTenant decides which tenant this request operates in.
|
||||
//
|
||||
// Priority:
|
||||
// 1. X-Tenant-ID header — must parse to a positive integer, the user must
|
||||
// be allowed to access it (home tenant / cross-tenant superuser / active
|
||||
// membership, see IsTenantAccessible) and the tenant must exist. The
|
||||
// fetched tenant is returned so the caller doesn't refetch it.
|
||||
// 2. JWT tenant claim (falling back to user.TenantID when the claim is 0).
|
||||
// 3. First active membership — lets a tenantless session become usable as
|
||||
// soon as an invitation is accepted (see resolveFirstMembershipTarget).
|
||||
//
|
||||
// Returns ok=false when the response has already been written (malformed
|
||||
// header, inaccessible or missing target tenant). targetTenantID == 0 with
|
||||
// ok=true means "authenticated but no usable workspace" — the caller decides
|
||||
// between tenantless routes and TENANT_REQUIRED.
|
||||
func resolveTargetTenant(
|
||||
c *gin.Context,
|
||||
tenantService interfaces.TenantService,
|
||||
memberService interfaces.TenantMemberService,
|
||||
cfg *config.Config,
|
||||
user *types.User,
|
||||
jwtTenantID uint64,
|
||||
) (targetTenantID uint64, tenant *types.Tenant, crossTenantSwitch bool, ok bool) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 默认 target = JWT 里的 tenant_id(来自登录或 /auth/switch-tenant),
|
||||
// 兼容 ValidateToken 的 fallback:claim 缺失时 jwtTenantID == user.TenantID。
|
||||
targetTenantID = jwtTenantID
|
||||
if targetTenantID == 0 {
|
||||
targetTenantID = user.TenantID
|
||||
}
|
||||
|
||||
if tenantHeader := c.GetHeader("X-Tenant-ID"); tenantHeader != "" {
|
||||
// 解析目标空间ID。畸形 / 零值必须显式拒绝:静默忽略会让坏掉的
|
||||
// 前端/SDK 悄悄写错空间,反而看不到问题。与 RequirePathTenantMatch
|
||||
// 中对 :id 的校验保持一致(非空、可解析、>0)。
|
||||
parsedTenantID, err := strconv.ParseUint(tenantHeader, 10, 64)
|
||||
if err != nil || parsedTenantID == 0 {
|
||||
logger.Warnf(ctx, "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 0, nil, false, false
|
||||
}
|
||||
// 检查用户是否有权限访问目标空间:自家空间、跨空间超管、或
|
||||
// 有 active membership 行——三选一,由 IsTenantAccessible 统一判定。
|
||||
if !IsTenantAccessible(ctx, user, parsedTenantID, memberService, cfg) {
|
||||
logger.Warnf(ctx, "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 workspace",
|
||||
})
|
||||
c.Abort()
|
||||
return 0, nil, false, false
|
||||
}
|
||||
// 验证目标空间是否存在
|
||||
targetTenant, err := tenantService.GetTenantByID(ctx, parsedTenantID)
|
||||
if err != nil || targetTenant == nil {
|
||||
logger.Warnf(ctx, "Error getting target tenant by ID: %v, tenantID: %d", err, parsedTenantID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target workspace ID"})
|
||||
c.Abort()
|
||||
return 0, nil, false, false
|
||||
}
|
||||
logger.Infof(ctx, "User %s switching to tenant %d", user.ID, parsedTenantID)
|
||||
return parsedTenantID, targetTenant, parsedTenantID != user.TenantID, true
|
||||
}
|
||||
|
||||
if targetTenantID == 0 {
|
||||
targetTenantID = resolveFirstMembershipTarget(ctx, user, memberService, tenantService)
|
||||
}
|
||||
return targetTenantID, nil, targetTenantID != user.TenantID, true
|
||||
}
|
||||
|
||||
// resolveFirstMembershipTarget lets a tenantless session immediately become
|
||||
// usable once an active membership exists (for example after accepting its
|
||||
// first invitation or being added directly by an administrator). The user
|
||||
@@ -393,7 +440,9 @@ func authenticateAPIKeyRequest(
|
||||
|
||||
func isPlatformTenantOptionalAPI(path, method string) bool {
|
||||
path = strings.TrimSuffix(strings.TrimSpace(path), "/")
|
||||
if strings.HasPrefix(path, "/api/v1/system/admin") {
|
||||
// 精确匹配 admin 控制面前缀("/api/v1/system/admin" 本身或其子路径)。
|
||||
// 裸 HasPrefix 会误放行诸如 "/api/v1/system/admin-foo" 的同前缀路径。
|
||||
if path == "/api/v1/system/admin" || strings.HasPrefix(path, "/api/v1/system/admin/") {
|
||||
return true
|
||||
}
|
||||
if method == http.MethodGet && (path == "/api/v1/tenants/all" || path == "/api/v1/tenants/search") {
|
||||
@@ -404,25 +453,20 @@ func isPlatformTenantOptionalAPI(path, method string) bool {
|
||||
|
||||
func attachPlatformAPIKeyAuthContext(c *gin.Context, key *types.TenantAPIKey) {
|
||||
principal, user := platformAPIKeyIdentity(key)
|
||||
userID := user.ID
|
||||
c.Set(types.UserContextKey.String(), user)
|
||||
c.Set(types.UserIDContextKey.String(), userID)
|
||||
c.Set(types.PrincipalContextKey.String(), principal)
|
||||
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleViewer)
|
||||
c.Set(types.SystemAdminContextKey.String(), false)
|
||||
ctx := c.Request.Context()
|
||||
ctx = context.WithValue(ctx, types.UserContextKey, user)
|
||||
ctx = context.WithValue(ctx, types.UserIDContextKey, userID)
|
||||
ctx = types.WithPrincipal(ctx, principal)
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
|
||||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, false)
|
||||
ctx = types.WithTenantAPIKeyScope(ctx, types.TenantAPIKeyScope{
|
||||
KeyID: key.ID,
|
||||
ScopeType: types.APIKeyScopePlatform,
|
||||
FullAccess: false,
|
||||
Capabilities: key.Capabilities,
|
||||
applyAuthSession(c, authSession{
|
||||
User: user,
|
||||
Principal: principal,
|
||||
// This role context exists only for legacy guard compatibility after
|
||||
// RequireRole short-circuits API-key principals; the key's real
|
||||
// authority is its platform capabilities enforced by the APIKeyGate.
|
||||
Role: types.TenantRoleViewer,
|
||||
APIKeyScope: &types.TenantAPIKeyScope{
|
||||
KeyID: key.ID,
|
||||
ScopeType: types.APIKeyScopePlatform,
|
||||
FullAccess: false,
|
||||
Capabilities: key.Capabilities,
|
||||
},
|
||||
})
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
func platformAPIKeyIdentity(key *types.TenantAPIKey) (types.Principal, *types.User) {
|
||||
@@ -449,19 +493,12 @@ func attachAPIKeyAuthContext(
|
||||
) {
|
||||
t, err := tenantService.GetTenantByID(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting tenant by ID: %v, tenantID: %d", err, tenantID)
|
||||
logger.Warnf(c.Request.Context(), "[auth] API key tenant lookup failed: tenant=%d err=%v", tenantID, err)
|
||||
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,
|
||||
)
|
||||
|
||||
var user *types.User
|
||||
var principal types.Principal
|
||||
if key != nil && key.IsPlatform() {
|
||||
@@ -480,7 +517,8 @@ func attachAPIKeyAuthContext(
|
||||
TenantID: tenantID,
|
||||
IsActive: true,
|
||||
}
|
||||
log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID)
|
||||
logger.Infof(c.Request.Context(),
|
||||
"No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID)
|
||||
}
|
||||
|
||||
var principalErr error
|
||||
@@ -492,9 +530,6 @@ func attachAPIKeyAuthContext(
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(types.UserContextKey.String(), user)
|
||||
c.Set(types.UserIDContextKey.String(), user.ID)
|
||||
c.Set(types.PrincipalContextKey.String(), principal)
|
||||
// This role context exists only for legacy guard compatibility after
|
||||
// RequireRole short-circuits API-key principals. The API key's real
|
||||
// authority is FullAccess + Capabilities + KnowledgeBaseIDs.
|
||||
@@ -503,23 +538,23 @@ func attachAPIKeyAuthContext(
|
||||
if fullAccess {
|
||||
apiKeyTenantRoleContext = types.TenantRoleOwner
|
||||
}
|
||||
c.Set(types.TenantRoleContextKey.String(), apiKeyTenantRoleContext)
|
||||
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, apiKeyTenantRoleContext)
|
||||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, false)
|
||||
session := authSession{
|
||||
User: user,
|
||||
Principal: principal,
|
||||
TenantID: tenantID,
|
||||
Tenant: t,
|
||||
Role: apiKeyTenantRoleContext,
|
||||
}
|
||||
if key != nil {
|
||||
ctx = types.WithTenantAPIKeyScope(ctx, types.TenantAPIKeyScope{
|
||||
session.APIKeyScope = &types.TenantAPIKeyScope{
|
||||
KeyID: key.ID,
|
||||
ScopeType: key.ScopeType,
|
||||
FullAccess: fullAccess,
|
||||
KnowledgeBaseIDs: key.KnowledgeBaseIDs,
|
||||
Capabilities: key.Capabilities,
|
||||
})
|
||||
}
|
||||
}
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
applyAuthSession(c, session)
|
||||
}
|
||||
|
||||
func resolveAPIPrincipal(ctx context.Context, tenant *types.Tenant, header http.Header) (types.Principal, error) {
|
||||
@@ -789,12 +824,3 @@ func resolveTenantRole(
|
||||
// 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("workspace ID not found in context")
|
||||
}
|
||||
return tenantID, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// authSession is the resolved identity an auth middleware attaches to the
|
||||
// request. One struct + one apply function replaces the five near-identical
|
||||
// "c.Set + context.WithValue" blocks that used to live in the JWT branch,
|
||||
// the tenantless branch, both API-key attach helpers and EmbedAuth — keeping
|
||||
// the gin-keys surface (c.Keys) and the request-context surface in lockstep
|
||||
// is exactly the invariant that was easy to break when each caller wrote the
|
||||
// pairs out by hand.
|
||||
type authSession struct {
|
||||
// User is the authenticated (possibly synthetic) user. Required.
|
||||
User *types.User
|
||||
Principal types.Principal
|
||||
// TenantID / Tenant scope the request to a workspace. TenantID == 0 means
|
||||
// a tenantless session (identity-level routes only) and attaches neither
|
||||
// key. Tenant may be nil even when TenantID is set (not expected today,
|
||||
// but the helper stays tolerant).
|
||||
TenantID uint64
|
||||
Tenant *types.Tenant
|
||||
// Role is the caller's resolved role inside TenantID. Empty string means
|
||||
// "attach no role key" (tenantless sessions); readers then fall back to
|
||||
// TenantRoleFromContext's fail-closed Viewer default.
|
||||
Role types.TenantRole
|
||||
SystemAdmin bool
|
||||
// APIKeyScope marks machine principals; the APIKeyGate authorizes them
|
||||
// per-route from this scope.
|
||||
APIKeyScope *types.TenantAPIKeyScope
|
||||
// Extra carries surface-specific context values (e.g. the authenticated
|
||||
// embed channel) that must be visible on both surfaces like the rest.
|
||||
Extra map[types.ContextKey]any
|
||||
}
|
||||
|
||||
// applyAuthSession writes the session onto BOTH context surfaces of the
|
||||
// gin request:
|
||||
//
|
||||
// - c.Keys (read via c.Get / helpers that take *gin.Context)
|
||||
// - c.Request.Context() (read via types.*FromContext)
|
||||
//
|
||||
// Every auth middleware must go through this helper instead of hand-writing
|
||||
// the pairs; a key set on only one surface is a latent bug that surfaces as
|
||||
// "works in handler A, broken in handler B" depending on which read style
|
||||
// the handler uses.
|
||||
func applyAuthSession(c *gin.Context, s authSession) {
|
||||
ctx := c.Request.Context()
|
||||
set := func(key types.ContextKey, v any) {
|
||||
c.Set(key.String(), v)
|
||||
ctx = context.WithValue(ctx, key, v)
|
||||
}
|
||||
|
||||
if s.TenantID != 0 {
|
||||
set(types.TenantIDContextKey, s.TenantID)
|
||||
}
|
||||
if s.Tenant != nil {
|
||||
set(types.TenantInfoContextKey, s.Tenant)
|
||||
}
|
||||
if s.User != nil {
|
||||
set(types.UserContextKey, s.User)
|
||||
set(types.UserIDContextKey, s.User.ID)
|
||||
}
|
||||
c.Set(types.PrincipalContextKey.String(), s.Principal)
|
||||
ctx = types.WithPrincipal(ctx, s.Principal)
|
||||
if s.Role != "" {
|
||||
set(types.TenantRoleContextKey, s.Role)
|
||||
}
|
||||
set(types.SystemAdminContextKey, s.SystemAdmin)
|
||||
if s.APIKeyScope != nil {
|
||||
ctx = types.WithTenantAPIKeyScope(ctx, *s.APIKeyScope)
|
||||
}
|
||||
for key, v := range s.Extra {
|
||||
set(key, v)
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestApplyAuthSessionSetsBothSurfaces locks the core invariant of the
|
||||
// helper: every attached value must be readable from BOTH c.Keys (c.Get)
|
||||
// and the request context (types.*FromContext). A key present on only one
|
||||
// surface is the class of bug the helper exists to prevent.
|
||||
func TestApplyAuthSessionSetsBothSurfaces(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/knowledge-bases", nil)
|
||||
|
||||
user := &types.User{ID: "u1", IsSystemAdmin: true}
|
||||
tenant := &types.Tenant{ID: 7}
|
||||
principal := types.Principal{Type: types.PrincipalWebUser, ID: "u1"}
|
||||
scope := &types.TenantAPIKeyScope{KeyID: 3}
|
||||
applyAuthSession(c, authSession{
|
||||
User: user,
|
||||
Principal: principal,
|
||||
TenantID: 7,
|
||||
Tenant: tenant,
|
||||
Role: types.TenantRoleAdmin,
|
||||
SystemAdmin: true,
|
||||
APIKeyScope: scope,
|
||||
Extra: map[types.ContextKey]any{EmbedChannelContextKey: &types.EmbedChannel{ID: "ch"}},
|
||||
})
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if got, ok := types.TenantIDFromContext(ctx); !ok || got != 7 {
|
||||
t.Fatalf("ctx tenant id = %d, ok=%v", got, ok)
|
||||
}
|
||||
if got, ok := c.Get(types.TenantIDContextKey.String()); !ok || got.(uint64) != 7 {
|
||||
t.Fatalf("keys tenant id = %v, ok=%v", got, ok)
|
||||
}
|
||||
if got, ok := types.TenantInfoFromContext(ctx); !ok || got.ID != 7 {
|
||||
t.Fatalf("ctx tenant info = %#v, ok=%v", got, ok)
|
||||
}
|
||||
if got, ok := types.UserIDFromContext(ctx); !ok || got != "u1" {
|
||||
t.Fatalf("ctx user id = %q, ok=%v", got, ok)
|
||||
}
|
||||
if got, ok := c.Get(types.UserContextKey.String()); !ok || got.(*types.User).ID != "u1" {
|
||||
t.Fatalf("keys user = %#v, ok=%v", got, ok)
|
||||
}
|
||||
if got, ok := types.PrincipalFromContext(ctx); !ok || got != principal {
|
||||
t.Fatalf("ctx principal = %#v, ok=%v", got, ok)
|
||||
}
|
||||
if got := types.TenantRoleFromContext(ctx); got != types.TenantRoleAdmin {
|
||||
t.Fatalf("ctx role = %q", got)
|
||||
}
|
||||
if got, ok := c.Get(types.TenantRoleContextKey.String()); !ok || got.(types.TenantRole) != types.TenantRoleAdmin {
|
||||
t.Fatalf("keys role = %v, ok=%v", got, ok)
|
||||
}
|
||||
if !types.IsSystemAdminFromContext(ctx) {
|
||||
t.Fatal("ctx system admin flag lost")
|
||||
}
|
||||
if got, ok := types.TenantAPIKeyScopeFromContext(ctx); !ok || got.KeyID != 3 {
|
||||
t.Fatalf("ctx api key scope = %#v, ok=%v", got, ok)
|
||||
}
|
||||
if ch, ok := EmbedChannelFromContext(ctx); !ok || ch.ID != "ch" {
|
||||
t.Fatalf("ctx embed channel = %#v, ok=%v", ch, ok)
|
||||
}
|
||||
if got, ok := c.Get(EmbedChannelContextKey.String()); !ok || got.(*types.EmbedChannel).ID != "ch" {
|
||||
t.Fatalf("keys embed channel = %v, ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAuthSessionTenantless verifies that a tenantless session attaches
|
||||
// neither tenant keys nor a role key — RequireRole's fail-closed Viewer
|
||||
// default depends on the role key being absent, and TENANT_REQUIRED
|
||||
// handling depends on the tenant key being absent.
|
||||
func TestApplyAuthSessionTenantless(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
|
||||
attachTenantlessUserContext(c, &types.User{ID: "u2"})
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if _, ok := types.TenantIDFromContext(ctx); ok {
|
||||
t.Fatal("tenantless session must not carry a tenant id")
|
||||
}
|
||||
if _, ok := c.Get(types.TenantRoleContextKey.String()); ok {
|
||||
t.Fatal("tenantless session must not carry a role key")
|
||||
}
|
||||
if got, ok := types.UserIDFromContext(ctx); !ok || got != "u2" {
|
||||
t.Fatalf("ctx user id = %q, ok=%v", got, ok)
|
||||
}
|
||||
if types.IsSystemAdminFromContext(ctx) {
|
||||
t.Fatal("non-admin user must not be flagged system admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
header string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"Basic abc", "", false},
|
||||
{"Bearer", "", false},
|
||||
{"Bearer abc", "abc", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if tc.header != "" {
|
||||
c.Request.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
got, ok := bearerToken(c)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("bearerToken(%q) = (%q, %v), want (%q, %v)", tc.header, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,30 +162,17 @@ func EmbedAuth(
|
||||
TenantID: ch.TenantID,
|
||||
IsActive: true,
|
||||
}
|
||||
principal := types.Principal{
|
||||
Type: types.PrincipalEmbedChannel,
|
||||
ID: fmt.Sprintf("%d:%s", ch.TenantID, ch.ID),
|
||||
}
|
||||
|
||||
c.Set(types.TenantIDContextKey.String(), ch.TenantID)
|
||||
c.Set(types.TenantInfoContextKey.String(), tenant)
|
||||
c.Set(types.UserContextKey.String(), user)
|
||||
c.Set(types.UserIDContextKey.String(), user.ID)
|
||||
c.Set(types.PrincipalContextKey.String(), principal)
|
||||
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleViewer)
|
||||
c.Set(types.SystemAdminContextKey.String(), false)
|
||||
c.Set(string(EmbedChannelContextKey), ch)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, ch.TenantID)
|
||||
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
|
||||
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.TenantRoleViewer)
|
||||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, false)
|
||||
ctx = context.WithValue(ctx, EmbedChannelContextKey, ch)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
applyAuthSession(c, authSession{
|
||||
User: user,
|
||||
Principal: types.Principal{
|
||||
Type: types.PrincipalEmbedChannel,
|
||||
ID: fmt.Sprintf("%d:%s", ch.TenantID, ch.ID),
|
||||
},
|
||||
TenantID: ch.TenantID,
|
||||
Tenant: tenant,
|
||||
Role: types.TenantRoleViewer,
|
||||
Extra: map[types.ContextKey]any{EmbedChannelContextKey: ch},
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user