From a10d4a5921137fa547dce5b1fd2903bcdce2b3b6 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 15:47:32 +0800 Subject: [PATCH 1/5] fix: require Wayne user ID for role binding --- server/README.md | 11 +-- server/internal/handler/wayne_role_binding.go | 78 ++++++++++++------- server/internal/router/router.go | 2 +- server/internal/service/wayne_role_binding.go | 27 +++---- .../service/wayne_role_binding_test.go | 27 ++++--- 5 files changed, 85 insertions(+), 60 deletions(-) diff --git a/server/README.md b/server/README.md index 8c280b8..3c4492f 100644 --- a/server/README.md +++ b/server/README.md @@ -285,14 +285,14 @@ Wayne 会把回调地址拼成: ## Wayne 授权代理接口 -AuthServer 的 Wayne 授权代理接口不要求调用方传 Wayne user ID。后端会从当前 `authserver_token` 里取 `email`,把它作为 Wayne username 传给 Wayne internal API。 +AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `userId`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户 ID 由请求体或路径参数提供。 对外接口: ```text GET /auth/api/v1/wayne/namespaces GET /auth/api/v1/wayne/groups -GET /auth/api/v1/wayne/users/me/roles +GET /auth/api/v1/wayne/users/:userid/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,6 +309,7 @@ Authorization: Bearer Content-Type: application/json { + "userId": 2001, "groupIds": [10, 11], "replace": false, "requestId": "req-001", @@ -316,13 +317,13 @@ Content-Type: application/json } ``` -AuthServer 转发到 Wayne internal API 时会使用 token email: +AuthServer 转发到 Wayne internal API 时会使用请求体里的 `userId`: ```text -PUT /api/v1/internal/namespaces/1/users//roles +PUT /api/v1/internal/namespaces/1/users/2001/roles ``` -并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。 +并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`userId` 只用于 Wayne path,不会透传到 Wayne 请求体。 相关配置: diff --git a/server/internal/handler/wayne_role_binding.go b/server/internal/handler/wayne_role_binding.go index 04c036d..cda3ffa 100644 --- a/server/internal/handler/wayne_role_binding.go +++ b/server/internal/handler/wayne_role_binding.go @@ -64,11 +64,11 @@ func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) { } func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) { - username, ok := currentTokenEmail(c) + userID, ok := parseUintPathParam(c, "userid") if !ok { return } - h.handleQuery(c, "user_roles", 0, username) + h.handleQuery(c, "user_roles", userID, "") } func (h *WayneRoleBindingHandler) NamespaceOperatorPermissions(c *gin.Context) { @@ -111,30 +111,34 @@ func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method st if !ok { return } + targetUserID, ok := roleBindingTargetUserID(c, req) + if !ok { + return + } if method == http.MethodPut && len(req.GroupIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"}) return } - result, err := h.call(c, scope, method, resourceID, operatorEmail, req) + result, err := h.call(c, scope, method, resourceID, targetUserID, operatorEmail, req) if err != nil { - h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "deny", req.RequestID, err.Error()) + h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUserID, "deny", req.RequestID, err.Error()) writeWayneRoleBindingError(c, result, err) return } - h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "allow", req.RequestID, "") + h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUserID, "allow", req.RequestID, "") writeWayneRoleBindingResult(c, result) } -func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, username string) { +func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, _ 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(), username) + result, err = h.wayne.GetUserRoles(c.Request.Context(), resourceID) default: c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported query resource"}) return @@ -177,17 +181,17 @@ func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scop writeWayneRoleBindingResult(c, result) } -func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) { +func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, targetUserID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) { if scope == "namespace" { if method == http.MethodPut { - return h.wayne.BindNamespace(c.Request.Context(), resourceID, operatorEmail, req) + return h.wayne.BindNamespace(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) } - return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, operatorEmail, req) + return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) } if method == http.MethodPut { - return h.wayne.BindApp(c.Request.Context(), resourceID, operatorEmail, req) + return h.wayne.BindApp(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) } - return h.wayne.UnbindApp(c.Request.Context(), resourceID, operatorEmail, req) + return h.wayne.UnbindApp(c.Request.Context(), resourceID, targetUserID, operatorEmail, req) } func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) { @@ -208,6 +212,26 @@ 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 + } + for _, key := range []string{"userId", "user_id", "userid"} { + 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 + } + c.JSON(http.StatusBadRequest, gin.H{"error": "userId is required"}) + return 0, false +} + func parseUintPathParam(c *gin.Context, name string) (uint64, bool) { raw := strings.TrimSpace(c.Param(name)) value, err := strconv.ParseUint(raw, 10, 64) @@ -256,21 +280,7 @@ func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBinding } } -func currentTokenEmail(c *gin.Context) (string, bool) { - claims, ok := CurrentClaims(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) - return "", false - } - email := strings.TrimSpace(claims.Email) - if email == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"}) - return "", false - } - return email, true -} - -func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) { +func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUserID uint64, decision, requestID, reason string) { h.audit.Write(service.AuditEntry{ RequestID: requestID, ActorUserID: userID, @@ -283,9 +293,9 @@ func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, oper ScopeType: scope, ScopeID: resourceID, Decision: decision, - Reason: reason, + Reason: truncateAuditReason(reason), Metadata: map[string]any{ - "targetUsername": targetUsername, + "targetUserId": targetUserID, }, }) } @@ -310,6 +320,14 @@ func (h *WayneRoleBindingHandler) writeQueryAudit(c *gin.Context, resourceType s ResourceType: "wayne_" + resourceType, ResourceID: strconv.FormatUint(resourceID, 10), Decision: decision, - Reason: reason, + Reason: truncateAuditReason(reason), }) } + +func truncateAuditReason(reason string) string { + const maxReasonBytes = 512 + if len(reason) <= maxReasonBytes { + return reason + } + return reason[:maxReasonBytes] +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index b0045b2..4300d49 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -115,7 +115,7 @@ 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/me/roles", wayneRoleBindingHandler.GetCurrentUserRoles) + protected.GET("/wayne/users/:userid/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) diff --git a/server/internal/service/wayne_role_binding.go b/server/internal/service/wayne_role_binding.go index d331f3f..8da4f91 100644 --- a/server/internal/service/wayne_role_binding.go +++ b/server/internal/service/wayne_role_binding.go @@ -24,6 +24,7 @@ var ( ) type WayneRoleBindingRequest struct { + UserID *uint64 `json:"userId,omitempty"` GroupIDs []uint64 `json:"groupIds,omitempty"` OperatorUserID *uint64 `json:"operatorUserId,omitempty"` OperatorName string `json:"operatorName,omitempty"` @@ -71,20 +72,20 @@ func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService { } } -func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username 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)), username, req) +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) UnbindNamespace(ctx context.Context, namespaceID uint64, username 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)), username, 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) BindApp(ctx context.Context, appID uint64, username 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)), username, 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) UnbindApp(ctx context.Context, appID uint64, username 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)), username, 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) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) { @@ -101,12 +102,11 @@ func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int return s.callRaw(ctx, http.MethodGet, internalPath, nil) } -func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username string) (*WayneRoleBindingResult, error) { - username = strings.TrimSpace(username) - if username == "" { - return nil, ErrWayenEmailMissing +func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, userID uint64) (*WayneRoleBindingResult, error) { + if userID == 0 { + return nil, ErrWayneRoleBindingRequestFailed } - return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil) + return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%d/roles", userID), nil) } func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) { @@ -128,6 +128,7 @@ func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath req.OperatorUserID = nil req.OperatorName = operatorEmail + req.UserID = nil body, err := json.Marshal(req) if err != nil { diff --git a/server/internal/service/wayne_role_binding_test.go b/server/internal/service/wayne_role_binding_test.go index c665046..782ce95 100644 --- a/server/internal/service/wayne_role_binding_test.go +++ b/server/internal/service/wayne_role_binding_test.go @@ -40,7 +40,9 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin operatorUserID := uint64(123) replace := false - result, err := svc.BindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{ + targetUserID := uint64(2001) + result, err := svc.BindNamespace(context.Background(), 1, targetUserID, "eastsales@qiniu.com", WayneRoleBindingRequest{ + UserID: &targetUserID, GroupIDs: []uint64{10, 11}, OperatorUserID: &operatorUserID, OperatorName: "attacker@example.com", @@ -54,7 +56,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/eastsales@qiniu.com/roles" { + if requestPath != "/api/v1/internal/namespaces/1/users/2001/roles" { t.Fatalf("requestPath = %q", requestPath) } if payload.OperatorName != "eastsales@qiniu.com" { @@ -63,6 +65,9 @@ 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.Replace == nil || *payload.Replace { t.Fatalf("Replace = %v, want false", payload.Replace) } @@ -77,23 +82,23 @@ func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) { { name: "unbind namespace", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.UnbindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}}) + return s.UnbindNamespace(context.Background(), 1, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}}) }, - want: "DELETE /api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles", + want: "DELETE /api/v1/internal/namespaces/1/users/2001/roles", }, { name: "bind app", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + return s.BindApp(context.Background(), 3, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) }, - want: "PUT /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles", + want: "PUT /api/v1/internal/apps/3/users/2001/roles", }, { name: "unbind app", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.UnbindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + return s.UnbindApp(context.Background(), 3, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) }, - want: "DELETE /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles", + want: "DELETE /api/v1/internal/apps/3/users/2001/roles", }, { name: "list namespaces", @@ -120,9 +125,9 @@ func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) { { name: "get user roles", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.GetUserRoles(context.Background(), "eastsales@qiniu.com") + return s.GetUserRoles(context.Background(), 2001) }, - want: "GET /api/v1/internal/users/eastsales@qiniu.com/roles", + want: "GET /api/v1/internal/users/2001/roles", }, { name: "namespace operator permissions", @@ -226,7 +231,7 @@ func TestWayneRoleBindingServiceHTTPError(t *testing.T) { WayneServiceName: "xinfra", WayneServiceAPISecretKey: "service-secret", }) - result, err := svc.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + result, err := svc.BindApp(context.Background(), 3, 2001, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) if err == nil { t.Fatal("expected error") } From add4b1566e8d38009ea691463d69a830e68c9015 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 16:32:46 +0800 Subject: [PATCH 2/5] chore: refresh generated component types --- frontend/components.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index d518bc8..6f21ae0 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -22,8 +22,6 @@ declare module 'vue' { ElSelect: typeof import('element-plus/es')['ElSelect'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default'] From 8d49230247efe4efebd24a2b029f695ad765be91 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 19:26:40 +0800 Subject: [PATCH 3/5] 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") } From a112cf742ce86971c94ed2ea2c373e7c98927acc Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 19:27:33 +0800 Subject: [PATCH 4/5] feat(frontend): add Wayne subsystem authorization page --- frontend/src/api/subsystemAuth.ts | 143 ++++ frontend/src/components/Layout/AppSidebar.vue | 2 +- frontend/src/stores/auth.ts | 20 +- .../src/views/subsystem/Authorization.vue | 649 ++++++++++++++---- 4 files changed, 664 insertions(+), 150 deletions(-) create mode 100644 frontend/src/api/subsystemAuth.ts diff --git a/frontend/src/api/subsystemAuth.ts b/frontend/src/api/subsystemAuth.ts new file mode 100644 index 0000000..bda2ba6 --- /dev/null +++ b/frontend/src/api/subsystemAuth.ts @@ -0,0 +1,143 @@ +import { getToken } from '@/utils/auth' + +export interface SubsystemAuthSystem { + key: string + name: string + enabled: boolean +} + +export interface WayneRole { + id: number + name: string + comment?: string + type: number +} + +export interface WaynePermission { + create: boolean + update: boolean + delete: boolean +} + +export interface WayneBusinessLineNamespace { + id: number + name: string + kubeNamespace: string + permissions?: WaynePermission + can_bind?: boolean + can_unbind?: boolean + permission_error?: string +} + +export interface WayneRoleBindingPayload { + groupIds?: number[] + replace?: boolean + requestId?: string + reason?: string + dryRun?: boolean +} + +export interface WayneUserRoles { + userId?: number + userName?: string + namespaces?: Array<{ + namespace?: { + id: number + name: string + } + groups?: Array<{ + id: number + name: string + }> + }> + apps?: unknown[] +} + +export const subsystemAuthApi = { + async listSystems(): Promise { + const data = await authRequest('/auth/api/v1/subsystem-auth/systems') + return Array.isArray(data.items) ? data.items : [] + }, + + async listWayneRoles(): Promise { + const data = await authRequest('/auth/api/v1/subsystem-auth/wayne/roles') + return Array.isArray(data.items) ? data.items : [] + }, + + async listWayneNamespaces(businessLineId: number): Promise { + const data = await authRequest(`/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/namespaces`) + return Array.isArray(data.items) ? data.items : [] + }, + + async getWayneUserRoles(username: string): Promise { + const data = await authRequest(`/auth/api/v1/subsystem-auth/wayne/users/${encodeURIComponent(username)}/roles`) + return data.data || data + }, + + async bindWayneNamespaceRoles( + businessLineId: number, + namespaceId: number, + username: string, + payload: WayneRoleBindingPayload, + ): Promise { + return authRequest( + `/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/namespaces/${namespaceId}/users/${encodeURIComponent(username)}/roles`, + { + method: 'PUT', + body: JSON.stringify(payload), + }, + ) + }, + + async unbindWayneNamespaceRoles( + businessLineId: number, + namespaceId: number, + username: string, + payload: WayneRoleBindingPayload = {}, + ): Promise { + return authRequest( + `/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/namespaces/${namespaceId}/users/${encodeURIComponent(username)}/roles`, + { + method: 'DELETE', + body: JSON.stringify(payload), + }, + ) + }, + + async initWayneBusinessLineUser(businessLineId: number, userId: number): Promise { + return authRequest(`/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/users/${userId}/init`, { + method: 'POST', + }) + }, +} + +async function authRequest(path: string, init: RequestInit = {}) { + const token = getToken() + const response = await fetch(path, { + ...init, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init.headers, + }, + }) + const text = await response.text() + const data = parseResponseBody(text) + if (!response.ok) { + const message = data?.error || data?.message || text || `HTTP ${response.status}` + throw new Error(message) + } + return data || {} +} + +function parseResponseBody(text: string) { + if (!text.trim()) { + return {} + } + try { + return JSON.parse(text) + } catch { + return { error: text } + } +} diff --git a/frontend/src/components/Layout/AppSidebar.vue b/frontend/src/components/Layout/AppSidebar.vue index 295871e..a820f0e 100644 --- a/frontend/src/components/Layout/AppSidebar.vue +++ b/frontend/src/components/Layout/AppSidebar.vue @@ -97,7 +97,7 @@ const route = useRoute() const authStore = useAuthStore() const businessLineStore = useBusinessLineStore() const portalExpanded = ref(true) -const isPlatformAdmin = computed(() => authStore.user?.is_admin === true) +const isPlatformAdmin = computed(() => authStore.isAdmin) const isBusinessLineAdmin = computed(() => businessLineStore.isCurrentAdmin) const subsystems = [ diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index cd68e3a..ad844b4 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,11 +1,12 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { computed, ref } from 'vue' import { authApi } from '@/api/auth' import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '@/utils/auth' export const useAuthStore = defineStore('auth', () => { const token = ref(getToken()) const user = ref(getUser()) + const isAdmin = computed(() => user.value?.is_admin === true || decodeAdminClaim(token.value)) function setAuth(newToken: string, newUser: any) { token.value = newToken @@ -45,6 +46,7 @@ export const useAuthStore = defineStore('auth', () => { return { token, user, + isAdmin, setAuth, setSessionToken, refreshUser, @@ -52,3 +54,19 @@ export const useAuthStore = defineStore('auth', () => { isLoggedIn, } }) + +function decodeAdminClaim(token: string | null): boolean { + if (!token) return false + try { + const payload = JSON.parse(decodeBase64Url(token.split('.')[1] || '')) + return payload.admin === true || payload.is_admin === true + } catch { + return false + } +} + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=') + return atob(padded) +} diff --git a/frontend/src/views/subsystem/Authorization.vue b/frontend/src/views/subsystem/Authorization.vue index 12c06fa..a0d2e3f 100644 --- a/frontend/src/views/subsystem/Authorization.vue +++ b/frontend/src/views/subsystem/Authorization.vue @@ -3,118 +3,178 @@

