diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 6f21ae0..67fe957 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -18,7 +18,6 @@ declare module 'vue' { ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElOption: typeof import('element-plus/es')['ElOption'] - ElSegmented: typeof import('element-plus/es')['ElSegmented'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] diff --git a/frontend/src/api/businessLine.ts b/frontend/src/api/businessLine.ts index ed0a073..5de8442 100644 --- a/frontend/src/api/businessLine.ts +++ b/frontend/src/api/businessLine.ts @@ -59,7 +59,6 @@ export const businessLineApi = { business_line_id: number target_user_id: number target_business_line_id: number - permission: 0 | 1 }): Promise { await request('/auth/api/v1/business-lines/authorizations', { method: 'POST', @@ -69,7 +68,7 @@ export const businessLineApi = { async listWayneNamespaces(): Promise { const data = await request('/auth/api/v1/wayne/namespaces') - const items = Array.isArray(data.data) ? data.data : Array.isArray(data.items) ? data.items : [] + const items = Array.isArray(data.data?.list) ? data.data.list : [] return items.map((item: any) => ({ id: Number(item.id), name: item.name || '', diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 7f3c85c..fdb27f9 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -6,9 +6,14 @@ export interface UserOption { } export const userApi = { - async list(): Promise { + async list(params: { businessLineId?: number } = {}): Promise { const token = getToken() - const response = await fetch('/auth/api/v1/users', { + const query = new URLSearchParams() + if (params.businessLineId) { + query.set('business_line_id', String(params.businessLineId)) + } + const path = query.toString() ? `/auth/api/v1/users?${query}` : '/auth/api/v1/users' + const response = await fetch(path, { headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), diff --git a/frontend/src/views/businessLine/Assignment.vue b/frontend/src/views/businessLine/Assignment.vue index 97b4902..e66124a 100644 --- a/frontend/src/views/businessLine/Assignment.vue +++ b/frontend/src/views/businessLine/Assignment.vue @@ -19,9 +19,6 @@ - - - 保存分配 @@ -72,18 +69,11 @@ const savingWayneNamespaces = ref(false) const grantForm = reactive<{ target_user_id: number | null target_business_line_id: number | null - permission: 0 | 1 }>({ target_user_id: null, target_business_line_id: null, - permission: 1, }) -const permissionOptions = [ - { label: '管理员', value: 0 }, - { label: '普通用户', value: 1 }, -] - watch( () => businessLineStore.current?.id, (id) => { @@ -139,7 +129,6 @@ async function grantPermission() { business_line_id: businessLineID, target_user_id: grantForm.target_user_id, target_business_line_id: grantForm.target_business_line_id, - permission: grantForm.permission, }) ElMessage.success('已保存业务线分配') } catch (error) { diff --git a/frontend/src/views/subsystem/Authorization.vue b/frontend/src/views/subsystem/Authorization.vue index a0d2e3f..95e02eb 100644 --- a/frontend/src/views/subsystem/Authorization.vue +++ b/frontend/src/views/subsystem/Authorization.vue @@ -239,10 +239,11 @@ const operatorStateDetail = computed(() => { watch( () => businessLineStore.current?.id, async () => { + selectedUserId.value = null selectedNamespaceId.value = null selectedRoleIds.value = [] userRoles.value = null - await loadBusinessLineData() + await Promise.all([loadUsers(), loadBusinessLineData()]) }, ) @@ -286,14 +287,25 @@ async function loadSystems() { } async function loadUsers() { + const businessLineId = currentBusinessLineId.value + if (!businessLineId) { + users.value = [] + selectedUserId.value = null + return + } loadingUsers.value = true try { - users.value = await userApi.list() + users.value = await userApi.list({ businessLineId }) if (!selectedUserId.value && users.value.length) { selectedUserId.value = users.value[0].uid await loadSelectedUserRoles() + } else if (selectedUserId.value && !users.value.some((user) => user.uid === selectedUserId.value)) { + selectedUserId.value = users.value[0]?.uid || null + await loadSelectedUserRoles() } } catch (error) { + users.value = [] + selectedUserId.value = null ElMessage.error(error instanceof Error ? error.message : '查询用户失败') } finally { loadingUsers.value = false diff --git a/server/.env.example b/server/.env.example index 97eae34..ff19953 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,6 +7,10 @@ OIDC_TOKEN_ENDPOINT=http://localhost:8080/auth/oauth/token OIDC_USERINFO_ENDPOINT=http://localhost:8080/auth/oauth/userinfo OIDC_JWKS_URI=http://localhost:8080/auth/oauth/jwks CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm +WAYNE_API_BASE_URL=http://wayne-backend:8080 +WAYNE_ADMIN_USERNAME=admin +WAYNE_ADMIN_PASSWORD=change-this-wayne-admin-password +WAYNE_TOKEN_TTL_MINUTES=1440 MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local AUTO_MIGRATE=true diff --git a/server/README.md b/server/README.md index d6b4fa1..781f137 100644 --- a/server/README.md +++ b/server/README.md @@ -285,7 +285,7 @@ Wayne 会把回调地址拼成: ## Wayne 授权代理接口 -AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户名由请求体或路径参数提供。 +AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端使用配置里的 Wayne 超级管理员账号登录 Wayne 原生 API,拿到 Wayne token 后存入数据库,后续代理请求都带 `Authorization: Bearer `。token 过期或 Wayne 返回 401 时会重新调用 Wayne 登录接口获取 token 并重试一次。 对外接口: @@ -317,30 +317,36 @@ Content-Type: application/json } ``` -AuthServer 转发到 Wayne internal API 时会使用请求体里的 `username`: +AuthServer 转发到 Wayne 原生 API 时会先按 `username` 查询 Wayne 用户,拿到 Wayne `user.id` 后再查询目标 namespace/app 下的用户角色绑定记录: ```text -PUT /api/v1/internal/namespaces/1/users/target@example.com/roles +GET /api/v1/users?name=target@example.com +GET /api/v1/namespaces/1/users?userId= ``` -并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`username` 只用于 Wayne path,不会透传到 Wayne 请求体。 +如果绑定记录存在,会调用原生更新接口;不存在则调用原生创建接口: + +```text +POST /api/v1/namespaces/1/users +PUT /api/v1/namespaces/1/users/ +``` + +删除角色时会先查询绑定记录,再调用: + +```text +DELETE /api/v1/namespaces/1/users/ +``` 相关配置: ```env -WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080 -WAYNE_SERVICE_NAME=xinfra -WAYNE_SERVICE_API_SECRET_KEY= +WAYNE_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080 +WAYNE_ADMIN_USERNAME=admin +WAYNE_ADMIN_PASSWORD= +WAYNE_TOKEN_TTL_MINUTES=1440 ``` -Wayne internal API 签名规则: - -```text -bodyHash = SHA256_HEX(rawBody) -payload = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash -signature = HMAC_SHA256_HEX(secret, payload) -X-Wayne-Signature = "sha256=" + signature -``` +`WAYNE_API_BASE_URL` 未配置时会兼容读取旧的 `WAYNE_INTERNAL_API_BASE_URL`。Wayne admin token 会写入 `wayne_tokens` 表,服务重启后优先复用未过期 token。 ## 子系统赋权接口 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 38b1e3b..168c450 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -37,6 +37,10 @@ type Config struct { WayenLoginValue string WayenOAuthRef string WayenOAuthLoginURL string + WayneAPIBaseURL string + WayneAdminUsername string + WayneAdminPassword string + WayneTokenTTLMinutes int WayneInternalAPIBaseURL string WayneServiceName string WayneServiceAPISecretKey string @@ -88,6 +92,10 @@ func Load() Config { WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"), WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"), WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")), + WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))), + WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""), + WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""), + WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440), WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")), WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"), WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""), diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 435fe2d..1a25dfc 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -19,6 +19,7 @@ func AutoMigrate(db *gorm.DB) error { &model.BusinessLineUser{}, &model.BusinessLineWayneNamespace{}, &model.AccessToken{}, + &model.WayneToken{}, &model.AuditLog{}, ) } diff --git a/server/internal/handler/business_line.go b/server/internal/handler/business_line.go index 208f736..208a5c5 100644 --- a/server/internal/handler/business_line.go +++ b/server/internal/handler/business_line.go @@ -31,7 +31,6 @@ type GrantBusinessLinePermissionRequest struct { BusinessLineID uint64 `json:"business_line_id" binding:"required"` TargetUserID uint64 `json:"target_user_id" binding:"required"` TargetBusinessLineID uint64 `json:"target_business_line_id" binding:"required"` - Permission int `json:"permission"` } type BusinessLinePayload struct { @@ -223,10 +222,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if req.Permission != 0 && req.Permission != 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "permission must be 0 or 1"}) - return - } if !claims.IsAdmin { var currentBinding model.BusinessLineUser @@ -278,7 +273,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { binding = model.BusinessLineUser{ BusinessLineID: req.TargetBusinessLineID, UserID: req.TargetUserID, - Permission: req.Permission, + Permission: 1, } if err := h.db.Create(&binding).Error; err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) @@ -288,30 +283,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) { } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return - } else if binding.Permission != req.Permission { - if err := h.db.Model(&binding).Update("permission", req.Permission).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - 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{ @@ -321,7 +292,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, + "created": created, }) } diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go index e4f5e75..65f9e1e 100644 --- a/server/internal/handler/user.go +++ b/server/internal/handler/user.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strconv" "github.com/1024XEngineer/xinfra/server/internal/model" @@ -32,8 +33,33 @@ func (h *UserHandler) Me(c *gin.Context) { } func (h *UserHandler) List(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + + query := h.db.Model(&model.User{}).Where("users.deleted_at IS NULL") + if businessLineID := c.Query("business_line_id"); businessLineID != "" { + if !claims.IsAdmin { + id, err := strconv.ParseUint(businessLineID, 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid business line id"}) + return + } + var binding model.BusinessLineUser + if err := h.db.Where("business_line_id = ? AND user_id = ?", id, claims.UserID).First(&binding).Error; err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "current user is not in business line"}) + return + } + } + query = query. + Joins("JOIN business_line_users ON business_line_users.user_id = users.id"). + Where("business_line_users.business_line_id = ?", businessLineID) + } + var users []model.User - if err := h.db.Where("deleted_at IS NULL").Order("id ASC").Find(&users).Error; err != nil { + if err := query.Order("users.id ASC").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 3c32231..1fbcbc6 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -71,6 +71,15 @@ type AccessToken struct { RevokedAt *time.Time `json:"revoked_at"` } +type WayneToken struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Account string `gorm:"size:128;not null;uniqueIndex" json:"account"` + Token string `gorm:"type:text;not null" json:"-"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type AuditLog struct { ID uint64 `gorm:"primaryKey" json:"id"` RequestID string `gorm:"size:128;not null;default:''" json:"request_id"` diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 0ea3481..8141566 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -69,7 +69,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { auditService := service.NewAuditService(deps.DB) authService := service.NewAuthService(deps.Config, deps.DB, auditService) wayenService := service.NewWayenService(deps.Config, deps.DB) - wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config) + wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config, deps.DB) healthHandler := handler.NewHealthHandler(deps.DB) authHandler := handler.NewAuthHandler(deps.Config, authService) diff --git a/server/internal/service/wayne_role_binding.go b/server/internal/service/wayne_role_binding.go index 67e8cba..7e2d081 100644 --- a/server/internal/service/wayne_role_binding.go +++ b/server/internal/service/wayne_role_binding.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -15,14 +16,19 @@ import ( "time" "github.com/1024XEngineer/xinfra/server/internal/config" - "github.com/1024XEngineer/xinfra/server/internal/wayne" + "github.com/1024XEngineer/xinfra/server/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) var ( - ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured") - ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed") + ErrWayneRoleBindingNotConfigured = errors.New("wayne native api is not configured") + ErrWayneRoleBindingRequestFailed = errors.New("wayne native role binding request failed") ) +const wayneAdminTokenAccount = "admin" + type WayneRoleBindingRequest struct { Username string `json:"username,omitempty"` GroupIDs []uint64 `json:"groupIds,omitempty"` @@ -70,14 +76,18 @@ func (e *WayneRoleBindingHTTPError) Error() string { } type WayneRoleBindingService struct { - cfg config.Config - client *http.Client - now func() time.Time + cfg config.Config + db *gorm.DB + client *http.Client + now func() time.Time + cachedToken string + cachedExp time.Time } -func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService { +func NewWayneRoleBindingService(cfg config.Config, db *gorm.DB) *WayneRoleBindingService { return &WayneRoleBindingService{ cfg: cfg, + db: db, client: &http.Client{ Timeout: 10 * time.Second, }, @@ -86,33 +96,33 @@ func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService { } 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) + return s.bindUserRoles(ctx, "namespace", namespaceID, username, 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) + return s.unbindUserRoles(ctx, "namespace", namespaceID, username, 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) + return s.bindUserRoles(ctx, "app", appID, username, 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) + return s.unbindUserRoles(ctx, "app", appID, username, req) } func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) { - return s.callRaw(ctx, http.MethodGet, "/api/v1/internal/namespaces", nil) + return s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces", nil) } func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) { - internalPath := "/api/v1/internal/groups" + path := "/api/v1/groups" if groupType != nil { values := url.Values{} values.Set("type", strconv.Itoa(*groupType)) - internalPath += "?" + values.Encode() + path += "?" + values.Encode() } - return s.callRaw(ctx, http.MethodGet, internalPath, nil) + return s.callRaw(ctx, http.MethodGet, path, nil) } func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) { @@ -146,11 +156,44 @@ func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username str if username == "" { return nil, ErrWayenEmailMissing } - return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil) + user, err := s.findUser(ctx, username) + if err != nil { + return nil, err + } + + namespaces, err := s.listResourceIDs(ctx, "/api/v1/namespaces") + if err != nil { + return nil, err + } + results := make([]json.RawMessage, 0, len(namespaces)) + values := url.Values{} + values.Set("userId", strconv.FormatUint(user.ID, 10)) + for _, namespaceID := range namespaces { + result, err := s.callRaw(ctx, http.MethodGet, roleBindingBasePath("namespace", namespaceID)+"?"+values.Encode(), nil) + if err != nil { + return result, err + } + results = append(results, rawWayneData(result.Body)) + } + body, err := json.Marshal(map[string]json.RawMessage{ + "namespaceRoles": mustMarshalRaw(results), + }) + if err != nil { + return nil, err + } + return &WayneRoleBindingResult{StatusCode: http.StatusOK, ContentType: "application/json", Body: body}, nil +} + +func (s *WayneRoleBindingService) listResourceIDs(ctx context.Context, path string) ([]uint64, error) { + result, err := s.callRaw(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + return parseWayneResourceIDs(result.Body) } 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) + return s.permissions(ctx, "namespace", namespaceID) } func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneOperatorPermissions, error) { @@ -162,59 +205,122 @@ func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context } 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) + return s.permissions(ctx, "app", appID) } -func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { - if err := s.validateConfig(); err != nil { - return nil, err - } - operatorEmail = strings.TrimSpace(operatorEmail) - if operatorEmail == "" { +func (s *WayneRoleBindingService) bindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { + if strings.TrimSpace(username) == "" { return nil, ErrWayenEmailMissing } - - req.OperatorUserID = nil - req.OperatorName = operatorEmail - req.Username = "" - - body, err := json.Marshal(req) + user, err := s.findUser(ctx, username) if err != nil { return nil, err } - return s.callRaw(ctx, method, internalPath, body) -} - -func (s *WayneRoleBindingService) operatorPermissions(ctx context.Context, internalPath, operatorEmail string) (*WayneRoleBindingResult, error) { - operatorEmail = strings.TrimSpace(operatorEmail) - if operatorEmail == "" { - return nil, ErrWayenEmailMissing - } - values := url.Values{} - values.Set("operatorName", operatorEmail) - return s.callRaw(ctx, http.MethodGet, internalPath+"?"+values.Encode(), nil) -} - -func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalPath string, body []byte) (*WayneRoleBindingResult, error) { - if err := s.validateConfig(); err != nil { + bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID) + if err != nil { return nil, err } + + body := nativeRoleBindingBody(scope, resourceID, user.ID, req.GroupIDs) + if len(bindings) == 0 { + return s.callRaw(ctx, http.MethodPost, roleBindingBasePath(scope, resourceID), body) + } + bodyWithID, err := nativeRoleBindingBodyWithID(body, bindings[0].ID) + if err != nil { + return nil, err + } + return s.callRaw(ctx, http.MethodPut, fmt.Sprintf("%s/%d", roleBindingBasePath(scope, resourceID), bindings[0].ID), bodyWithID) +} + +func (s *WayneRoleBindingService) unbindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) { + if strings.TrimSpace(username) == "" { + return nil, ErrWayenEmailMissing + } + user, err := s.findUser(ctx, username) + if err != nil { + return nil, err + } + bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID) + if err != nil { + return nil, err + } + if len(bindings) == 0 { + return &WayneRoleBindingResult{StatusCode: http.StatusOK, ContentType: "application/json", Body: []byte(`{"data":null}`)}, nil + } + return s.callRaw(ctx, http.MethodDelete, fmt.Sprintf("%s/%d", roleBindingBasePath(scope, resourceID), bindings[0].ID), nil) +} + +func (s *WayneRoleBindingService) permissions(ctx context.Context, scope string, resourceID uint64) (*WayneRoleBindingResult, error) { + path := fmt.Sprintf("%s/permissions/%d", roleBindingBasePath(scope, resourceID), resourceID) + result, err := s.callRaw(ctx, http.MethodGet, path, nil) + if err != nil { + return result, err + } + permissions, err := parseNativePermissions(result.Body, scope) + if err != nil { + return nil, err + } + body, err := json.Marshal(map[string]WayneOperatorPermissions{"permissions": permissions}) + if err != nil { + return nil, err + } + return &WayneRoleBindingResult{StatusCode: result.StatusCode, ContentType: "application/json", Body: body}, nil +} + +func (s *WayneRoleBindingService) findUser(ctx context.Context, username string) (*wayneNativeUser, error) { + values := url.Values{} + values.Set("name", strings.TrimSpace(username)) + result, err := s.callRaw(ctx, http.MethodGet, "/api/v1/users?"+values.Encode(), nil) + if err != nil { + return nil, err + } + users, err := parseWayneUsers(result.Body) + if err != nil { + return nil, err + } + for _, user := range users { + if sameWayneUser(user, username) { + return &user, nil + } + } + return nil, fmt.Errorf("wayne user %q not found", username) +} + +func (s *WayneRoleBindingService) listUserBindings(ctx context.Context, scope string, resourceID uint64, userID uint64) ([]wayneRoleBinding, error) { + values := url.Values{} + values.Set("userId", strconv.FormatUint(userID, 10)) + result, err := s.callRaw(ctx, http.MethodGet, roleBindingBasePath(scope, resourceID)+"?"+values.Encode(), nil) + if err != nil { + return nil, err + } + return parseWayneRoleBindings(result.Body) +} + +func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, nativePath string, body []byte) (*WayneRoleBindingResult, error) { + token, err := s.adminToken(ctx, false) + if err != nil { + return nil, err + } + result, err := s.callRawWithToken(ctx, method, nativePath, body, token) + if result != nil && result.StatusCode == http.StatusUnauthorized { + token, refreshErr := s.adminToken(ctx, true) + if refreshErr != nil { + return result, refreshErr + } + return s.callRawWithToken(ctx, method, nativePath, body, token) + } + return result, err +} + +func (s *WayneRoleBindingService) callRawWithToken(ctx context.Context, method, nativePath string, body []byte, token string) (*WayneRoleBindingResult, error) { if body == nil { body = []byte{} } - target, signingURI, err := s.requestURL(internalPath) + target, err := s.requestURL(nativePath) if err != nil { return nil, err } - log.Printf( - "wayne role binding request: method=%s target=%s signing_uri=%s body_bytes=%d service_name=%s secret_configured=%t", - method, - target, - signingURI, - len(body), - s.cfg.WayneServiceName, - strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "", - ) + log.Printf("wayne native request: method=%s target=%s body_bytes=%d", method, target, len(body)) httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) if err != nil { return nil, err @@ -223,16 +329,11 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP httpReq.Header.Set("Content-Type", "application/json") } httpReq.Header.Set("Accept", "application/json") - - headers, err := wayne.BuildSignedHeaders(s.cfg.WayneServiceName, s.cfg.WayneServiceAPISecretKey, method, signingURI, body, s.now()) - if err != nil { - return nil, err - } - headers.Apply(httpReq) + httpReq.Header.Set("Authorization", "Bearer "+token) resp, err := s.client.Do(httpReq) if err != nil { - log.Printf("wayne role binding request failed: method=%s target=%s error=%v", method, target, err) + log.Printf("wayne native request failed: method=%s target=%s error=%v", method, target, err) return nil, err } defer resp.Body.Close() @@ -246,48 +347,154 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP ContentType: resp.Header.Get("Content-Type"), Body: respBody, } - log.Printf( - "wayne role binding response: method=%s target=%s status=%d content_type=%q body=%q", - method, - target, - resp.StatusCode, - result.ContentType, - truncateForDebugLog(string(respBody), 512), - ) + log.Printf("wayne native response: method=%s target=%s status=%d content_type=%q body=%q", method, target, resp.StatusCode, result.ContentType, truncateForDebugLog(string(respBody), 512)) if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody} } return result, nil } +func (s *WayneRoleBindingService) adminToken(ctx context.Context, forceRefresh bool) (string, error) { + if err := s.validateConfig(); err != nil { + return "", err + } + now := s.now() + if !forceRefresh && strings.TrimSpace(s.cachedToken) != "" && s.cachedExp.After(now.Add(time.Minute)) { + return s.cachedToken, nil + } + if !forceRefresh && s.db != nil { + var saved model.WayneToken + err := s.db.WithContext(ctx).Where("account = ?", wayneAdminTokenAccount).First(&saved).Error + if err == nil && strings.TrimSpace(saved.Token) != "" && saved.ExpiresAt.After(now.Add(time.Minute)) { + s.cachedToken = saved.Token + s.cachedExp = saved.ExpiresAt + return saved.Token, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + } + token, expiresAt, err := s.loginAdmin(ctx) + if err != nil { + return "", err + } + s.cachedToken = token + s.cachedExp = expiresAt + if s.db != nil { + err = s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "account"}}, + DoUpdates: clause.AssignmentColumns([]string{"token", "expires_at", "updated_at"}), + }).Create(&model.WayneToken{ + Account: wayneAdminTokenAccount, + Token: token, + ExpiresAt: expiresAt, + }).Error + } + return token, err +} + +func (s *WayneRoleBindingService) loginAdmin(ctx context.Context) (string, time.Time, error) { + body, err := json.Marshal(map[string]string{ + "username": s.cfg.WayneAdminUsername, + "password": s.cfg.WayneAdminPassword, + }) + if err != nil { + return "", time.Time{}, err + } + target, err := s.requestURL("/login/db") + if err != nil { + return "", time.Time{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body)) + if err != nil { + return "", time.Time{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return "", time.Time{}, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return "", time.Time{}, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { + return "", time.Time{}, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody} + } + token, err := parseWayneLoginToken(respBody) + if err != nil { + return "", time.Time{}, err + } + expiresAt := wayneTokenExpiresAt(token, s.now().Add(time.Duration(s.cfg.WayneTokenTTLMinutes)*time.Minute)) + return token, expiresAt, nil +} + func (s *WayneRoleBindingService) validateConfig() error { - baseConfigured := strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL) != "" - serviceConfigured := strings.TrimSpace(s.cfg.WayneServiceName) != "" - secretConfigured := strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "" - if !baseConfigured || !serviceConfigured || !secretConfigured { + baseConfigured := strings.TrimSpace(s.cfg.WayneAPIBaseURL) != "" + usernameConfigured := strings.TrimSpace(s.cfg.WayneAdminUsername) != "" + passwordConfigured := strings.TrimSpace(s.cfg.WayneAdminPassword) != "" + if !baseConfigured || !usernameConfigured || !passwordConfigured { log.Printf( - "wayne role binding config invalid: base_url_configured=%t service_name_configured=%t secret_configured=%t", + "wayne native config invalid: base_url_configured=%t admin_username_configured=%t admin_password_configured=%t", baseConfigured, - serviceConfigured, - secretConfigured, + usernameConfigured, + passwordConfigured, ) return ErrWayneRoleBindingNotConfigured } return nil } -func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) { - base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/")) +func (s *WayneRoleBindingService) requestURL(nativePath string) (string, error) { + base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneAPIBaseURL), "/")) if err != nil { - return "", "", err + return "", err } if base.Scheme == "" || base.Host == "" { - return "", "", fmt.Errorf("invalid wayne internal api base url: %s", s.cfg.WayneInternalAPIBaseURL) + return "", fmt.Errorf("invalid wayne api base url: %s", s.cfg.WayneAPIBaseURL) } - path, rawQuery, _ := strings.Cut(internalPath, "?") + path, rawQuery, _ := strings.Cut(nativePath, "?") base.Path = strings.TrimRight(base.Path, "/") + path base.RawQuery = rawQuery - return base.String(), base.RequestURI(), nil + return base.String(), nil +} + +func roleBindingBasePath(scope string, resourceID uint64) string { + switch scope { + case "app": + return fmt.Sprintf("/api/v1/apps/%d/users", resourceID) + default: + return fmt.Sprintf("/api/v1/namespaces/%d/users", resourceID) + } +} + +func nativeRoleBindingBody(scope string, resourceID, userID uint64, groupIDs []uint64) []byte { + groups := make([]map[string]uint64, 0, len(groupIDs)) + for _, groupID := range groupIDs { + groups = append(groups, map[string]uint64{"id": groupID}) + } + body := map[string]any{ + "user": map[string]uint64{"id": userID}, + "groups": groups, + } + if scope == "app" { + body["app"] = map[string]uint64{"id": resourceID} + } else { + body["namespace"] = map[string]uint64{"id": resourceID} + } + raw, _ := json.Marshal(body) + return raw +} + +func nativeRoleBindingBodyWithID(body []byte, id uint64) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + payload["id"] = id + return json.Marshal(payload) } func truncateForDebugLog(value string, limit int) string { @@ -298,42 +505,169 @@ func truncateForDebugLog(value string, limit int) string { return value[:limit] + "...(truncated)" } +func parseWayneLoginToken(body []byte) (string, error) { + var wrapped struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + Token string `json:"token"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return "", err + } + token := strings.TrimSpace(wrapped.Data.Token) + if token == "" { + token = strings.TrimSpace(wrapped.Token) + } + if token == "" { + return "", fmt.Errorf("wayne login response token is empty") + } + return token, nil +} + +func wayneTokenExpiresAt(token string, fallback time.Time) time.Time { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return fallback + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fallback + } + var claims struct { + ExpiresAt int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.ExpiresAt <= 0 { + return fallback + } + return time.Unix(claims.ExpiresAt, 0) +} + func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) { var wrapped struct { - Data []WayneRoleGroup `json:"data"` - Items []WayneRoleGroup `json:"items"` + Data struct { + List []WayneRoleGroup `json:"list"` + } `json:"data"` } - 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 { + if err := json.Unmarshal(body, &wrapped); err != nil { return nil, err } - return direct, nil + return wrapped.Data.List, 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 parseNativePermissions(body []byte, scope string) (WayneOperatorPermissions, error) { + var wrapped struct { + Data map[string]map[string]bool `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return WayneOperatorPermissions{}, err + } + key := "namespaceUser" + if scope == "app" { + key = "appUser" + } + return WayneOperatorPermissions{ + Create: wrapped.Data[key]["create"], + Update: wrapped.Data[key]["update"], + Delete: wrapped.Data[key]["delete"], + }, nil +} + +func rawWayneData(body []byte) json.RawMessage { + var wrapped struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err == nil && len(wrapped.Data) > 0 { + return wrapped.Data + } + return json.RawMessage(body) +} + +func mustMarshalRaw(value any) json.RawMessage { + raw, err := json.Marshal(value) + if err != nil { + return json.RawMessage(`null`) + } + return raw +} + +func parseWayneResourceIDs(body []byte) ([]uint64, error) { + var wrapped struct { + Data struct { + List []struct { + ID uint64 `json:"id"` + } `json:"list"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + return resourceIDsFromItems(wrapped.Data.List), nil +} + +func resourceIDsFromItems(items []struct { + ID uint64 `json:"id"` +}) []uint64 { + ids := make([]uint64, 0, len(items)) + for _, item := range items { + if item.ID != 0 { + ids = append(ids, item.ID) + } + } + return ids +} + +type wayneNativeUser struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Display string `json:"display"` +} + +func parseWayneUsers(body []byte) ([]wayneNativeUser, error) { + var wrapped struct { + Data struct { + List []wayneNativeUser `json:"list"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + return wrapped.Data.List, nil +} + +func sameWayneUser(user wayneNativeUser, username string) bool { + username = strings.TrimSpace(strings.ToLower(username)) + return strings.ToLower(strings.TrimSpace(user.Name)) == username || + strings.ToLower(strings.TrimSpace(user.Email)) == username +} + +type wayneRoleBinding struct { + ID uint64 `json:"id"` +} + +func parseWayneRoleBindings(body []byte) ([]wayneRoleBinding, error) { + var wrapped struct { + Data struct { + List []wayneRoleBinding `json:"list"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + return wrapped.Data.List, 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 65ec5a0..3c56bf9 100644 --- a/server/internal/service/wayne_role_binding_test.go +++ b/server/internal/service/wayne_role_binding_test.go @@ -2,235 +2,209 @@ package service import ( "context" + "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" "github.com/1024XEngineer/xinfra/server/internal/config" - "github.com/1024XEngineer/xinfra/server/internal/wayne" ) -func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testing.T) { - var requestPath string - var payload WayneRoleBindingRequest +func TestWayneRoleBindingServiceBindNamespaceUsesNativeAPI(t *testing.T) { + var got []string + var updateBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestPath = r.URL.RequestURI() - body := readTestBody(t, r) - if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) { - t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body)) + got = append(got, r.Method+" "+r.URL.RequestURI()) + switch r.URL.RequestURI() { + case "/login/db": + assertLoginBody(t, r) + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case "/api/v1/users?name=target%40example.com": + assertBearer(t, r) + _, _ = w.Write([]byte(`{"data":{"list":[{"id":7,"name":"target@example.com","email":"target@example.com"}]}}`)) + case "/api/v1/namespaces/1/users?userId=7": + assertBearer(t, r) + _, _ = w.Write([]byte(`{"data":{"list":[{"id":99}]}}`)) + case "/api/v1/namespaces/1/users/99": + assertBearer(t, r) + body := readTestBody(t, r) + if err := json.Unmarshal(body, &updateBody); err != nil { + t.Fatalf("invalid update body: %v", err) + } + _, _ = w.Write([]byte(`{"data":{"changed":true}}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) } - if err := json.Unmarshal(body, &payload); err != nil { - t.Fatalf("invalid request body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{"changed":true}}`)) })) defer server.Close() - svc := NewWayneRoleBindingService(config.Config{ - WayneInternalAPIBaseURL: server.URL, - WayneServiceName: "xinfra", - WayneServiceAPISecretKey: "service-secret", - }) - svc.now = func() time.Time { return time.Unix(1721000000, 0) } - - operatorUserID := uint64(123) - replace := false - 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", - Replace: &replace, - RequestID: "req-001", - Reason: "grant", - }) + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) + result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{10, 11}}) if err != nil { t.Fatalf("BindNamespace() error = %v", err) } if result.StatusCode != http.StatusOK { t.Fatalf("StatusCode = %d, want 200", result.StatusCode) } - if requestPath != "/api/v1/internal/namespaces/1/users/target@example.com/roles" { - t.Fatalf("requestPath = %q", requestPath) + want := []string{ + "POST /login/db", + "GET /api/v1/users?name=target%40example.com", + "GET /api/v1/namespaces/1/users?userId=7", + "PUT /api/v1/namespaces/1/users/99", } - if payload.OperatorName != "eastsales@qiniu.com" { - t.Fatalf("OperatorName = %q, want token email", payload.OperatorName) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("requests:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } - if payload.OperatorUserID != nil { - t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID) + if updateBody["id"].(float64) != 99 { + t.Fatalf("update id = %v, want 99", updateBody["id"]) } - if payload.Username != "" { - t.Fatalf("Username should be omitted from Wayne body, got %q", payload.Username) + if updateBody["namespace"].(map[string]any)["id"].(float64) != 1 { + t.Fatalf("namespace body = %#v", updateBody["namespace"]) } - if payload.Replace == nil || *payload.Replace { - t.Fatalf("Replace = %v, want false", payload.Replace) + if updateBody["user"].(map[string]any)["id"].(float64) != 7 { + t.Fatalf("user body = %#v", updateBody["user"]) } } -func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) { +func TestParseWayneUsersSupportsNativePageList(t *testing.T) { + users, err := parseWayneUsers([]byte(`{"data":{"pageNo":1,"pageSize":10,"totalCount":1,"list":[{"id":7,"name":"target@example.com","email":"target@example.com"}]}}`)) + if err != nil { + t.Fatalf("parseWayneUsers() error = %v", err) + } + if len(users) != 1 || users[0].ID != 7 || users[0].Name != "target@example.com" { + t.Fatalf("users = %#v", users) + } +} + +func TestWayneRoleBindingServiceCreateAndDeleteNativeBindings(t *testing.T) { tests := []struct { name string call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error) want string }{ { - name: "unbind namespace", + name: "create app binding", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.UnbindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}}) + return s.BindApp(context.Background(), 3, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) }, - want: "DELETE /api/v1/internal/namespaces/1/users/target@example.com/roles", + want: "POST /api/v1/apps/3/users", }, { - name: "bind app", + name: "delete namespace binding", call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + return s.UnbindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{}) }, - 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, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) - }, - want: "DELETE /api/v1/internal/apps/3/users/target@example.com/roles", - }, - { - name: "list namespaces", - call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.ListNamespaces(context.Background()) - }, - want: "GET /api/v1/internal/namespaces", - }, - { - name: "list namespace groups", - call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - groupType := 1 - return s.ListGroups(context.Background(), &groupType) - }, - want: "GET /api/v1/internal/groups?type=1", - }, - { - name: "list all groups", - call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.ListGroups(context.Background(), nil) - }, - want: "GET /api/v1/internal/groups", - }, - { - name: "get user roles", - call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.GetUserRoles(context.Background(), "target@example.com") - }, - want: "GET /api/v1/internal/users/target@example.com/roles", - }, - { - name: "namespace operator permissions", - call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com") - }, - want: "GET /api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com", - }, - { - name: "app operator permissions", - call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { - return s.AppOperatorPermissions(context.Background(), 3, "eastsales@qiniu.com") - }, - want: "GET /api/v1/internal/apps/3/operator-permissions?operatorName=eastsales%40qiniu.com", + want: "DELETE /api/v1/namespaces/1/users/44", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var got string + var finalRequest string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = r.Method + " " + r.URL.RequestURI() - _, _ = w.Write([]byte(`{"data":{"changed":true}}`)) + switch { + case r.URL.RequestURI() == "/login/db": + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/users?"): + _, _ = w.Write([]byte(`{"data":{"list":[{"id":7,"name":"target@example.com"}]}}`)) + case strings.Contains(r.URL.RequestURI(), "userId=7"): + if strings.Contains(tt.want, "POST ") { + _, _ = w.Write([]byte(`{"data":{"list":[]}}`)) + } else { + _, _ = w.Write([]byte(`{"data":{"list":[{"id":44}]}}`)) + } + default: + finalRequest = r.Method + " " + r.URL.RequestURI() + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + } })) defer server.Close() - svc := NewWayneRoleBindingService(config.Config{ - WayneInternalAPIBaseURL: server.URL, - WayneServiceName: "xinfra", - WayneServiceAPISecretKey: "service-secret", - }) + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) if _, err := tt.call(svc); err != nil { t.Fatalf("call error = %v", err) } - if got != tt.want { - t.Fatalf("got endpoint %q, want %q", got, tt.want) + if finalRequest != tt.want { + t.Fatalf("final request = %q, want %q", finalRequest, tt.want) } }) } } -func TestWayneRoleBindingServiceQuerySignsEmptyBodyAndQueryURI(t *testing.T) { +func TestWayneRoleBindingServiceRefreshesTokenAfterUnauthorized(t *testing.T) { + loginCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body := readTestBody(t, r) - if len(body) != 0 { - t.Fatalf("GET body length = %d, want 0", len(body)) + switch r.URL.RequestURI() { + case "/login/db": + loginCount++ + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case "/api/v1/groups?type=1": + if loginCount == 1 { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"msg":"expired"}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"list":[]}}`)) + default: + t.Fatalf("unexpected request %s", r.URL.RequestURI()) } - if r.URL.RequestURI() != "/api/v1/internal/groups?type=1" { - t.Fatalf("RequestURI = %q", r.URL.RequestURI()) - } - if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) { - t.Fatalf("invalid GET signature headers: %#v", r.Header) - } - _, _ = w.Write([]byte(`{"data":[]}`)) })) defer server.Close() - svc := NewWayneRoleBindingService(config.Config{ - WayneInternalAPIBaseURL: server.URL, - WayneServiceName: "xinfra", - WayneServiceAPISecretKey: "service-secret", - }) + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) groupType := 1 if _, err := svc.ListGroups(context.Background(), &groupType); err != nil { t.Fatalf("ListGroups() error = %v", err) } + if loginCount != 2 { + t.Fatalf("loginCount = %d, want 2", loginCount) + } } -func TestWayneRoleBindingServiceOperatorPermissionsSignsQueryURI(t *testing.T) { +func TestWayneRoleBindingServiceOperatorPermissionsFromNativePermissions(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body := readTestBody(t, r) - if r.URL.RequestURI() != "/api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com" { - t.Fatalf("RequestURI = %q", r.URL.RequestURI()) + switch r.URL.RequestURI() { + case "/login/db": + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case "/api/v1/namespaces/1/users/permissions/1": + _, _ = w.Write([]byte(`{"data":{"namespaceUser":{"create":true,"update":true,"delete":false}}}`)) + default: + t.Fatalf("unexpected request %s", r.URL.RequestURI()) } - if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) { - t.Fatalf("invalid operator permissions signature headers: %#v", r.Header) - } - _, _ = w.Write([]byte(`{"data":{"permissions":{"create":true,"update":true,"delete":false}}}`)) })) defer server.Close() - svc := NewWayneRoleBindingService(config.Config{ - WayneInternalAPIBaseURL: server.URL, - WayneServiceName: "xinfra", - WayneServiceAPISecretKey: "service-secret", - }) - if _, err := svc.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com"); err != nil { - t.Fatalf("NamespaceOperatorPermissions() error = %v", err) + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) + permissions, err := svc.NamespaceOperatorPermissionsParsed(context.Background(), 1, "operator@example.com") + if err != nil { + t.Fatalf("NamespaceOperatorPermissionsParsed() error = %v", err) + } + if !permissions.Create || !permissions.Update || permissions.Delete { + t.Fatalf("permissions = %#v", permissions) } } func TestWayneRoleBindingServiceHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`)) + switch r.URL.RequestURI() { + case "/login/db": + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case "/api/v1/groups": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`)) + default: + t.Fatalf("unexpected request %s", r.URL.RequestURI()) + } })) defer server.Close() - svc := NewWayneRoleBindingService(config.Config{ - WayneInternalAPIBaseURL: server.URL, - WayneServiceName: "xinfra", - WayneServiceAPISecretKey: "service-secret", - }) - result, err := svc.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}}) + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) + result, err := svc.ListGroups(context.Background(), nil) if err == nil { t.Fatal("expected error") } @@ -242,6 +216,39 @@ func TestWayneRoleBindingServiceHTTPError(t *testing.T) { } } +func testWayneConfig(baseURL string) config.Config { + return config.Config{ + WayneAPIBaseURL: baseURL, + WayneAdminUsername: "wayne-admin", + WayneAdminPassword: "wayne-password", + WayneTokenTTLMinutes: 60, + } +} + +func assertLoginBody(t *testing.T, r *http.Request) { + t.Helper() + var body map[string]string + if err := json.Unmarshal(readTestBody(t, r), &body); err != nil { + t.Fatalf("invalid login body: %v", err) + } + if body["username"] != "wayne-admin" || body["password"] != "wayne-password" { + t.Fatalf("login body = %#v", body) + } +} + +func assertBearer(t *testing.T, r *http.Request) { + t.Helper() + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Fatalf("missing bearer header: %#v", r.Header) + } +} + +func testJWT(exp time.Time) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"exp":` + strconv.FormatInt(exp.Unix(), 10) + `}`)) + return header + "." + payload + ".sig" +} + func readTestBody(t *testing.T, r *http.Request) []byte { t.Helper() body, err := io.ReadAll(r.Body)