Files
whm b04b180d30 feat: harden loose-offline sync for user JWT, schema, and console ops
Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:47:35 +08:00

433 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package authx
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/zeromicro/go-zero/rest/httpx"
)
type ctxKey string
const (
CtxTenantID ctxKey = "tenant_id"
CtxUserID ctxKey = "user_id"
CtxRole ctxKey = "role"
CtxAgentID ctxKey = "agent_id"
CtxPerms ctxKey = "perms"
CtxOrgUnitID ctxKey = "org_unit_id"
)
// 平台级角色 → 权限映射(角色、权限一律中文命名)
var RolePermissions = map[string][]string{
Role超级管理员: {
Perm管理租户, // 只管公司一级:列表/新建等;不介入公司内模块、成员、同步
},
Role管理员: {
Perm读取模块, Perm写入模块, Perm发布模块,
Perm新增数据, Perm查询数据, Perm更新数据, Perm删除数据, Perm导出数据, Perm导入数据,
Perm查看审计, Perm上传文件, Perm下载文件,
Perm管理智能体, Perm邀请成员, Perm管理组织,
Perm数据同步,
},
Role编辑: {
Perm读取模块, Perm写入模块,
Perm新增数据, Perm查询数据, Perm更新数据, Perm导出数据, Perm导入数据,
Perm上传文件, Perm下载文件,
},
Role只读: {
Perm读取模块, Perm查询数据, Perm导出数据, Perm下载文件, Perm查看审计,
},
Role待加入: {},
}
type Claims struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
Role string `json:"role,omitempty"`
OrgUnitID int64 `json:"org_unit_id,omitempty"`
AgentID int64 `json:"agent_id,omitempty"`
Perms []string `json:"perms,omitempty"`
jwt.RegisteredClaims
}
type JWTConfig struct {
AccessSecret string
AccessExpire int64
}
func (c JWTConfig) Expire() time.Duration {
if c.AccessExpire <= 0 {
return 24 * time.Hour
}
return time.Duration(c.AccessExpire) * time.Second
}
func IssueToken(cfg JWTConfig, tenantID, userID int64, role string, orgUnitID int64) (string, int64, error) {
if cfg.AccessSecret == "" {
return "", 0, errors.New("auth access secret empty")
}
if userID <= 0 {
return "", 0, errors.New("user_id required")
}
if role == "" {
role = Role管理员
}
role = NormalizeRole(role)
if _, ok := RolePermissions[role]; !ok {
return "", 0, fmt.Errorf("unknown role: %s", role)
}
// 待加入 / 超级管理员可无租户;其他角色必须带 tenant_id
if tenantID <= 0 && role != Role待加入 && role != Role超级管理员 {
return "", 0, errors.New("tenant_id and user_id required")
}
if tenantID < 0 {
tenantID = 0
}
return signClaims(cfg, Claims{
TenantID: tenantID,
UserID: userID,
Role: role,
OrgUnitID: orgUnitID,
})
}
// IssueAgentToken 签发智能体 JWTrole=agent权限写在 perms 声明中)。
func IssueAgentToken(cfg JWTConfig, tenantID, agentID int64, perms []string) (string, int64, error) {
if cfg.AccessSecret == "" {
return "", 0, errors.New("auth access secret empty")
}
if tenantID <= 0 || agentID <= 0 {
return "", 0, errors.New("tenant_id and agent_id required")
}
return signClaims(cfg, Claims{
TenantID: tenantID,
UserID: agentID,
Role: RoleAgent,
AgentID: agentID,
Perms: NormalizePerms(perms),
})
}
func signClaims(cfg JWTConfig, claims Claims) (string, int64, error) {
exp := time.Now().Add(cfg.Expire())
claims.RegisteredClaims = jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(exp),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "aijianzhan-platform",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(cfg.AccessSecret))
if err != nil {
return "", 0, err
}
return signed, exp.Unix(), nil
}
func ParseToken(cfg JWTConfig, tokenStr string) (*Claims, error) {
if cfg.AccessSecret == "" {
return nil, errors.New("auth access secret empty")
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}))
token, err := parser.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(cfg.AccessSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
if claims.UserID <= 0 {
return nil, errors.New("token missing user")
}
if claims.Role == "" {
claims.Role = Role只读
}
claims.Role = NormalizeRole(claims.Role)
if claims.TenantID <= 0 && claims.Role != Role待加入 && claims.Role != Role超级管理员 {
return nil, errors.New("token missing tenant/user")
}
if claims.TenantID < 0 {
claims.TenantID = 0
}
if claims.Role == Role智能体 && claims.AgentID <= 0 {
claims.AgentID = claims.UserID
}
return claims, nil
}
func Middleware(jwtCfg JWTConfig, devAuth bool) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
raw := strings.TrimSpace(auth[7:])
claims, err := ParseToken(jwtCfg, raw)
if err != nil {
WriteError(w, http.StatusUnauthorized, "invalid token: "+err.Error())
return
}
ctx := WithFullClaims(r.Context(), claims.TenantID, claims.UserID, claims.Role, claims.AgentID, claims.OrgUnitID, claims.Perms)
next(w, r.WithContext(ctx))
return
}
if devAuth {
tenantID, _ := strconv.ParseInt(r.Header.Get("X-Tenant-Id"), 10, 64)
userID, _ := strconv.ParseInt(r.Header.Get("X-User-Id"), 10, 64)
role := r.Header.Get("X-Role")
if tenantID <= 0 {
tenantID = 1
}
if userID <= 0 {
userID = 1
}
if role == "" {
role = Role管理员
}
role = NormalizeRole(role)
ctx := WithClaims(r.Context(), tenantID, userID, role)
next(w, r.WithContext(ctx))
return
}
WriteError(w, http.StatusUnauthorized, "missing Authorization Bearer token")
}
}
}
// RequirePermission 校验平台角色或智能体 JWT 内嵌权限。
func RequirePermission(perm string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !HasPermissionCtx(r.Context(), perm) {
role := Role(r.Context())
WriteError(w, http.StatusForbidden, "permission denied: "+perm+" (role="+role+")")
return
}
next(w, r)
}
}
}
// RequireSyncPushAccess 放行「数据同步」权限,或已登录人类成员(用户自助 sync
// 智能体无「数据同步」仍 403无 sync 权的人类由 handler 再按 Binding 收窄范围。
func RequireSyncPushAccess() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if HasPermissionCtx(r.Context(), Perm数据同步) {
next(w, r)
return
}
if IsAgent(Role(r.Context())) {
WriteError(w, http.StatusForbidden, "permission denied: 数据同步 (role=智能体)")
return
}
if UserID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "permission denied: need user login or 数据同步")
return
}
next(w, r)
}
}
}
// SyncTenantWide 是否租户级同步权(管理员/智能体带「数据同步」);否则须按 Binding 自助范围。
func SyncTenantWide(ctx context.Context) bool {
return HasPermissionCtx(ctx, Perm数据同步)
}
// RequireTenant 要求已加入租户pending / 无 tenant 不可访问业务数据。
// 超级管理员可在「进入某公司」后携带 tenant_id 访问公司内接口(可看可改,前端会多重确认)。
func RequireTenant() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if Role(r.Context()) == Role智能体 {
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "agent missing tenant")
return
}
next(w, r)
return
}
if Role(r.Context()) == Role超级管理员 {
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "请先在平台管理中进入某公司后再操作公司内部功能")
return
}
next(w, r)
return
}
if TenantID(r.Context()) <= 0 || Role(r.Context()) == Role待加入 {
WriteError(w, http.StatusForbidden, "join a tenant first (pending membership)")
return
}
next(w, r)
}
}
}
// RequirePlatformAdmin 仅平台超级管理员。
func RequirePlatformAdmin() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !IsPlatformAdmin(Role(r.Context())) {
WriteError(w, http.StatusForbidden, "需要超级管理员")
return
}
next(w, r)
}
}
}
// RequireTenantBound 保留兼容:业务接口要求已绑定租户。
func RequireTenantBound() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if IsPlatformAdmin(Role(r.Context())) {
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "请先进入某公司")
return
}
next(w, r)
return
}
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "请先加入公司")
return
}
next(w, r)
}
}
}
func HasPermission(role, perm string) bool {
perms, ok := RolePermissions[NormalizeRole(role)]
if !ok {
return false
}
want := NormalizePerm(perm)
for _, p := range perms {
if NormalizePerm(p) == want {
return true
}
}
return false
}
func HasPermissionCtx(ctx context.Context, perm string) bool {
role := NormalizeRole(Role(ctx))
want := NormalizePerm(perm)
if role == Role超级管理员 {
// 进入公司后:超管可使用全部公司权限(平台专属「管理租户」仍保留)
if want == Perm管理租户 {
return true
}
for _, p := range CompanyPermCatalog() {
if NormalizePerm(p) == want {
return TenantID(ctx) > 0
}
}
return HasPermission(role, want)
}
if role == Role智能体 {
ok := false
for _, p := range Perms(ctx) {
if NormalizePerm(p) == want {
ok = true
break
}
}
if !ok {
return false
}
} else if !HasPermission(role, want) {
return false
}
tid := TenantID(ctx)
if tid > 0 && EntitlementChecker != nil {
if !EntitlementChecker(ctx, tid, want) {
return false
}
}
return true
}
func withClaims(ctx context.Context, tenantID, userID int64, role string) context.Context {
return WithClaims(ctx, tenantID, userID, role)
}
// WithClaims 写入租户/用户/角色(公开展示等场景用)。
func WithClaims(ctx context.Context, tenantID, userID int64, role string) context.Context {
return WithFullClaims(ctx, tenantID, userID, role, 0, 0, nil)
}
func WithFullClaims(ctx context.Context, tenantID, userID int64, role string, agentID, orgUnitID int64, perms []string) context.Context {
ctx = context.WithValue(ctx, CtxTenantID, tenantID)
ctx = context.WithValue(ctx, CtxUserID, userID)
ctx = context.WithValue(ctx, CtxRole, role)
ctx = context.WithValue(ctx, CtxAgentID, agentID)
ctx = context.WithValue(ctx, CtxOrgUnitID, orgUnitID)
if perms != nil {
ctx = context.WithValue(ctx, CtxPerms, append([]string{}, perms...))
}
return ctx
}
func TenantID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxTenantID).(int64)
return v
}
func UserID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxUserID).(int64)
return v
}
func AgentID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxAgentID).(int64)
return v
}
func OrgUnitID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxOrgUnitID).(int64)
return v
}
func Role(ctx context.Context) string {
v, _ := ctx.Value(CtxRole).(string)
if v == "" {
return Role只读
}
return NormalizeRole(v)
}
func Perms(ctx context.Context) []string {
v, _ := ctx.Value(CtxPerms).([]string)
return v
}
func WriteError(w http.ResponseWriter, code int, msg string) {
httpx.WriteJson(w, code, map[string]any{
"code": code,
"message": msg,
})
}
// WriteRetryableError 供 agent 识别可稍后重试(不换 UUID
func WriteRetryableError(w http.ResponseWriter, code int, msg string) {
httpx.WriteJson(w, code, map[string]any{
"code": code,
"message": msg,
"retryable": true,
})
}