子系统赋权

-

Wayne / CloudDM 入口权限、默认角色与授权状态

+

{{ currentBusinessLineName }} · Wayne namespace 角色授权

+
+
+ 刷新 + + 保存授权 +
- 新增授权
接入子系统
-
2
-
Wayne · CloudDM
+
{{ enabledSystemCount }}
+
{{ systemSummary }}
-
授权主体
-
6
-
用户 3 · 用户组 3
+
Wayne Namespace
+
{{ wayneNamespaces.length }}
+
当前业务线映射
-
待审批
-
2
-
最近提交 10:18
+
可选角色
+
{{ wayneRoles.length }}
+
{{ roleSummary }}
-
默认授权
-
● 生效
-
新用户默认只读
+
当前操作权限
+
● {{ operatorStateText }}
+
{{ operatorStateDetail }}
-
- - - - - - - - - - - - - - - - - -
-
-
+
- +
-

{{ system.name }}

-

{{ system.defaultPolicy }}

+

Wayne

+

业务线 namespace 角色绑定,默认新用户初始化为访客

-
+
{{ role.name }} - {{ role.count }} + #{{ role.id }} +
+
暂无角色
+
+
+ +
+
+ +
+

CloudDM

+

接口预留,当前不开放授权操作

+
+
+ 状态 + 未启用 +
+
+
+
+ +
+
+

