Merge pull request #95 from ztkkOip/feat/base-service-delivery

Feat(base service delivery)基础服务部署在主系统的初始化
This commit is contained in:
ztkkOip
2026-07-22 14:55:44 +08:00
committed by GitHub
21 changed files with 2903 additions and 689 deletions
+1 -1
View File
@@ -17,8 +17,8 @@ declare module 'vue' {
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
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']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
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 || '',
+68
View File
@@ -0,0 +1,68 @@
import { getToken } from '@/utils/auth'
export interface DeploymentCreatePayload {
component: string
business_line_id: number
params: Record<string, unknown>
}
export interface DeploymentCreateResult {
deployment_id: string
status: string
}
export const deploymentApi = {
async create(payload: DeploymentCreatePayload): Promise<DeploymentCreateResult> {
const data = await authRequest('/auth/api/v1/deployments', {
method: 'POST',
body: JSON.stringify(payload),
})
return {
deployment_id: String(data.deployment_id || ''),
status: String(data.status || ''),
}
},
async cancel(deploymentId: string): Promise<void> {
await authRequest(`/auth/api/v1/deployments/${encodeURIComponent(deploymentId)}/cancel`, {
method: 'POST',
})
},
eventsURL(deploymentId: string): string {
const token = getToken()
const params = token ? `?access_token=${encodeURIComponent(token)}` : ''
return `/auth/api/v1/deployments/${encodeURIComponent(deploymentId)}/events${params}`
},
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
+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}` } : {}),
+6
View File
@@ -105,6 +105,12 @@ const router = createRouter({
component: () => import('@/views/service/Catalog.vue'), component: () => import('@/views/service/Catalog.vue'),
meta: { title: '基础服务' }, meta: { title: '基础服务' },
}, },
{
path: 'service/catalog/:component',
name: 'ServiceDelivery',
component: () => import('@/views/service/Catalog.vue'),
meta: { title: '基础服务交付' },
},
{ {
path: 'service/management', path: 'service/management',
name: 'ServiceManagement', name: 'ServiceManagement',
@@ -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) {
File diff suppressed because it is too large Load Diff
+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
+7
View File
@@ -7,6 +7,13 @@ 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
ANSIBLE_SERVICE_BASE_URL=http://ansible-runner:8084
ANSIBLE_INTERNAL_TOKEN=change-this-ansible-internal-token
DEPLOYMENT_CALLBACK_BASE_URL=http://authserver-backend:8080
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
```
## 子系统赋权接口 ## 子系统赋权接口
+14
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
@@ -53,6 +57,9 @@ type Config struct {
CloudDMClientSecret string CloudDMClientSecret string
CloudDMRedirectURI string CloudDMRedirectURI string
CloudDMTargetURL string CloudDMTargetURL string
AnsibleServiceBaseURL string
AnsibleInternalToken string
DeploymentCallbackBaseURL string
} }
func Load() Config { func Load() Config {
@@ -88,6 +95,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", ""),
@@ -104,6 +115,9 @@ func Load() Config {
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""), CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""), CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""), CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
AnsibleServiceBaseURL: trimURL(env("ANSIBLE_SERVICE_BASE_URL", "")),
AnsibleInternalToken: env("ANSIBLE_INTERNAL_TOKEN", ""),
DeploymentCallbackBaseURL: trimURL(env("DEPLOYMENT_CALLBACK_BASE_URL", publicBaseURL)),
} }
} }
+3
View File
@@ -19,6 +19,9 @@ func AutoMigrate(db *gorm.DB) error {
&model.BusinessLineUser{}, &model.BusinessLineUser{},
&model.BusinessLineWayneNamespace{}, &model.BusinessLineWayneNamespace{},
&model.AccessToken{}, &model.AccessToken{},
&model.WayneToken{},
&model.Deployment{},
&model.DeploymentEvent{},
&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,
}) })
} }
+8 -2
View File
@@ -15,11 +15,17 @@ const ClaimsKey = "claims"
func AuthMiddleware(cfg config.Config) gin.HandlerFunc { func AuthMiddleware(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
value := c.GetHeader("Authorization") value := c.GetHeader("Authorization")
if !strings.HasPrefix(value, "Bearer ") { tokenValue := ""
if strings.HasPrefix(value, "Bearer ") {
tokenValue = strings.TrimPrefix(value, "Bearer ")
} else {
tokenValue = strings.TrimSpace(c.Query("access_token"))
}
if tokenValue == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return return
} }
claims, err := auth.Parse(cfg.JWTSecret, strings.TrimPrefix(value, "Bearer ")) claims, err := auth.Parse(cfg.JWTSecret, tokenValue)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return return
+249
View File
@@ -0,0 +1,249 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/model"
"github.com/1024XEngineer/xinfra/server/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DeploymentHandler struct {
cfg config.Config
db *gorm.DB
deployments *service.DeploymentService
}
func NewDeploymentHandler(cfg config.Config, db *gorm.DB, deployments *service.DeploymentService) *DeploymentHandler {
return &DeploymentHandler{cfg: cfg, db: db, deployments: deployments}
}
func (h *DeploymentHandler) Create(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing auth claims"})
return
}
var req service.DeploymentCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.ensureBusinessLineMember(c, req.BusinessLineID, claims.UserID, claims.IsAdmin) {
return
}
deployment, err := h.deployments.Create(c.Request.Context(), req, claims.UserID, claims.Username)
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, service.ErrDeploymentNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{
"error": err.Error(),
"deployment_id": deployment.DeploymentID,
"status": deployment.Status,
})
return
}
c.JSON(http.StatusAccepted, gin.H{"deployment_id": deployment.DeploymentID, "status": deployment.Status})
}
func (h *DeploymentHandler) Get(c *gin.Context) {
deployment, ok := h.getAuthorizedDeployment(c)
if !ok {
return
}
events, err := h.deployments.Events(c.Request.Context(), deployment.DeploymentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deployment": deployment, "events": events})
}
func (h *DeploymentHandler) Events(c *gin.Context) {
deployment, ok := h.getAuthorizedDeployment(c)
if !ok {
return
}
events, err := h.deployments.Events(c.Request.Context(), deployment.DeploymentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
w := c.Writer
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
for _, event := range events {
if err := writeSSE(w, event.Type, service.DeploymentEventData(event)); err != nil {
return
}
}
if isTerminalDeploymentStatus(deployment.Status) {
_ = writeSSE(w, service.DeploymentEventDone, gin.H{"status": deployment.Status})
return
}
ch, unsubscribe := h.deployments.Subscribe(deployment.DeploymentID)
defer unsubscribe()
flusher, _ := w.(http.Flusher)
if flusher != nil {
flusher.Flush()
}
for {
select {
case <-c.Request.Context().Done():
return
case event := <-ch:
if err := writeSSE(w, event.Event.Type, event.Data); err != nil {
return
}
if event.Event.Type == service.DeploymentEventDone {
return
}
}
}
}
func (h *DeploymentHandler) Cancel(c *gin.Context) {
deployment, ok := h.getAuthorizedDeployment(c)
if !ok {
return
}
if err := h.deployments.Cancel(c.Request.Context(), deployment.DeploymentID); err != nil {
writeDeploymentError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"deployment_id": deployment.DeploymentID, "status": service.DeploymentStatusCanceling})
}
func (h *DeploymentHandler) InternalEvent(c *gin.Context) {
if !h.authorizeInternal(c) {
return
}
deploymentID := strings.TrimSpace(c.Param("id"))
var req service.DeploymentEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
event, err := h.deployments.AppendEvent(c.Request.Context(), deploymentID, req)
if err != nil {
writeDeploymentError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"event_id": event.ID, "seq": event.Seq})
}
func (h *DeploymentHandler) InternalFinish(c *gin.Context) {
if !h.authorizeInternal(c) {
return
}
deploymentID := strings.TrimSpace(c.Param("id"))
var req service.DeploymentFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
deployment, err := h.deployments.Finish(c.Request.Context(), deploymentID, req)
if err != nil {
writeDeploymentError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"deployment_id": deployment.DeploymentID, "status": deployment.Status})
}
func (h *DeploymentHandler) getAuthorizedDeployment(c *gin.Context) (model.Deployment, bool) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing auth claims"})
return model.Deployment{}, false
}
deploymentID := strings.TrimSpace(c.Param("id"))
deployment, err := h.deployments.Get(c.Request.Context(), deploymentID)
if err != nil {
writeDeploymentError(c, err)
return model.Deployment{}, false
}
if !h.ensureBusinessLineMember(c, deployment.BusinessLineID, claims.UserID, claims.IsAdmin) {
return model.Deployment{}, false
}
return deployment, true
}
func (h *DeploymentHandler) ensureBusinessLineMember(c *gin.Context, businessLineID uint64, userID uint64, isAdmin bool) bool {
if businessLineID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "business_line_id is required"})
return false
}
if isAdmin {
return true
}
var binding model.BusinessLineUser
err := h.db.WithContext(c.Request.Context()).Where("business_line_id = ? AND user_id = ?", businessLineID, userID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusForbidden, gin.H{"error": "current user is not assigned to this business line"})
return false
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return false
}
return true
}
func (h *DeploymentHandler) authorizeInternal(c *gin.Context) bool {
if h.cfg.AnsibleInternalToken == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ansible internal token is not configured"})
return false
}
value := c.GetHeader("Authorization")
if strings.TrimPrefix(value, "Bearer ") != h.cfg.AnsibleInternalToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid internal token"})
return false
}
return true
}
func writeSSE(w gin.ResponseWriter, event string, data any) error {
body, err := json.Marshal(data)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, body); err != nil {
return err
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
return nil
}
func writeDeploymentError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrDeploymentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrDeploymentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrDeploymentInvalidState):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrDeploymentNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
func isTerminalDeploymentStatus(status string) bool {
return status == service.DeploymentStatusSuccess || status == service.DeploymentStatusFailed || status == service.DeploymentStatusCanceled
}
+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
} }
+37
View File
@@ -71,6 +71,43 @@ 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 Deployment struct {
ID uint64 `gorm:"primaryKey" json:"id"`
DeploymentID string `gorm:"size:64;not null;uniqueIndex" json:"deployment_id"`
Component string `gorm:"size:64;not null;index" json:"component"`
BusinessLineID uint64 `gorm:"not null;index" json:"business_line_id"`
BusinessLine string `gorm:"size:128;not null;default:''" json:"business_line"`
Status string `gorm:"size:32;not null;index" json:"status"`
RequestPayload string `gorm:"type:longtext" json:"request_payload"`
ResultPayload string `gorm:"type:longtext" json:"result_payload"`
CreatedBy uint64 `gorm:"not null;index" json:"created_by"`
CreatedByName string `gorm:"size:128;not null;default:''" json:"created_by_name"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DeploymentEvent struct {
ID uint64 `gorm:"primaryKey" json:"id"`
DeploymentID string `gorm:"size:64;not null;index:idx_deployment_events_deployment_seq,priority:1" json:"deployment_id"`
Seq uint64 `gorm:"not null;index:idx_deployment_events_deployment_seq,priority:2" json:"seq"`
Type string `gorm:"size:32;not null;index" json:"type"`
Level string `gorm:"size:32;not null;default:''" json:"level"`
Message string `gorm:"type:longtext" json:"message"`
Payload string `gorm:"type:longtext" json:"payload"`
CreatedAt time.Time `gorm:"index" json:"created_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"`
+9 -1
View File
@@ -69,7 +69,8 @@ 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)
deploymentService := service.NewDeploymentService(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)
@@ -81,6 +82,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService) clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
samlHandler := handler.NewSAMLHandler(deps.Config, authService) samlHandler := handler.NewSAMLHandler(deps.Config, authService)
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService) oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
deploymentHandler := handler.NewDeploymentHandler(deps.Config, deps.DB, deploymentService)
r.GET("/healthz", healthHandler.Healthz) r.GET("/healthz", healthHandler.Healthz)
r.GET("/readyz", healthHandler.Readyz) r.GET("/readyz", healthHandler.Readyz)
@@ -89,6 +91,8 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
r.POST("/auth/oauth/token", oauthHandler.Token) r.POST("/auth/oauth/token", oauthHandler.Token)
r.GET("/auth/oauth/jwks", oauthHandler.JWKS) r.GET("/auth/oauth/jwks", oauthHandler.JWKS)
r.GET("/auth/oauth/userinfo", oauthHandler.UserInfo) r.GET("/auth/oauth/userinfo", oauthHandler.UserInfo)
r.POST("/auth/internal/deployments/:id/events", deploymentHandler.InternalEvent)
r.POST("/auth/internal/deployments/:id/finish", deploymentHandler.InternalFinish)
v1 := r.Group("/auth/api/v1") v1 := r.Group("/auth/api/v1")
{ {
@@ -130,6 +134,10 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles) protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles)
protected.DELETE("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.UnbindWayneNamespaceRoles) protected.DELETE("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.UnbindWayneNamespaceRoles)
protected.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser) protected.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser)
protected.POST("/deployments", deploymentHandler.Create)
protected.GET("/deployments/:id", deploymentHandler.Get)
protected.GET("/deployments/:id/events", deploymentHandler.Events)
protected.POST("/deployments/:id/cancel", deploymentHandler.Cancel)
protected.GET("/clouddm/login", clouddmHandler.Login) protected.GET("/clouddm/login", clouddmHandler.Login)
} }
} }
+499
View File
@@ -0,0 +1,499 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
DeploymentStatusPending = "pending"
DeploymentStatusRunning = "running"
DeploymentStatusSuccess = "success"
DeploymentStatusFailed = "failed"
DeploymentStatusCanceling = "canceling"
DeploymentStatusCanceled = "canceled"
DeploymentEventLog = "log"
DeploymentEventStatus = "status"
DeploymentEventResult = "result"
DeploymentEventDone = "done"
DeploymentEventError = "error"
)
var (
ErrDeploymentNotConfigured = errors.New("ansible deployment service is not configured")
ErrDeploymentNotFound = errors.New("deployment not found")
ErrDeploymentForbidden = errors.New("deployment permission denied")
ErrDeploymentInvalidState = errors.New("deployment state does not allow this operation")
)
type DeploymentCreateRequest struct {
Component string `json:"component"`
BusinessLineID uint64 `json:"business_line_id"`
Params map[string]any `json:"params"`
}
type DeploymentEventRequest struct {
Type string `json:"type"`
Level string `json:"level"`
Status string `json:"status"`
Seq uint64 `json:"seq"`
Message string `json:"message"`
Payload map[string]any `json:"payload"`
}
type DeploymentFinishRequest struct {
Status string `json:"status"`
ExitCode int `json:"exit_code"`
Error string `json:"error"`
Summary map[string]any `json:"summary"`
}
type DeploymentEventEnvelope struct {
Event model.DeploymentEvent
Data map[string]any
}
type DeploymentService struct {
cfg config.Config
db *gorm.DB
client *http.Client
mu sync.Mutex
subscribers map[string]map[chan DeploymentEventEnvelope]struct{}
}
func NewDeploymentService(cfg config.Config, db *gorm.DB) *DeploymentService {
return &DeploymentService{
cfg: cfg,
db: db,
client: &http.Client{Timeout: 8 * time.Second},
subscribers: make(map[string]map[chan DeploymentEventEnvelope]struct{}),
}
}
func (s *DeploymentService) Create(ctx context.Context, req DeploymentCreateRequest, actorID uint64, actorName string) (model.Deployment, error) {
component := strings.TrimSpace(strings.ToLower(req.Component))
if !isSupportedDeploymentComponent(component) {
return model.Deployment{}, fmt.Errorf("unsupported deployment component: %s", req.Component)
}
if req.BusinessLineID == 0 {
return model.Deployment{}, errors.New("business_line_id is required")
}
payload, err := json.Marshal(req)
if err != nil {
return model.Deployment{}, err
}
var businessLine model.BusinessLine
if err := s.db.WithContext(ctx).First(&businessLine, req.BusinessLineID).Error; err != nil {
return model.Deployment{}, err
}
deployment := model.Deployment{
DeploymentID: newDeploymentID(),
Component: component,
BusinessLineID: req.BusinessLineID,
BusinessLine: businessLine.Name,
Status: DeploymentStatusPending,
RequestPayload: string(payload),
CreatedBy: actorID,
CreatedByName: actorName,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&deployment).Error; err != nil {
return err
}
_, err := s.appendEventTx(ctx, tx, deployment.DeploymentID, DeploymentEventStatus, "info", "deployment created", map[string]any{"status": DeploymentStatusPending})
return err
}); err != nil {
return model.Deployment{}, err
}
if err := s.startPythonDeployment(ctx, deployment, req.Params); err != nil {
_ = s.FailStart(ctx, deployment.DeploymentID, err)
return deployment, err
}
return deployment, nil
}
func (s *DeploymentService) Get(ctx context.Context, deploymentID string) (model.Deployment, error) {
var deployment model.Deployment
if err := s.db.WithContext(ctx).Where("deployment_id = ?", deploymentID).First(&deployment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return deployment, ErrDeploymentNotFound
}
return deployment, err
}
return deployment, nil
}
func (s *DeploymentService) Events(ctx context.Context, deploymentID string) ([]model.DeploymentEvent, error) {
var events []model.DeploymentEvent
err := s.db.WithContext(ctx).Where("deployment_id = ?", deploymentID).Order("seq ASC").Find(&events).Error
return events, err
}
func (s *DeploymentService) AppendEvent(ctx context.Context, deploymentID string, req DeploymentEventRequest) (model.DeploymentEvent, error) {
eventType := normalizeDeploymentEventType(req.Type)
level := strings.TrimSpace(req.Level)
if level == "" {
level = "info"
}
payload := req.Payload
if payload == nil {
payload = map[string]any{}
}
if req.Status != "" {
payload["status"] = normalizeDeploymentStatus(req.Status)
}
event, err := s.appendEvent(ctx, deploymentID, eventType, level, req.Message, payload)
if err != nil {
return event, err
}
if status, _ := payload["status"].(string); status != "" {
_ = s.updateStatus(ctx, deploymentID, status, "")
}
return event, nil
}
func (s *DeploymentService) Finish(ctx context.Context, deploymentID string, req DeploymentFinishRequest) (model.Deployment, error) {
status := normalizeDeploymentStatus(req.Status)
if status == "" {
status = DeploymentStatusFailed
}
payload := map[string]any{
"status": status,
"exit_code": req.ExitCode,
"summary": req.Summary,
}
if req.Error != "" {
payload["error"] = req.Error
}
result, _ := json.Marshal(payload)
now := time.Now()
var deployment model.Deployment
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("deployment_id = ?", deploymentID).First(&deployment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrDeploymentNotFound
}
return err
}
updates := map[string]any{"status": status, "result_payload": string(result), "finished_at": &now}
if err := tx.Model(&model.Deployment{}).Where("deployment_id = ?", deploymentID).Updates(updates).Error; err != nil {
return err
}
eventType := DeploymentEventResult
if status == DeploymentStatusFailed || status == DeploymentStatusCanceled {
eventType = DeploymentEventError
}
if _, err := s.appendEventTx(ctx, tx, deploymentID, eventType, eventLevelForStatus(status), finishMessage(status, req.Error), payload); err != nil {
return err
}
_, err := s.appendEventTx(ctx, tx, deploymentID, DeploymentEventDone, eventLevelForStatus(status), status, map[string]any{"status": status})
return err
})
if err != nil {
return deployment, err
}
deployment.Status = status
deployment.ResultPayload = string(result)
deployment.FinishedAt = &now
return deployment, nil
}
func (s *DeploymentService) Cancel(ctx context.Context, deploymentID string) error {
deployment, err := s.Get(ctx, deploymentID)
if err != nil {
return err
}
if !deploymentCancelable(deployment.Status) {
return ErrDeploymentInvalidState
}
if err := s.updateStatus(ctx, deploymentID, DeploymentStatusCanceling, "cancel requested"); err != nil {
return err
}
if s.cfg.AnsibleServiceBaseURL == "" {
return ErrDeploymentNotConfigured
}
body, _ := json.Marshal(map[string]any{"deployment_id": deploymentID})
path := s.cfg.AnsibleServiceBaseURL + "/internal/ansible/deployments/" + deploymentID + "/cancel"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
if s.cfg.AnsibleInternalToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.AnsibleInternalToken)
}
resp, err := s.client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("ansible cancel failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return nil
}
func (s *DeploymentService) Subscribe(deploymentID string) (chan DeploymentEventEnvelope, func()) {
ch := make(chan DeploymentEventEnvelope, 64)
s.mu.Lock()
if s.subscribers[deploymentID] == nil {
s.subscribers[deploymentID] = make(map[chan DeploymentEventEnvelope]struct{})
}
s.subscribers[deploymentID][ch] = struct{}{}
s.mu.Unlock()
return ch, func() {
s.mu.Lock()
if subscribers := s.subscribers[deploymentID]; subscribers != nil {
delete(subscribers, ch)
if len(subscribers) == 0 {
delete(s.subscribers, deploymentID)
}
}
s.mu.Unlock()
close(ch)
}
}
func (s *DeploymentService) FailStart(ctx context.Context, deploymentID string, cause error) error {
_, err := s.Finish(ctx, deploymentID, DeploymentFinishRequest{
Status: DeploymentStatusFailed,
Error: cause.Error(),
})
return err
}
func (s *DeploymentService) startPythonDeployment(ctx context.Context, deployment model.Deployment, params map[string]any) error {
if s.cfg.AnsibleServiceBaseURL == "" {
return ErrDeploymentNotConfigured
}
callbackBaseURL := strings.TrimRight(s.cfg.DeploymentCallbackBaseURL, "/")
body, err := json.Marshal(map[string]any{
"deployment_id": deployment.DeploymentID,
"component": deployment.Component,
"callback_url": callbackBaseURL + "/auth/internal/deployments/" + deployment.DeploymentID + "/events",
"finish_url": callbackBaseURL + "/auth/internal/deployments/" + deployment.DeploymentID + "/finish",
"params": params,
})
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AnsibleServiceBaseURL+"/internal/ansible/deploy", bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
if s.cfg.AnsibleInternalToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.AnsibleInternalToken)
}
resp, err := s.client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("ansible deploy failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return nil
}
func (s *DeploymentService) appendEvent(ctx context.Context, deploymentID, eventType, level, message string, payload map[string]any) (model.DeploymentEvent, error) {
var event model.DeploymentEvent
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
event, err = s.appendEventTx(ctx, tx, deploymentID, eventType, level, message, payload)
return err
})
return event, err
}
func (s *DeploymentService) appendEventTx(ctx context.Context, tx *gorm.DB, deploymentID, eventType, level, message string, payload map[string]any) (model.DeploymentEvent, error) {
var deployment model.Deployment
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("deployment_id = ?", deploymentID).First(&deployment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.DeploymentEvent{}, ErrDeploymentNotFound
}
return model.DeploymentEvent{}, err
}
var maxSeq uint64
if err := tx.Model(&model.DeploymentEvent{}).Where("deployment_id = ?", deploymentID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil {
return model.DeploymentEvent{}, err
}
payloadBody, _ := json.Marshal(payload)
event := model.DeploymentEvent{
DeploymentID: deploymentID,
Seq: maxSeq + 1,
Type: eventType,
Level: level,
Message: message,
Payload: string(payloadBody),
}
if err := tx.Create(&event).Error; err != nil {
return model.DeploymentEvent{}, err
}
go s.broadcast(event, payload)
return event, nil
}
func (s *DeploymentService) updateStatus(ctx context.Context, deploymentID string, status string, message string) error {
status = normalizeDeploymentStatus(status)
if status == "" {
return nil
}
now := time.Now()
updates := map[string]any{"status": status}
if status == DeploymentStatusRunning {
updates["started_at"] = &now
}
if isDeploymentTerminal(status) {
updates["finished_at"] = &now
}
if err := s.db.WithContext(ctx).Model(&model.Deployment{}).Where("deployment_id = ?", deploymentID).Updates(updates).Error; err != nil {
return err
}
if message != "" {
_, err := s.appendEvent(ctx, deploymentID, DeploymentEventStatus, eventLevelForStatus(status), message, map[string]any{"status": status})
return err
}
return nil
}
func (s *DeploymentService) broadcast(event model.DeploymentEvent, data map[string]any) {
envelope := DeploymentEventEnvelope{Event: event, Data: eventData(event, data)}
s.mu.Lock()
subscribers := make([]chan DeploymentEventEnvelope, 0, len(s.subscribers[event.DeploymentID]))
for ch := range s.subscribers[event.DeploymentID] {
subscribers = append(subscribers, ch)
}
s.mu.Unlock()
for _, ch := range subscribers {
select {
case ch <- envelope:
default:
}
}
}
func eventData(event model.DeploymentEvent, data map[string]any) map[string]any {
out := map[string]any{
"deployment_id": event.DeploymentID,
"seq": event.Seq,
"type": event.Type,
"level": event.Level,
"message": event.Message,
"created_at": event.CreatedAt,
}
for key, value := range data {
out[key] = value
}
return out
}
func DeploymentEventData(event model.DeploymentEvent) map[string]any {
payload := map[string]any{}
if strings.TrimSpace(event.Payload) != "" {
_ = json.Unmarshal([]byte(event.Payload), &payload)
}
return eventData(event, payload)
}
func isSupportedDeploymentComponent(component string) bool {
switch component {
case "mysql", "openresty":
return true
default:
return false
}
}
func normalizeDeploymentEventType(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case DeploymentEventStatus:
return DeploymentEventStatus
case DeploymentEventResult:
return DeploymentEventResult
case DeploymentEventDone:
return DeploymentEventDone
case DeploymentEventError:
return DeploymentEventError
default:
return DeploymentEventLog
}
}
func normalizeDeploymentStatus(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case DeploymentStatusPending, DeploymentStatusRunning, DeploymentStatusSuccess, DeploymentStatusFailed, DeploymentStatusCanceling, DeploymentStatusCanceled:
return strings.TrimSpace(strings.ToLower(value))
default:
return ""
}
}
func isDeploymentTerminal(status string) bool {
return status == DeploymentStatusSuccess || status == DeploymentStatusFailed || status == DeploymentStatusCanceled
}
func deploymentCancelable(status string) bool {
return status == DeploymentStatusPending || status == DeploymentStatusRunning || status == DeploymentStatusCanceling
}
func eventLevelForStatus(status string) string {
if status == DeploymentStatusFailed || status == DeploymentStatusCanceled {
return "error"
}
return "info"
}
func finishMessage(status, fallback string) string {
if fallback != "" {
return fallback
}
switch status {
case DeploymentStatusSuccess:
return "deployment completed"
case DeploymentStatusCanceled:
return "deployment canceled"
default:
return "deployment failed"
}
}
func newDeploymentID() string {
now := time.Now()
buf := make([]byte, 3)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("CMP-%s-%d", now.Format("20060102"), now.UnixNano()%1000000)
}
return fmt.Sprintf("CMP-%s-%s", now.Format("20060102"), strings.ToUpper(hex.EncodeToString(buf)))
}
func SortedDeploymentEvents(events []model.DeploymentEvent) {
sort.Slice(events, func(i, j int) bool {
return events[i].Seq < events[j].Seq
})
}
+433 -99
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"`
@@ -71,13 +77,17 @@ func (e *WayneRoleBindingHTTPError) Error() string {
type WayneRoleBindingService struct { type WayneRoleBindingService struct {
cfg config.Config cfg config.Config
db *gorm.DB
client *http.Client client *http.Client
now func() time.Time 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")
+149 -142
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())
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) 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) { if err := json.Unmarshal(body, &updateBody); err != nil {
t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body)) t.Fatalf("invalid update body: %v", err)
} }
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}}`)) _, _ = w.Write([]byte(`{"data":{"changed":true}}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, 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.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
} }
if r.URL.RequestURI() != "/api/v1/internal/groups?type=1" { _, _ = w.Write([]byte(`{"data":{"list":[]}}`))
t.Fatalf("RequestURI = %q", r.URL.RequestURI()) 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 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) {
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.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`)) _, _ = 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)