feat(auth): use Wayne native role APIs

This commit is contained in:
mac
2026-07-21 17:05:48 +08:00
parent 981e9557fe
commit c324e2ec59
15 changed files with 685 additions and 315 deletions
-1
View File
@@ -18,7 +18,6 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+1 -2
View File
@@ -59,7 +59,6 @@ export const businessLineApi = {
business_line_id: number business_line_id: number
target_user_id: number target_user_id: number
target_business_line_id: number target_business_line_id: number
permission: 0 | 1
}): Promise<void> { }): Promise<void> {
await request('/auth/api/v1/business-lines/authorizations', { await request('/auth/api/v1/business-lines/authorizations', {
method: 'POST', method: 'POST',
@@ -69,7 +68,7 @@ export const businessLineApi = {
async listWayneNamespaces(): Promise<WayneNamespace[]> { async listWayneNamespaces(): Promise<WayneNamespace[]> {
const data = await request('/auth/api/v1/wayne/namespaces') 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) => ({ return items.map((item: any) => ({
id: Number(item.id), id: Number(item.id),
name: item.name || '', name: item.name || '',
+7 -2
View File
@@ -6,9 +6,14 @@ export interface UserOption {
} }
export const userApi = { export const userApi = {
async list(): Promise<UserOption[]> { async list(params: { businessLineId?: number } = {}): Promise<UserOption[]> {
const token = getToken() 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: { headers: {
Accept: 'application/json', Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -19,9 +19,6 @@
<el-option v-for="item in businessLineStore.items" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in businessLineStore.items" :key="item.id" :label="item.name" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="权限">
<el-segmented v-model="grantForm.permission" :options="permissionOptions" />
</el-form-item>
<el-button type="primary" :loading="granting" @click="grantPermission">保存分配</el-button> <el-button type="primary" :loading="granting" @click="grantPermission">保存分配</el-button>
</el-form> </el-form>
@@ -72,18 +69,11 @@ const savingWayneNamespaces = ref(false)
const grantForm = reactive<{ const grantForm = reactive<{
target_user_id: number | null target_user_id: number | null
target_business_line_id: number | null target_business_line_id: number | null
permission: 0 | 1
}>({ }>({
target_user_id: null, target_user_id: null,
target_business_line_id: null, target_business_line_id: null,
permission: 1,
}) })
const permissionOptions = [
{ label: '管理员', value: 0 },
{ label: '普通用户', value: 1 },
]
watch( watch(
() => businessLineStore.current?.id, () => businessLineStore.current?.id,
(id) => { (id) => {
@@ -139,7 +129,6 @@ async function grantPermission() {
business_line_id: businessLineID, business_line_id: businessLineID,
target_user_id: grantForm.target_user_id, target_user_id: grantForm.target_user_id,
target_business_line_id: grantForm.target_business_line_id, target_business_line_id: grantForm.target_business_line_id,
permission: grantForm.permission,
}) })
ElMessage.success('已保存业务线分配') ElMessage.success('已保存业务线分配')
} catch (error) { } catch (error) {
+14 -2
View File
@@ -239,10 +239,11 @@ const operatorStateDetail = computed(() => {
watch( watch(
() => businessLineStore.current?.id, () => businessLineStore.current?.id,
async () => { async () => {
selectedUserId.value = null
selectedNamespaceId.value = null selectedNamespaceId.value = null
selectedRoleIds.value = [] selectedRoleIds.value = []
userRoles.value = null userRoles.value = null
await loadBusinessLineData() await Promise.all([loadUsers(), loadBusinessLineData()])
}, },
) )
@@ -286,14 +287,25 @@ async function loadSystems() {
} }
async function loadUsers() { async function loadUsers() {
const businessLineId = currentBusinessLineId.value
if (!businessLineId) {
users.value = []
selectedUserId.value = null
return
}
loadingUsers.value = true loadingUsers.value = true
try { try {
users.value = await userApi.list() users.value = await userApi.list({ businessLineId })
if (!selectedUserId.value && users.value.length) { if (!selectedUserId.value && users.value.length) {
selectedUserId.value = users.value[0].uid selectedUserId.value = users.value[0].uid
await loadSelectedUserRoles() 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) { } catch (error) {
users.value = []
selectedUserId.value = null
ElMessage.error(error instanceof Error ? error.message : '查询用户失败') ElMessage.error(error instanceof Error ? error.message : '查询用户失败')
} finally { } finally {
loadingUsers.value = false loadingUsers.value = false
+4
View File
@@ -7,6 +7,10 @@ OIDC_TOKEN_ENDPOINT=http://localhost:8080/auth/oauth/token
OIDC_USERINFO_ENDPOINT=http://localhost:8080/auth/oauth/userinfo OIDC_USERINFO_ENDPOINT=http://localhost:8080/auth/oauth/userinfo
OIDC_JWKS_URI=http://localhost:8080/auth/oauth/jwks OIDC_JWKS_URI=http://localhost:8080/auth/oauth/jwks
CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm 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 MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local
AUTO_MIGRATE=true AUTO_MIGRATE=true
+21 -15
View File
@@ -285,7 +285,7 @@ Wayne 会把回调地址拼成:
## Wayne 授权代理接口 ## Wayne 授权代理接口
AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户名由请求体或路径参数提供。 AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端使用配置里的 Wayne 超级管理员账号登录 Wayne 原生 API,拿到 Wayne token 后存入数据库,后续代理请求都带 `Authorization: Bearer <wayne_token>`。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 ```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=<wayne_user_id>
``` ```
并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`username` 只用于 Wayne path,不会透传到 Wayne 请求体。 如果绑定记录存在,会调用原生更新接口;不存在则调用原生创建接口:
```text
POST /api/v1/namespaces/1/users
PUT /api/v1/namespaces/1/users/<namespace_user_id>
```
删除角色时会先查询绑定记录,再调用:
```text
DELETE /api/v1/namespaces/1/users/<namespace_user_id>
```
相关配置: 相关配置:
```env ```env
WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080 WAYNE_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
WAYNE_SERVICE_NAME=xinfra WAYNE_ADMIN_USERNAME=admin
WAYNE_SERVICE_API_SECRET_KEY=<wayne-service-secret> WAYNE_ADMIN_PASSWORD=<wayne-admin-password>
WAYNE_TOKEN_TTL_MINUTES=1440
``` ```
Wayne internal API 签名规则: `WAYNE_API_BASE_URL` 未配置时会兼容读取旧的 `WAYNE_INTERNAL_API_BASE_URL`。Wayne admin token 会写入 `wayne_tokens` 表,服务重启后优先复用未过期 token。
```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
```
## 子系统赋权接口 ## 子系统赋权接口
+8
View File
@@ -37,6 +37,10 @@ type Config struct {
WayenLoginValue string WayenLoginValue string
WayenOAuthRef string WayenOAuthRef string
WayenOAuthLoginURL string WayenOAuthLoginURL string
WayneAPIBaseURL string
WayneAdminUsername string
WayneAdminPassword string
WayneTokenTTLMinutes int
WayneInternalAPIBaseURL string WayneInternalAPIBaseURL string
WayneServiceName string WayneServiceName string
WayneServiceAPISecretKey string WayneServiceAPISecretKey string
@@ -88,6 +92,10 @@ func Load() Config {
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"), WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"), WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")), 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", "")), WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"), WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""), WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
+1
View File
@@ -19,6 +19,7 @@ func AutoMigrate(db *gorm.DB) error {
&model.BusinessLineUser{}, &model.BusinessLineUser{},
&model.BusinessLineWayneNamespace{}, &model.BusinessLineWayneNamespace{},
&model.AccessToken{}, &model.AccessToken{},
&model.WayneToken{},
&model.AuditLog{}, &model.AuditLog{},
) )
} }
+2 -31
View File
@@ -31,7 +31,6 @@ type GrantBusinessLinePermissionRequest struct {
BusinessLineID uint64 `json:"business_line_id" binding:"required"` BusinessLineID uint64 `json:"business_line_id" binding:"required"`
TargetUserID uint64 `json:"target_user_id" binding:"required"` TargetUserID uint64 `json:"target_user_id" binding:"required"`
TargetBusinessLineID uint64 `json:"target_business_line_id" binding:"required"` TargetBusinessLineID uint64 `json:"target_business_line_id" binding:"required"`
Permission int `json:"permission"`
} }
type BusinessLinePayload struct { type BusinessLinePayload struct {
@@ -223,10 +222,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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 { if !claims.IsAdmin {
var currentBinding model.BusinessLineUser var currentBinding model.BusinessLineUser
@@ -278,7 +273,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
binding = model.BusinessLineUser{ binding = model.BusinessLineUser{
BusinessLineID: req.TargetBusinessLineID, BusinessLineID: req.TargetBusinessLineID,
UserID: req.TargetUserID, UserID: req.TargetUserID,
Permission: req.Permission, Permission: 1,
} }
if err := h.db.Create(&binding).Error; err != nil { if err := h.db.Create(&binding).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -288,30 +283,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
} else if err != nil { } else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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{ c.JSON(http.StatusOK, gin.H{
@@ -321,7 +292,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
"permission": binding.Permission, "permission": binding.Permission,
"created_at": binding.CreatedAt.Format(time.RFC3339), "created_at": binding.CreatedAt.Format(time.RFC3339),
"updated_at": binding.UpdatedAt.Format(time.RFC3339), "updated_at": binding.UpdatedAt.Format(time.RFC3339),
"wayne_init": initializedWayne, "created": created,
}) })
} }
+27 -1
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"net/http" "net/http"
"strconv"
"github.com/1024XEngineer/xinfra/server/internal/model" "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) { 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 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
+9
View File
@@ -71,6 +71,15 @@ type AccessToken struct {
RevokedAt *time.Time `json:"revoked_at"` 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 { type AuditLog struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
RequestID string `gorm:"size:128;not null;default:''" json:"request_id"` RequestID string `gorm:"size:128;not null;default:''" json:"request_id"`
+1 -1
View File
@@ -69,7 +69,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
auditService := service.NewAuditService(deps.DB) auditService := service.NewAuditService(deps.DB)
authService := service.NewAuthService(deps.Config, deps.DB, auditService) authService := service.NewAuthService(deps.Config, deps.DB, auditService)
wayenService := service.NewWayenService(deps.Config, deps.DB) wayenService := service.NewWayenService(deps.Config, deps.DB)
wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config) wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config, deps.DB)
healthHandler := handler.NewHealthHandler(deps.DB) healthHandler := handler.NewHealthHandler(deps.DB)
authHandler := handler.NewAuthHandler(deps.Config, authService) authHandler := handler.NewAuthHandler(deps.Config, authService)
+436 -102
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -15,14 +16,19 @@ import (
"time" "time"
"github.com/1024XEngineer/xinfra/server/internal/config" "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 ( var (
ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured") ErrWayneRoleBindingNotConfigured = errors.New("wayne native api is not configured")
ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed") ErrWayneRoleBindingRequestFailed = errors.New("wayne native role binding request failed")
) )
const wayneAdminTokenAccount = "admin"
type WayneRoleBindingRequest struct { type WayneRoleBindingRequest struct {
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
GroupIDs []uint64 `json:"groupIds,omitempty"` GroupIDs []uint64 `json:"groupIds,omitempty"`
@@ -70,14 +76,18 @@ func (e *WayneRoleBindingHTTPError) Error() string {
} }
type WayneRoleBindingService struct { type WayneRoleBindingService struct {
cfg config.Config cfg config.Config
client *http.Client db *gorm.DB
now func() time.Time 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{ return &WayneRoleBindingService{
cfg: cfg, cfg: cfg,
db: db,
client: &http.Client{ client: &http.Client{
Timeout: 10 * time.Second, 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) { 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) { 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) { 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) { 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) { 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) { func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
internalPath := "/api/v1/internal/groups" path := "/api/v1/groups"
if groupType != nil { if groupType != nil {
values := url.Values{} values := url.Values{}
values.Set("type", strconv.Itoa(*groupType)) 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) { func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) {
@@ -146,11 +156,44 @@ func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username str
if username == "" { if username == "" {
return nil, ErrWayenEmailMissing 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) { 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) { 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) { 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) { func (s *WayneRoleBindingService) bindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil { if strings.TrimSpace(username) == "" {
return nil, err
}
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
return nil, ErrWayenEmailMissing return nil, ErrWayenEmailMissing
} }
user, err := s.findUser(ctx, username)
req.OperatorUserID = nil
req.OperatorName = operatorEmail
req.Username = ""
body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.callRaw(ctx, method, internalPath, body) bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID)
} if err != nil {
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 {
return nil, err 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 { if body == nil {
body = []byte{} body = []byte{}
} }
target, signingURI, err := s.requestURL(internalPath) target, err := s.requestURL(nativePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
log.Printf( log.Printf("wayne native request: method=%s target=%s body_bytes=%d", method, target, len(body))
"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) != "",
)
httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err 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("Content-Type", "application/json")
} }
httpReq.Header.Set("Accept", "application/json") httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
headers, err := wayne.BuildSignedHeaders(s.cfg.WayneServiceName, s.cfg.WayneServiceAPISecretKey, method, signingURI, body, s.now())
if err != nil {
return nil, err
}
headers.Apply(httpReq)
resp, err := s.client.Do(httpReq) resp, err := s.client.Do(httpReq)
if err != nil { 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 return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -246,48 +347,154 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP
ContentType: resp.Header.Get("Content-Type"), ContentType: resp.Header.Get("Content-Type"),
Body: respBody, Body: respBody,
} }
log.Printf( 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))
"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),
)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody} return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
} }
return result, nil 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 { func (s *WayneRoleBindingService) validateConfig() error {
baseConfigured := strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL) != "" baseConfigured := strings.TrimSpace(s.cfg.WayneAPIBaseURL) != ""
serviceConfigured := strings.TrimSpace(s.cfg.WayneServiceName) != "" usernameConfigured := strings.TrimSpace(s.cfg.WayneAdminUsername) != ""
secretConfigured := strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "" passwordConfigured := strings.TrimSpace(s.cfg.WayneAdminPassword) != ""
if !baseConfigured || !serviceConfigured || !secretConfigured { if !baseConfigured || !usernameConfigured || !passwordConfigured {
log.Printf( 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, baseConfigured,
serviceConfigured, usernameConfigured,
secretConfigured, passwordConfigured,
) )
return ErrWayneRoleBindingNotConfigured return ErrWayneRoleBindingNotConfigured
} }
return nil return nil
} }
func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) { func (s *WayneRoleBindingService) requestURL(nativePath string) (string, error) {
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/")) base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneAPIBaseURL), "/"))
if err != nil { if err != nil {
return "", "", err return "", err
} }
if base.Scheme == "" || base.Host == "" { 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.Path = strings.TrimRight(base.Path, "/") + path
base.RawQuery = rawQuery 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 { func truncateForDebugLog(value string, limit int) string {
@@ -298,42 +505,169 @@ func truncateForDebugLog(value string, limit int) string {
return value[:limit] + "...(truncated)" 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) { func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) {
var wrapped struct { var wrapped struct {
Data []WayneRoleGroup `json:"data"` Data struct {
Items []WayneRoleGroup `json:"items"` List []WayneRoleGroup `json:"list"`
} `json:"data"`
} }
if err := json.Unmarshal(body, &wrapped); err == nil { 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 nil, err
} }
return direct, nil return wrapped.Data.List, nil
} }
func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) { func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) {
var wrapped struct { var wrapped struct {
Data struct {
Permissions WayneOperatorPermissions `json:"permissions"`
} `json:"data"`
Permissions WayneOperatorPermissions `json:"permissions"` Permissions WayneOperatorPermissions `json:"permissions"`
} }
if err := json.Unmarshal(body, &wrapped); err != nil { if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, err return nil, err
} }
if wrapped.Data.Permissions != (WayneOperatorPermissions{}) {
return &wrapped.Data.Permissions, nil
}
return &wrapped.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 { func isWayneVisitorRoleName(name string) bool {
normalized := strings.ToLower(strings.TrimSpace(name)) normalized := strings.ToLower(strings.TrimSpace(name))
return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor") return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor")
+154 -147
View File
@@ -2,235 +2,209 @@ package service
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/1024XEngineer/xinfra/server/internal/config" "github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
) )
func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testing.T) { func TestWayneRoleBindingServiceBindNamespaceUsesNativeAPI(t *testing.T) {
var requestPath string var got []string
var payload WayneRoleBindingRequest var updateBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath = r.URL.RequestURI() got = append(got, r.Method+" "+r.URL.RequestURI())
body := readTestBody(t, r) switch 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) { case "/login/db":
t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body)) 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() defer server.Close()
svc := NewWayneRoleBindingService(config.Config{ svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
WayneInternalAPIBaseURL: server.URL, result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{10, 11}})
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",
})
if err != nil { if err != nil {
t.Fatalf("BindNamespace() error = %v", err) t.Fatalf("BindNamespace() error = %v", err)
} }
if result.StatusCode != http.StatusOK { if result.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200", result.StatusCode) t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
} }
if requestPath != "/api/v1/internal/namespaces/1/users/target@example.com/roles" { want := []string{
t.Fatalf("requestPath = %q", requestPath) "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" { if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("OperatorName = %q, want token email", payload.OperatorName) t.Fatalf("requests:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
} }
if payload.OperatorUserID != nil { if updateBody["id"].(float64) != 99 {
t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID) t.Fatalf("update id = %v, want 99", updateBody["id"])
} }
if payload.Username != "" { if updateBody["namespace"].(map[string]any)["id"].(float64) != 1 {
t.Fatalf("Username should be omitted from Wayne body, got %q", payload.Username) t.Fatalf("namespace body = %#v", updateBody["namespace"])
} }
if payload.Replace == nil || *payload.Replace { if updateBody["user"].(map[string]any)["id"].(float64) != 7 {
t.Fatalf("Replace = %v, want false", payload.Replace) 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 { tests := []struct {
name string name string
call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error) call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
want string want string
}{ }{
{ {
name: "unbind namespace", name: "create app binding",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) { 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) { 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", want: "DELETE /api/v1/namespaces/1/users/44",
},
{
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",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Method + " " + r.URL.RequestURI() switch {
_, _ = w.Write([]byte(`{"data":{"changed":true}}`)) 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() defer server.Close()
svc := NewWayneRoleBindingService(config.Config{ svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
if _, err := tt.call(svc); err != nil { if _, err := tt.call(svc); err != nil {
t.Fatalf("call error = %v", err) t.Fatalf("call error = %v", err)
} }
if got != tt.want { if finalRequest != tt.want {
t.Fatalf("got endpoint %q, want %q", got, 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r) switch r.URL.RequestURI() {
if len(body) != 0 { case "/login/db":
t.Fatalf("GET body length = %d, want 0", len(body)) 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() defer server.Close()
svc := NewWayneRoleBindingService(config.Config{ svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
groupType := 1 groupType := 1
if _, err := svc.ListGroups(context.Background(), &groupType); err != nil { if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
t.Fatalf("ListGroups() error = %v", err) 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r) switch r.URL.RequestURI() {
if r.URL.RequestURI() != "/api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com" { case "/login/db":
t.Fatalf("RequestURI = %q", r.URL.RequestURI()) _, _ = 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() defer server.Close()
svc := NewWayneRoleBindingService(config.Config{ svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
WayneInternalAPIBaseURL: server.URL, permissions, err := svc.NamespaceOperatorPermissionsParsed(context.Background(), 1, "operator@example.com")
WayneServiceName: "xinfra", if err != nil {
WayneServiceAPISecretKey: "service-secret", t.Fatalf("NamespaceOperatorPermissionsParsed() error = %v", err)
}) }
if _, err := svc.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com"); err != nil { if !permissions.Create || !permissions.Update || permissions.Delete {
t.Fatalf("NamespaceOperatorPermissions() error = %v", err) t.Fatalf("permissions = %#v", permissions)
} }
} }
func TestWayneRoleBindingServiceHTTPError(t *testing.T) { func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden) switch r.URL.RequestURI() {
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`)) 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() defer server.Close()
svc := NewWayneRoleBindingService(config.Config{ svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
WayneInternalAPIBaseURL: server.URL, result, err := svc.ListGroups(context.Background(), nil)
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
result, err := svc.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
if err == nil { if err == nil {
t.Fatal("expected error") 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 { func readTestBody(t *testing.T, r *http.Request) []byte {
t.Helper() t.Helper()
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)