Wayne 授权操作

+ 数据源:AuthServer · Wayne internal API +
+
+ + + + + + + + + + + + + + + + +
+ + 保存角色 + + + 清空角色 + + + 初始化访客 + +
+
+ +
+
授权规则
+

当前账号必须是平台管理员或当前业务线管理员。

+

保存前会再次校验 Wayne namespace 的授权能力。

+

用户加入业务线时后端会自动初始化 Wayne 访客角色。

+
+
+
+ +
+
+

当前权限

+ 只读模式 +
+
+ 当前账号没有 Wayne namespace 角色绑定权限,只展示现有权限。
-

授权列表

- 数据源:AuthServer · 子系统授权 +

当前用户 Wayne 角色

+ {{ selectedUsername || '未选择用户' }}
- - - - - - - - + + + + + - + - - + - - - + + + +
授权主体类型子系统角色 / 范围来源状态最近变更操作NamespaceKube Namespace当前角色授权能力操作
-
- {{ item.initial }} -
-
{{ item.principal }}
-
{{ item.detail }}
-
-
+
{{ namespace.name || '-' }}
+
id={{ namespace.id }}
{{ item.type === 'user' ? '用户' : '用户组' }}{{ item.system }}{{ namespace.kubeNamespace || '-' }} - {{ item.role }} - {{ item.scope }} + + {{ role }} + + 未绑定 {{ item.source }}● {{ item.statusText }}{{ item.updatedAt }} + ● {{ namespace.permission_error }} + ● 可授权 + ● 无授权权限 +
- - + +
当前业务线没有绑定 Wayne namespace
@@ -123,62 +183,272 @@ From 981e9557fe00b977751a23d6eadee54d83487e7b Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 17 Jul 2026 19:28:19 +0800 Subject: [PATCH 5/5] feat(frontend): make mock panels follow business line --- frontend/src/utils/businessLineMock.ts | 113 ++++++++++++++++++++ frontend/src/views/cluster/ClusterList.vue | 50 +++++++-- frontend/src/views/config/ConfigCenter.vue | 24 +++-- frontend/src/views/dashboard/Index.vue | 50 +++++---- frontend/src/views/monitor/Alert.vue | 98 ++++++----------- frontend/src/views/monitor/Monitor.vue | 68 ++++-------- frontend/src/views/resource/Management.vue | 39 ++++--- frontend/src/views/resource/StatusBoard.vue | 52 ++++----- frontend/src/views/resource/Tenants.vue | 60 +++++++---- frontend/src/views/service/Catalog.vue | 17 ++- frontend/src/views/service/Management.vue | 26 +++-- frontend/src/views/task/TaskCenter.vue | 27 +++-- 12 files changed, 383 insertions(+), 241 deletions(-) create mode 100644 frontend/src/utils/businessLineMock.ts diff --git a/frontend/src/utils/businessLineMock.ts b/frontend/src/utils/businessLineMock.ts new file mode 100644 index 0000000..95d0e1c --- /dev/null +++ b/frontend/src/utils/businessLineMock.ts @@ -0,0 +1,113 @@ +import { computed } from 'vue' +import { useBusinessLineStore } from '@/stores/businessLine' + +export const BUSINESS_LINE_NAMES = ['kodo', 'linxi', 'xinfra', 'las'] as const +export type BusinessLineName = (typeof BUSINESS_LINE_NAMES)[number] + +interface BusinessLineMockProfile { + name: BusinessLineName + clusters: number + nodes: number + physicalMachines: number + virtualMachines: number + cpuAllocated: number + components: number + mysql: number + redis: number + alertsP0: number + alertsP1: number + tasksRunning: number + primaryZone: string + secondaryZone: string + servicePrefix: string +} + +const profiles: Record = { + kodo: { + name: 'kodo', + clusters: 3, + nodes: 128, + physicalMachines: 186, + virtualMachines: 512, + cpuAllocated: 61, + components: 214, + mysql: 38, + redis: 92, + alertsP0: 1, + alertsP1: 5, + tasksRunning: 2, + primaryZone: 'IDC-华北机房', + secondaryZone: 'IDC-华东机房', + servicePrefix: 'kodo', + }, + linxi: { + name: 'linxi', + clusters: 2, + nodes: 74, + physicalMachines: 96, + virtualMachines: 238, + cpuAllocated: 47, + components: 128, + mysql: 21, + redis: 46, + alertsP0: 0, + alertsP1: 2, + tasksRunning: 1, + primaryZone: 'IDC-华东机房', + secondaryZone: '阿里云-华南', + servicePrefix: 'linxi', + }, + xinfra: { + name: 'xinfra', + clusters: 2, + nodes: 52, + physicalMachines: 68, + virtualMachines: 156, + cpuAllocated: 39, + components: 84, + mysql: 12, + redis: 31, + alertsP0: 0, + alertsP1: 1, + tasksRunning: 1, + primaryZone: 'IDC-华北机房', + secondaryZone: '香港 IDC', + servicePrefix: 'xinfra', + }, + las: { + name: 'las', + clusters: 1, + nodes: 34, + physicalMachines: 42, + virtualMachines: 118, + cpuAllocated: 31, + components: 66, + mysql: 8, + redis: 18, + alertsP0: 0, + alertsP1: 3, + tasksRunning: 0, + primaryZone: '七牛-新加坡', + secondaryZone: 'AWS-美国', + servicePrefix: 'las', + }, +} + +export function normalizeBusinessLineName(name?: string | null): BusinessLineName { + const normalized = String(name || '').trim().toLowerCase() + if (BUSINESS_LINE_NAMES.includes(normalized as BusinessLineName)) { + return normalized as BusinessLineName + } + return 'kodo' +} + +export function useBusinessLineMockProfile() { + const businessLineStore = useBusinessLineStore() + const currentName = computed(() => normalizeBusinessLineName(businessLineStore.current?.name)) + const profile = computed(() => profiles[currentName.value]) + + return { + currentName, + profile, + } +} diff --git a/frontend/src/views/cluster/ClusterList.vue b/frontend/src/views/cluster/ClusterList.vue index 80b0536..7f945fe 100644 --- a/frontend/src/views/cluster/ClusterList.vue +++ b/frontend/src/views/cluster/ClusterList.vue @@ -24,7 +24,7 @@ - + {{ cluster.name }} {{ cluster.zone }} ● {{ cluster.status }} @@ -43,7 +43,7 @@
-

rke2-bj-prod-01 · 节点列表(节选)

+

{{ businessLineClusters[0]?.name }} · 节点列表(节选)

node-label 多租户隔离
@@ -60,7 +60,7 @@ - + {{ node.name }} {{ node.ip }} {{ node.label }} @@ -77,7 +77,10 @@