bf5be08e96
角色此前能分配但不生效(viewer 也能烧租户积分)。本次让角色真正挡动作: - store.RoleRank 角色层级:owner(4)>admin(3)>member(2)>viewer/billing_admin(1), 未知/非成员=0;billing_admin 定位为「财务只读」不跑任务 - 可复用中间件 RequireTenantRole(db, minRole),抽 MemberRoleResolver 小接口便于单测 - POST /tasks 挂 ≥member 门控:唯一会烧租户积分的入口(KB 入库不计租户),挡住即够 - 桌面端 StudioView 收 readOnly:viewer 时「运行」禁用+只读提示(UX 兜底,真闸在后端) - 测试:RoleRank 纯逻辑 + 中间件 7 门控 case(owner/admin/member 放行, viewer/billing_admin/非成员 403,未登录 401) 实机验证(gateway+docker):viewer 提交 403 → 升 member 202 → 降回 viewer 403 → owner 自租户 202(solo 用户不受影响),三态可逆、角色驱动、中间件先于 handler。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
4.1 KiB
Go
128 lines
4.1 KiB
Go
package middleware
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-gateway/internal/auth"
|
||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||
)
|
||
|
||
// CtxUserID 是鉴权后写入 gin.Context 的已验证用户 ID 键。
|
||
const CtxUserID = "uid"
|
||
|
||
// Auth 解析 Authorization: Bearer <JWT>,校验通过则把已验证 userID 写入上下文。
|
||
// 非阻断:无 token / 无效 token 时不报错,由各 handler(经 userID 兜底 header)或
|
||
// 后续 RequireAuth 决定是否放行。
|
||
func Auth() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
h := c.GetHeader("Authorization")
|
||
if strings.HasPrefix(h, "Bearer ") {
|
||
if uid, err := auth.Parse(strings.TrimSpace(h[len("Bearer "):])); err == nil {
|
||
c.Set(CtxUserID, uid)
|
||
}
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// RequireAuth 在 Auth 之后使用:上下文无已验证 userID 则 401 拒绝。
|
||
// 用于 owner 作用域的业务路由;SSE/导出等按 task_id 寻址的端点不挂(EventSource 无法带头)。
|
||
func RequireAuth() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if v, ok := c.Get(CtxUserID); ok {
|
||
if s, _ := v.(string); s != "" {
|
||
c.Next()
|
||
return
|
||
}
|
||
}
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||
}
|
||
}
|
||
|
||
// RequireAdmin 保护运维控制面:必须登录,且(设了 ADMIN_USER_IDS 时)uid 须在白名单内。
|
||
// ADMIN_USER_IDS 为空:开发期放行任意登录用户;生产期(APP_ENV=prod/GIN_MODE=release)直接拒绝
|
||
// ——逼运维显式配置管理员,杜绝"任意账号改模型/密钥配置"。
|
||
func RequireAdmin() gin.HandlerFunc {
|
||
allow := splitEnv("ADMIN_USER_IDS")
|
||
prod := strings.EqualFold(os.Getenv("APP_ENV"), "production") || strings.EqualFold(os.Getenv("APP_ENV"), "prod") ||
|
||
strings.EqualFold(os.Getenv("GIN_MODE"), "release")
|
||
return func(c *gin.Context) {
|
||
uid, _ := c.Get(CtxUserID)
|
||
id, _ := uid.(string)
|
||
if id == "" {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||
return
|
||
}
|
||
if len(allow) == 0 {
|
||
if prod {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "未配置管理员(ADMIN_USER_IDS)"})
|
||
return
|
||
}
|
||
c.Next() // 开发期放行
|
||
return
|
||
}
|
||
for _, a := range allow {
|
||
if a == id {
|
||
c.Next()
|
||
return
|
||
}
|
||
}
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||
}
|
||
}
|
||
|
||
// MemberRoleResolver 解析用户在某租户的角色(*store.Postgres 天然满足)。抽成接口便于单测门控逻辑。
|
||
type MemberRoleResolver interface {
|
||
MemberRole(ctx context.Context, tenantID, userID string) string
|
||
}
|
||
|
||
// RequireTenantRole 要求当前用户在活跃租户中的角色不低于 minRole(按 store.RoleRank 阶梯)。
|
||
// 须挂在 RequireAuth + TenantContext 之后(依赖已注入的 uid 与 tenant_id)。
|
||
// 无租户上下文(正常不会:TenantContext 兜底补建默认租户)或非成员 → rank 0 → 拦下。
|
||
// 复用中间件:当前仅接 SubmitTask(≥member,挡只读 viewer 烧租户积分);将来成员自管/共享
|
||
// 工作区上线,直接给对应写路由挂上更高 minRole 即生效,无需改 handler。
|
||
func RequireTenantRole(db MemberRoleResolver, minRole string) gin.HandlerFunc {
|
||
min := store.RoleRank(minRole)
|
||
return func(c *gin.Context) {
|
||
uid, _ := c.Get(CtxUserID)
|
||
id, _ := uid.(string)
|
||
if id == "" {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||
return
|
||
}
|
||
tid, _ := c.Get(CtxTenantID)
|
||
tenant, _ := tid.(string)
|
||
if store.RoleRank(db.MemberRole(c.Request.Context(), tenant, id)) < min {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "当前角色为只读,无权执行该操作"})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func splitEnv(key string) []string {
|
||
var out []string
|
||
for _, p := range strings.Split(os.Getenv(key), ",") {
|
||
if p = strings.TrimSpace(p); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// envInt 读正整数环境变量,缺省回退 def。
|
||
func envInt(key string, def int) int {
|
||
if v := os.Getenv(key); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
return n
|
||
}
|
||
}
|
||
return def
|
||
}
|