From 8d49230247efe4efebd24a2b029f695ad765be91 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 19:26:40 +0800 Subject: [PATCH] feat(server): add Wayne subsystem authorization APIs --- server/README.md | 64 ++- server/internal/handler/business_line.go | 84 +++- server/internal/handler/subsystem_auth.go | 389 ++++++++++++++++++ server/internal/handler/wayne_role_binding.go | 52 ++- server/internal/router/router.go | 12 +- server/internal/service/wayne_role_binding.go | 117 +++++- .../service/wayne_role_binding_test.go | 29 +- 7 files changed, 679 insertions(+), 68 deletions(-) create mode 100644 server/internal/handler/subsystem_auth.go diff --git a/server/README.md b/server/README.md index 3c4492f..d6b4fa1 100644 --- a/server/README.md +++ b/server/README.md @@ -285,14 +285,14 @@ Wayne 会把回调地址拼成: ## Wayne 授权代理接口 -AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `userId`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户 ID 由请求体或路径参数提供。 +AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户名由请求体或路径参数提供。 对外接口: ```text GET /auth/api/v1/wayne/namespaces GET /auth/api/v1/wayne/groups -GET /auth/api/v1/wayne/users/:userid/roles +GET /auth/api/v1/wayne/users/:username/roles GET /auth/api/v1/wayne/namespaces/:namespaceid/operator-permissions GET /auth/api/v1/wayne/apps/:appid/operator-permissions PUT /auth/api/v1/wayne/namespaces/:namespaceid/roles @@ -309,7 +309,7 @@ Authorization: Bearer Content-Type: application/json { - "userId": 2001, + "username": "target@example.com", "groupIds": [10, 11], "replace": false, "requestId": "req-001", @@ -317,13 +317,13 @@ Content-Type: application/json } ``` -AuthServer 转发到 Wayne internal API 时会使用请求体里的 `userId`: +AuthServer 转发到 Wayne internal API 时会使用请求体里的 `username`: ```text -PUT /api/v1/internal/namespaces/1/users/2001/roles +PUT /api/v1/internal/namespaces/1/users/target@example.com/roles ``` -并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`userId` 只用于 Wayne path,不会透传到 Wayne 请求体。 +并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`username` 只用于 Wayne path,不会透传到 Wayne 请求体。 相关配置: @@ -342,6 +342,58 @@ signature = HMAC_SHA256_HEX(secret, payload) X-Wayne-Signature = "sha256=" + signature ``` +## 子系统赋权接口 + +子系统赋权接口是平台业务层接口,前端应优先调用这一组,而不是直接调用低层 `/wayne/*` 代理。当前只实现 Wayne,CloudDM 先返回未启用占位。 + +权限规则: + +- 平台管理员可以操作任意业务线。 +- 非平台管理员必须是当前业务线管理员,也就是 `business_line_users.permission = 0`。 +- Wayne 写操作前还会查询 Wayne `operator-permissions`,确认当前登录用户在目标 namespace 下具备创建/更新/删除用户角色的权限。 +- 用户首次加入业务线时,如果该业务线绑定了 Wayne namespace,会自动给该用户初始化 Wayne namespace `访客` 角色。 + +接口列表: + +```text +GET /auth/api/v1/subsystem-auth/systems +GET /auth/api/v1/subsystem-auth/wayne/roles +GET /auth/api/v1/subsystem-auth/wayne/business-lines/:id/namespaces +GET /auth/api/v1/subsystem-auth/wayne/users/:username/roles +PUT /auth/api/v1/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles +DELETE /auth/api/v1/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles +POST /auth/api/v1/subsystem-auth/wayne/business-lines/:id/users/:userid/init +``` + +Wayne 授权示例: + +```http +PUT /auth/api/v1/subsystem-auth/wayne/business-lines/1/namespaces/3/users/eastsales@qiniu.com/roles +Authorization: Bearer +Content-Type: application/json + +{ + "groupIds": [2], + "replace": true, + "requestId": "req-001", + "reason": "业务线授权" +} +``` + +Wayne 解绑示例: + +```http +DELETE /auth/api/v1/subsystem-auth/wayne/business-lines/1/namespaces/3/users/eastsales@qiniu.com/roles +Authorization: Bearer +Content-Type: application/json + +{ + "groupIds": [2], + "requestId": "req-002", + "reason": "回收业务线授权" +} +``` + 管理员可查看当前 SAML metadata 配置: ```text diff --git a/server/internal/handler/business_line.go b/server/internal/handler/business_line.go index 4eb1603..208f736 100644 --- a/server/internal/handler/business_line.go +++ b/server/internal/handler/business_line.go @@ -4,16 +4,19 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "github.com/1024XEngineer/xinfra/server/internal/model" + "github.com/1024XEngineer/xinfra/server/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type BusinessLineHandler struct { - db *gorm.DB + db *gorm.DB + wayne *service.WayneRoleBindingService } type BusinessLineWithPermission struct { @@ -45,8 +48,8 @@ type WayneNamespaceBindingItem struct { KubeNamespace string `json:"kubeNamespace"` } -func NewBusinessLineHandler(db *gorm.DB) *BusinessLineHandler { - return &BusinessLineHandler{db: db} +func NewBusinessLineHandler(db *gorm.DB, wayne *service.WayneRoleBindingService) *BusinessLineHandler { + return &BusinessLineHandler{db: db, wayne: wayne} } func (h *BusinessLineHandler) ListCurrentUserBusinessLines(c *gin.Context) { @@ -269,6 +272,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { } var binding model.BusinessLineUser + created := false err := h.db.Where("business_line_id = ? AND user_id = ?", req.TargetBusinessLineID, req.TargetUserID).First(&binding).Error if errors.Is(err, gorm.ErrRecordNotFound) { binding = model.BusinessLineUser{ @@ -280,6 +284,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } + created = true } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -291,6 +296,24 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { binding.Permission = req.Permission } + var initializedWayne []gin.H + if created { + operatorEmail, ok := subsystemOperatorEmail(c, claims) + if !ok { + return + } + targetUsername := wayneUsernameForUser(targetUser) + if targetUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target user has no Wayne username"}) + return + } + initialized, ok := h.initializeWayneVisitorForBusinessLine(c, req.TargetBusinessLineID, targetUsername, operatorEmail, claims.IsAdmin) + if !ok { + return + } + initializedWayne = initialized + } + c.JSON(http.StatusOK, gin.H{ "id": binding.ID, "business_line_id": binding.BusinessLineID, @@ -298,6 +321,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { "permission": binding.Permission, "created_at": binding.CreatedAt.Format(time.RFC3339), "updated_at": binding.UpdatedAt.Format(time.RFC3339), + "wayne_init": initializedWayne, }) } @@ -399,6 +423,60 @@ func (h *BusinessLineHandler) canManageBusinessLine(c *gin.Context, businessLine return true } +func (h *BusinessLineHandler) initializeWayneVisitorForBusinessLine(c *gin.Context, businessLineID uint64, targetUsername string, operatorEmail string, skipWaynePermissionCheck bool) ([]gin.H, bool) { + var namespaces []model.BusinessLineWayneNamespace + if err := h.db.Where("business_line_id = ?", businessLineID).Order("wayne_namespace_id ASC").Find(&namespaces).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return nil, false + } + if len(namespaces) == 0 { + return []gin.H{}, true + } + if h.wayne == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "wayne internal role binding api is not configured"}) + return nil, false + } + + groupIDs, err := h.wayne.NamespaceVisitorGroupIDs(c.Request.Context()) + if err != nil { + writeWayneRoleBindingError(c, nil, err) + return nil, false + } + replace := true + req := service.WayneRoleBindingRequest{ + GroupIDs: groupIDs, + Replace: &replace, + RequestID: "business-line-user-init-" + strconv.FormatInt(time.Now().UnixNano(), 10), + Reason: "初始化业务线 Wayne 访客角色", + } + + items := make([]gin.H, 0, len(namespaces)) + for _, namespace := range namespaces { + if !skipWaynePermissionCheck { + permissions, err := h.wayne.NamespaceOperatorPermissionsParsed(c.Request.Context(), namespace.WayneNamespaceID, operatorEmail) + if err != nil { + writeWayneRoleBindingError(c, nil, err) + return nil, false + } + if !permissions.Create && !permissions.Update { + c.JSON(http.StatusForbidden, gin.H{"error": "current user does not have Wayne namespace role create or update permission"}) + return nil, false + } + } + result, err := h.wayne.BindNamespace(c.Request.Context(), namespace.WayneNamespaceID, strings.TrimSpace(targetUsername), operatorEmail, req) + if err != nil { + writeWayneRoleBindingError(c, result, err) + return nil, false + } + items = append(items, gin.H{ + "namespace_id": namespace.WayneNamespaceID, + "namespace_name": namespace.WayneNamespaceName, + "group_ids": groupIDs, + }) + } + return items, true +} + func parseBusinessLineID(c *gin.Context) (uint64, bool) { value, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || value == 0 { diff --git a/server/internal/handler/subsystem_auth.go b/server/internal/handler/subsystem_auth.go new file mode 100644 index 0000000..5cbd988 --- /dev/null +++ b/server/internal/handler/subsystem_auth.go @@ -0,0 +1,389 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/1024XEngineer/xinfra/server/internal/auth" + "github.com/1024XEngineer/xinfra/server/internal/model" + "github.com/1024XEngineer/xinfra/server/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type SubsystemAuthHandler struct { + db *gorm.DB + wayne *service.WayneRoleBindingService + audit *service.AuditService +} + +func NewSubsystemAuthHandler(db *gorm.DB, wayne *service.WayneRoleBindingService, audit *service.AuditService) *SubsystemAuthHandler { + return &SubsystemAuthHandler{db: db, wayne: wayne, audit: audit} +} + +func (h *SubsystemAuthHandler) ListSystems(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "items": []gin.H{ + {"key": "wayne", "name": "Wayne", "enabled": true}, + {"key": "clouddm", "name": "CloudDM", "enabled": false}, + }, + }) +} + +func (h *SubsystemAuthHandler) ListWayneNamespaceRoles(c *gin.Context) { + groups, err := h.wayne.ListNamespaceRoleGroups(c.Request.Context()) + if err != nil { + writeWayneRoleBindingError(c, nil, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": groups}) +} + +func (h *SubsystemAuthHandler) ListWayneBusinessLineNamespaces(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + businessLineID, ok := parseBusinessLineID(c) + if !ok { + return + } + if !h.canManageBusinessLine(c, claims, businessLineID) { + return + } + + rows, ok := h.listBusinessLineWayneNamespaces(c, businessLineID) + if !ok { + return + } + operatorEmail, ok := subsystemOperatorEmail(c, claims) + if !ok { + return + } + + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + item := gin.H{ + "id": row.WayneNamespaceID, + "name": row.WayneNamespaceName, + "kubeNamespace": row.KubeNamespace, + } + if claims.IsAdmin { + item["permissions"] = &service.WayneOperatorPermissions{Create: true, Update: true, Delete: true} + item["can_bind"] = true + item["can_unbind"] = true + } else { + permissions, err := h.wayne.NamespaceOperatorPermissionsParsed(c.Request.Context(), row.WayneNamespaceID, operatorEmail) + if err != nil { + item["permission_error"] = err.Error() + } else { + item["permissions"] = permissions + item["can_bind"] = permissions.Create || permissions.Update + item["can_unbind"] = permissions.Delete + } + } + items = append(items, item) + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +func (h *SubsystemAuthHandler) GetWayneUserRoles(c *gin.Context) { + username := strings.TrimSpace(c.Param("username")) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"}) + return + } + result, err := h.wayne.GetUserRoles(c.Request.Context(), username) + if err != nil { + writeWayneRoleBindingError(c, result, err) + return + } + writeWayneRoleBindingResult(c, result) +} + +func (h *SubsystemAuthHandler) BindWayneNamespaceRoles(c *gin.Context) { + h.handleWayneNamespaceRoles(c, http.MethodPut) +} + +func (h *SubsystemAuthHandler) UnbindWayneNamespaceRoles(c *gin.Context) { + h.handleWayneNamespaceRoles(c, http.MethodDelete) +} + +func (h *SubsystemAuthHandler) InitWayneBusinessLineUser(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + businessLineID, ok := parseBusinessLineID(c) + if !ok { + return + } + targetUserID, ok := parseUintPathParam(c, "userid") + if !ok { + return + } + if !h.canManageBusinessLine(c, claims, businessLineID) { + return + } + + var target model.User + if err := h.db.Where("id = ? AND deleted_at IS NULL", targetUserID).First(&target).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "target user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + operatorEmail, ok := subsystemOperatorEmail(c, claims) + if !ok { + return + } + targetUsername := wayneUsernameForUser(target) + result, ok := h.initializeWayneVisitor(c, businessLineID, targetUsername, operatorEmail, claims.IsAdmin, service.WayneRoleBindingRequest{ + RequestID: "business-line-user-init-" + strconv.FormatUint(targetUserID, 10) + "-" + strconv.FormatInt(time.Now().Unix(), 10), + Reason: "初始化业务线 Wayne 访客角色", + }) + if !ok { + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "items": result}) +} + +func (h *SubsystemAuthHandler) handleWayneNamespaceRoles(c *gin.Context, method string) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + businessLineID, ok := parseBusinessLineID(c) + if !ok { + return + } + namespaceID, ok := parseUintPathParam(c, "namespaceid") + if !ok { + return + } + targetUsername := strings.TrimSpace(c.Param("username")) + if targetUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"}) + return + } + if !h.canManageBusinessLine(c, claims, businessLineID) { + return + } + if !h.ensureNamespaceBelongsToBusinessLine(c, businessLineID, namespaceID) { + return + } + if !h.ensureLocalUserExists(c, targetUsername) { + return + } + operatorEmail, ok := subsystemOperatorEmail(c, claims) + if !ok { + return + } + if !claims.IsAdmin && !h.ensureWayneOperatorPermission(c, namespaceID, operatorEmail, method) { + return + } + + req, ok := parseRoleBindingRequest(c) + if !ok { + return + } + req.Username = "" + if method == http.MethodPut && len(req.GroupIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"}) + return + } + + var result *service.WayneRoleBindingResult + var err error + if method == http.MethodPut { + result, err = h.wayne.BindNamespace(c.Request.Context(), namespaceID, targetUsername, operatorEmail, req) + } else { + result, err = h.wayne.UnbindNamespace(c.Request.Context(), namespaceID, targetUsername, operatorEmail, req) + } + if err != nil { + h.writeAudit(c, claims, businessLineID, namespaceID, targetUsername, method, "deny", req.RequestID, err.Error()) + writeWayneRoleBindingError(c, result, err) + return + } + + h.writeAudit(c, claims, businessLineID, namespaceID, targetUsername, method, "allow", req.RequestID, "") + writeWayneRoleBindingResult(c, result) +} + +func (h *SubsystemAuthHandler) canManageBusinessLine(c *gin.Context, claims *auth.Claims, businessLineID uint64) bool { + var businessLine model.BusinessLine + if err := h.db.First(&businessLine, "id = ?", businessLineID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "business line not found"}) + return false + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return false + } + if claims.IsAdmin { + return true + } + + var binding model.BusinessLineUser + if err := h.db.Where("business_line_id = ? AND user_id = ? AND permission = ?", businessLineID, claims.UserID, 0). + First(&binding).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusForbidden, gin.H{"error": "current user is not platform admin or business line admin"}) + return false + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return false + } + return true +} + +func (h *SubsystemAuthHandler) ensureNamespaceBelongsToBusinessLine(c *gin.Context, businessLineID, namespaceID uint64) bool { + var row model.BusinessLineWayneNamespace + if err := h.db.Where("business_line_id = ? AND wayne_namespace_id = ?", businessLineID, namespaceID).First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusForbidden, gin.H{"error": "wayne namespace is not bound to current business line"}) + return false + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return false + } + return true +} + +func (h *SubsystemAuthHandler) ensureLocalUserExists(c *gin.Context, username string) bool { + var user model.User + if err := h.db.Where("(username = ? OR email = ?) AND deleted_at IS NULL", username, username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "target user not found"}) + return false + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return false + } + return true +} + +func (h *SubsystemAuthHandler) ensureWayneOperatorPermission(c *gin.Context, namespaceID uint64, operatorEmail string, method string) bool { + permissions, err := h.wayne.NamespaceOperatorPermissionsParsed(c.Request.Context(), namespaceID, operatorEmail) + if err != nil { + writeWayneRoleBindingError(c, nil, err) + return false + } + if method == http.MethodDelete { + if permissions.Delete { + return true + } + c.JSON(http.StatusForbidden, gin.H{"error": "current user does not have Wayne namespace role delete permission"}) + return false + } + if permissions.Create || permissions.Update { + return true + } + c.JSON(http.StatusForbidden, gin.H{"error": "current user does not have Wayne namespace role create or update permission"}) + return false +} + +func (h *SubsystemAuthHandler) listBusinessLineWayneNamespaces(c *gin.Context, businessLineID uint64) ([]model.BusinessLineWayneNamespace, bool) { + var rows []model.BusinessLineWayneNamespace + if err := h.db.Where("business_line_id = ?", businessLineID).Order("wayne_namespace_id ASC").Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return nil, false + } + return rows, true +} + +func (h *SubsystemAuthHandler) initializeWayneVisitor(c *gin.Context, businessLineID uint64, targetUsername string, operatorEmail string, skipWaynePermissionCheck bool, req service.WayneRoleBindingRequest) ([]gin.H, bool) { + namespaces, ok := h.listBusinessLineWayneNamespaces(c, businessLineID) + if !ok { + return nil, false + } + if len(namespaces) == 0 { + return []gin.H{}, true + } + groupIDs, err := h.wayne.NamespaceVisitorGroupIDs(c.Request.Context()) + if err != nil { + writeWayneRoleBindingError(c, nil, err) + return nil, false + } + req.GroupIDs = groupIDs + replace := true + req.Replace = &replace + + items := make([]gin.H, 0, len(namespaces)) + for _, namespace := range namespaces { + if !skipWaynePermissionCheck && !h.ensureWayneOperatorPermission(c, namespace.WayneNamespaceID, operatorEmail, http.MethodPut) { + return nil, false + } + result, err := h.wayne.BindNamespace(c.Request.Context(), namespace.WayneNamespaceID, targetUsername, operatorEmail, req) + if err != nil { + writeWayneRoleBindingError(c, result, err) + return nil, false + } + items = append(items, gin.H{ + "namespace_id": namespace.WayneNamespaceID, + "namespace_name": namespace.WayneNamespaceName, + "group_ids": groupIDs, + }) + } + return items, true +} + +func (h *SubsystemAuthHandler) writeAudit(c *gin.Context, claims *auth.Claims, businessLineID, namespaceID uint64, targetUsername, method, decision, requestID, reason string) { + h.audit.Write(service.AuditEntry{ + RequestID: requestID, + ActorUserID: claims.UserID, + ActorUsername: actorNameFromClaims(claims), + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "subsystem_auth.wayne." + strings.ToLower(method) + "." + decision, + ResourceType: "wayne_namespace", + ResourceID: strconv.FormatUint(namespaceID, 10), + ScopeType: "business_line", + ScopeID: businessLineID, + BusinessLineID: businessLineID, + NamespaceID: namespaceID, + Decision: decision, + Reason: truncateAuditReason(reason), + Metadata: map[string]any{ + "targetUsername": targetUsername, + }, + }) +} + +func subsystemOperatorEmail(c *gin.Context, claims *auth.Claims) (string, bool) { + operatorEmail := strings.TrimSpace(claims.Email) + if operatorEmail == "" { + operatorEmail = strings.TrimSpace(claims.Username) + } + if operatorEmail == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username is missing in token"}) + return "", false + } + return operatorEmail, true +} + +func actorNameFromClaims(claims *auth.Claims) string { + if claims == nil { + return "" + } + if value := strings.TrimSpace(claims.Email); value != "" { + return value + } + return claims.Username +} + +func wayneUsernameForUser(user model.User) string { + if value := strings.TrimSpace(user.Email); value != "" { + return value + } + return strings.TrimSpace(user.Username) +} diff --git a/server/internal/handler/wayne_role_binding.go b/server/internal/handler/wayne_role_binding.go index cda3ffa..b01813f 100644 --- a/server/internal/handler/wayne_role_binding.go +++ b/server/internal/handler/wayne_role_binding.go @@ -64,11 +64,12 @@ func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) { } func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) { - userID, ok := parseUintPathParam(c, "userid") - if !ok { + username := strings.TrimSpace(c.Param("username")) + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"}) return } - h.handleQuery(c, "user_roles", userID, "") + h.handleQuery(c, "user_roles", 0, username) } func (h *WayneRoleBindingHandler) NamespaceOperatorPermissions(c *gin.Context) { @@ -111,7 +112,7 @@ func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method st if !ok { return } - targetUserID, ok := roleBindingTargetUserID(c, req) + targetUsername, ok := roleBindingTargetUsername(c, req) if !ok { return } @@ -120,25 +121,25 @@ func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method st return } - result, err := h.call(c, scope, method, resourceID, targetUserID, operatorEmail, req) + result, err := h.call(c, scope, method, resourceID, targetUsername, operatorEmail, req) if err != nil { - h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUserID, "deny", req.RequestID, err.Error()) + h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUsername, "deny", req.RequestID, err.Error()) writeWayneRoleBindingError(c, result, err) return } - h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUserID, "allow", req.RequestID, "") + h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUsername, "allow", req.RequestID, "") writeWayneRoleBindingResult(c, result) } -func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, _ string) { +func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, username string) { var result *service.WayneRoleBindingResult var err error switch resourceType { case "namespaces": result, err = h.wayne.ListNamespaces(c.Request.Context()) case "user_roles": - result, err = h.wayne.GetUserRoles(c.Request.Context(), resourceID) + result, err = h.wayne.GetUserRoles(c.Request.Context(), username) default: c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported query resource"}) return @@ -181,17 +182,17 @@ func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scop writeWayneRoleBindingResult(c, result) } -func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, targetUserID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) { +func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, targetUsername string, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) { if scope == "namespace" { if method == http.MethodPut { - return h.wayne.BindNamespace(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) + return h.wayne.BindNamespace(c.Request.Context(), resourceID, targetUsername, operatorEmail, req) } - return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) + return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, targetUsername, operatorEmail, req) } if method == http.MethodPut { - return h.wayne.BindApp(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) + return h.wayne.BindApp(c.Request.Context(), resourceID, targetUsername, operatorEmail, req) } - return h.wayne.UnbindApp(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) + return h.wayne.UnbindApp(c.Request.Context(), resourceID, targetUsername, operatorEmail, req) } func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) { @@ -212,24 +213,19 @@ func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, b return req, true } -func roleBindingTargetUserID(c *gin.Context, req service.WayneRoleBindingRequest) (uint64, bool) { - if req.UserID != nil && *req.UserID != 0 { - return *req.UserID, true +func roleBindingTargetUsername(c *gin.Context, req service.WayneRoleBindingRequest) (string, bool) { + if username := strings.TrimSpace(req.Username); username != "" { + return username, true } - for _, key := range []string{"userId", "user_id", "userid"} { + for _, key := range []string{"username", "userName", "user_name"} { raw := strings.TrimSpace(c.Query(key)) if raw == "" { continue } - value, err := strconv.ParseUint(raw, 10, 64) - if err != nil || value == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid userId"}) - return 0, false - } - return value, true + return raw, true } - c.JSON(http.StatusBadRequest, gin.H{"error": "userId is required"}) - return 0, false + c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) + return "", false } func parseUintPathParam(c *gin.Context, name string) (uint64, bool) { @@ -280,7 +276,7 @@ func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBinding } } -func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUserID uint64, decision, requestID, reason string) { +func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) { h.audit.Write(service.AuditEntry{ RequestID: requestID, ActorUserID: userID, @@ -295,7 +291,7 @@ func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, oper Decision: decision, Reason: truncateAuditReason(reason), Metadata: map[string]any{ - "targetUserId": targetUserID, + "targetUsername": targetUsername, }, }) } diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 4300d49..0ea3481 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -74,9 +74,10 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { healthHandler := handler.NewHealthHandler(deps.DB) authHandler := handler.NewAuthHandler(deps.Config, authService) userHandler := handler.NewUserHandler(deps.DB) - businessLineHandler := handler.NewBusinessLineHandler(deps.DB) + businessLineHandler := handler.NewBusinessLineHandler(deps.DB, wayneRoleBindingService) wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService) wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService) + subsystemAuthHandler := handler.NewSubsystemAuthHandler(deps.DB, wayneRoleBindingService, auditService) clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService) samlHandler := handler.NewSAMLHandler(deps.Config, authService) oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService) @@ -115,13 +116,20 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { protected.PUT("/wayen/credential", wayenHandler.SaveCredential) protected.GET("/wayne/namespaces", wayneRoleBindingHandler.ListNamespaces) protected.GET("/wayne/groups", wayneRoleBindingHandler.ListGroups) - protected.GET("/wayne/users/:userid/roles", wayneRoleBindingHandler.GetCurrentUserRoles) + protected.GET("/wayne/users/:username/roles", wayneRoleBindingHandler.GetCurrentUserRoles) protected.GET("/wayne/namespaces/:namespaceid/operator-permissions", wayneRoleBindingHandler.NamespaceOperatorPermissions) protected.GET("/wayne/apps/:appid/operator-permissions", wayneRoleBindingHandler.AppOperatorPermissions) protected.PUT("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.BindNamespace) protected.DELETE("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.UnbindNamespace) protected.PUT("/wayne/apps/:appid/roles", wayneRoleBindingHandler.BindApp) protected.DELETE("/wayne/apps/:appid/roles", wayneRoleBindingHandler.UnbindApp) + protected.GET("/subsystem-auth/systems", subsystemAuthHandler.ListSystems) + protected.GET("/subsystem-auth/wayne/roles", subsystemAuthHandler.ListWayneNamespaceRoles) + protected.GET("/subsystem-auth/wayne/business-lines/:id/namespaces", subsystemAuthHandler.ListWayneBusinessLineNamespaces) + protected.GET("/subsystem-auth/wayne/users/:username/roles", subsystemAuthHandler.GetWayneUserRoles) + protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles) + protected.DELETE("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.UnbindWayneNamespaceRoles) + protected.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser) protected.GET("/clouddm/login", clouddmHandler.Login) } } diff --git a/server/internal/service/wayne_role_binding.go b/server/internal/service/wayne_role_binding.go index 8da4f91..67e8cba 100644 --- a/server/internal/service/wayne_role_binding.go +++ b/server/internal/service/wayne_role_binding.go @@ -24,7 +24,7 @@ var ( ) type WayneRoleBindingRequest struct { - UserID *uint64 `json:"userId,omitempty"` + Username string `json:"username,omitempty"` GroupIDs []uint64 `json:"groupIds,omitempty"` OperatorUserID *uint64 `json:"operatorUserId,omitempty"` OperatorName string `json:"operatorName,omitempty"` @@ -40,6 +40,19 @@ type WayneRoleBindingResult struct { Body []byte } +type WayneRoleGroup struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Comment string `json:"comment"` + Type int `json:"type"` +} + +type WayneOperatorPermissions struct { + Create bool `json:"create"` + Update bool `json:"update"` + Delete bool `json:"delete"` +} + type WayneRoleBindingHTTPError struct { StatusCode int Body []byte @@ -72,20 +85,20 @@ func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService { } } -func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, userID uint64, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { - return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%d/roles", namespaceID, userID), operatorEmail, req) +func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { + return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req) } -func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, userID uint64, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { - return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%d/roles", namespaceID, userID), operatorEmail, req) +func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { + return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req) } -func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, userID uint64, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { - return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%d/roles", appID, userID), operatorEmail, req) +func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { + return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req) } -func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, userID uint64, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { - return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%d/roles", appID, userID), operatorEmail, req) +func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { + return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req) } func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) { @@ -102,17 +115,52 @@ func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int return s.callRaw(ctx, http.MethodGet, internalPath, nil) } -func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, userID uint64) (*WayneRoleBindingResult, error) { - if userID == 0 { - return nil, ErrWayneRoleBindingRequestFailed +func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) { + groupType := 1 + result, err := s.ListGroups(ctx, &groupType) + if err != nil { + return nil, err } - return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%d/roles", userID), nil) + return parseWayneRoleGroups(result.Body) +} + +func (s *WayneRoleBindingService) NamespaceVisitorGroupIDs(ctx context.Context) ([]uint64, error) { + groups, err := s.ListNamespaceRoleGroups(ctx) + if err != nil { + return nil, err + } + ids := make([]uint64, 0, 1) + for _, group := range groups { + if isWayneVisitorRoleName(group.Name) { + ids = append(ids, group.ID) + } + } + if len(ids) == 0 { + return nil, fmt.Errorf("wayne visitor role group not found") + } + return ids, nil +} + +func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username string) (*WayneRoleBindingResult, error) { + username = strings.TrimSpace(username) + if username == "" { + return nil, ErrWayenEmailMissing + } + return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil) } func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) { return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail) } +func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneOperatorPermissions, error) { + result, err := s.NamespaceOperatorPermissions(ctx, namespaceID, operatorEmail) + if err != nil { + return nil, err + } + return parseWayneOperatorPermissions(result.Body) +} + func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) { return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/apps/%d/operator-permissions", appID), operatorEmail) } @@ -128,7 +176,7 @@ func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath req.OperatorUserID = nil req.OperatorName = operatorEmail - req.UserID = nil + req.Username = "" body, err := json.Marshal(req) if err != nil { @@ -249,3 +297,44 @@ func truncateForDebugLog(value string, limit int) string { } return value[:limit] + "...(truncated)" } + +func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) { + var wrapped struct { + Data []WayneRoleGroup `json:"data"` + Items []WayneRoleGroup `json:"items"` + } + if err := json.Unmarshal(body, &wrapped); err == nil { + if wrapped.Data != nil { + return wrapped.Data, nil + } + if wrapped.Items != nil { + return wrapped.Items, nil + } + } + var direct []WayneRoleGroup + if err := json.Unmarshal(body, &direct); err != nil { + return nil, err + } + return direct, nil +} + +func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) { + var wrapped struct { + Data struct { + Permissions WayneOperatorPermissions `json:"permissions"` + } `json:"data"` + Permissions WayneOperatorPermissions `json:"permissions"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + if wrapped.Data.Permissions != (WayneOperatorPermissions{}) { + return &wrapped.Data.Permissions, nil + } + return &wrapped.Permissions, nil +} + +func isWayneVisitorRoleName(name string) bool { + normalized := strings.ToLower(strings.TrimSpace(name)) + return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor") +} diff --git a/server/internal/service/wayne_role_binding_test.go b/server/internal/service/wayne_role_binding_test.go index 782ce95..65ec5a0 100644 --- a/server/internal/service/wayne_role_binding_test.go +++ b/server/internal/service/wayne_role_binding_test.go @@ -40,9 +40,8 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin operatorUserID := uint64(123) replace := false - targetUserID := uint64(2001) - result, err := svc.BindNamespace(context.Background(), 1, targetUserID, "eastsales@qiniu.com", WayneRoleBindingRequest{ - UserID: &targetUserID, + result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{ + Username: "target@example.com", GroupIDs: []uint64{10, 11}, OperatorUserID: &operatorUserID, OperatorName: "attacker@example.com", @@ -56,7 +55,7 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin if result.StatusCode != http.StatusOK { t.Fatalf("StatusCode = %d, want 200", result.StatusCode) } - if requestPath != "/api/v1/internal/namespaces/1/users/2001/roles" { + if requestPath != "/api/v1/internal/namespaces/1/users/target@example.com/roles" { t.Fatalf("requestPath = %q", requestPath) } if payload.OperatorName != "eastsales@qiniu.com" { @@ -65,8 +64,8 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin if payload.OperatorUserID != nil { t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID) } - if payload.UserID != nil { - t.Fatalf("UserID should be omitted from Wayne body, got %v", *payload.UserID) + if payload.Username != "" { + t.Fatalf("Username should be omitted from Wayne body, got %q", payload.Username) } if payload.Replace == nil || *payload.Replace { t.Fatalf("Replace = %v, want false", payload.Replace) @@ -82,23 +81,23 @@ func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) { { name: "unbind namespace", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.UnbindNamespace(context.Background(), 1, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}}) + return s.UnbindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}}) }, - want: "DELETE /api/v1/internal/namespaces/1/users/2001/roles", + want: "DELETE /api/v1/internal/namespaces/1/users/target@example.com/roles", }, { name: "bind app", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.BindApp(context.Background(), 3, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + return s.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) }, - want: "PUT /api/v1/internal/apps/3/users/2001/roles", + want: "PUT /api/v1/internal/apps/3/users/target@example.com/roles", }, { name: "unbind app", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.UnbindApp(context.Background(), 3, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + return s.UnbindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) }, - want: "DELETE /api/v1/internal/apps/3/users/2001/roles", + want: "DELETE /api/v1/internal/apps/3/users/target@example.com/roles", }, { name: "list namespaces", @@ -125,9 +124,9 @@ func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) { { name: "get user roles", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.GetUserRoles(context.Background(), 2001) + return s.GetUserRoles(context.Background(), "target@example.com") }, - want: "GET /api/v1/internal/users/2001/roles", + want: "GET /api/v1/internal/users/target@example.com/roles", }, { name: "namespace operator permissions", @@ -231,7 +230,7 @@ func TestWayneRoleBindingServiceHTTPError(t *testing.T) { WayneServiceName: "xinfra", WayneServiceAPISecretKey: "service-secret", }) - result, err := svc.BindApp(context.Background(), 3, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + result, err := svc.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) if err == nil { t.Fatal("expected error") }