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
+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_JWKS_URI=http://localhost:8080/auth/oauth/jwks
CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm
WAYNE_API_BASE_URL=http://wayne-backend:8080
WAYNE_ADMIN_USERNAME=admin
WAYNE_ADMIN_PASSWORD=change-this-wayne-admin-password
WAYNE_TOKEN_TTL_MINUTES=1440
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
AUTO_MIGRATE=true
+21 -15
View File
@@ -285,7 +285,7 @@ Wayne 会把回调地址拼成:
## Wayne 授权代理接口
AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户名由请求体或路径参数提供。
AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端使用配置里的 Wayne 超级管理员账号登录 Wayne 原生 API,拿到 Wayne token 后存入数据库,后续代理请求都带 `Authorization: Bearer <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
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
WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
WAYNE_SERVICE_NAME=xinfra
WAYNE_SERVICE_API_SECRET_KEY=<wayne-service-secret>
WAYNE_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
WAYNE_ADMIN_USERNAME=admin
WAYNE_ADMIN_PASSWORD=<wayne-admin-password>
WAYNE_TOKEN_TTL_MINUTES=1440
```
Wayne internal API 签名规则:
```text
bodyHash = SHA256_HEX(rawBody)
payload = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
signature = HMAC_SHA256_HEX(secret, payload)
X-Wayne-Signature = "sha256=" + signature
```
`WAYNE_API_BASE_URL` 未配置时会兼容读取旧的 `WAYNE_INTERNAL_API_BASE_URL`。Wayne admin token 会写入 `wayne_tokens` 表,服务重启后优先复用未过期 token。
## 子系统赋权接口
+92 -78
View File
@@ -14,45 +14,52 @@ type OAuthClient struct {
}
type Config struct {
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
SSOEnabled bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
SAMLLogoutURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
SSOEnabled bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
SAMLLogoutURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneAPIBaseURL string
WayneAdminUsername string
WayneAdminPassword string
WayneTokenTTLMinutes int
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
AnsibleServiceBaseURL string
AnsibleInternalToken string
DeploymentCallbackBaseURL string
}
func Load() Config {
@@ -65,45 +72,52 @@ func Load() Config {
oidcIssuer = strings.TrimRight(oidcIssuer, "/")
return Config{
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
SSOEnabled: envBool("SSO_ENABLED", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
SSOEnabled: envBool("SSO_ENABLED", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))),
WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""),
WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""),
WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
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.BusinessLineWayneNamespace{},
&model.AccessToken{},
&model.WayneToken{},
&model.Deployment{},
&model.DeploymentEvent{},
&model.AuditLog{},
)
}
+2 -31
View File
@@ -31,7 +31,6 @@ type GrantBusinessLinePermissionRequest struct {
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
TargetUserID uint64 `json:"target_user_id" binding:"required"`
TargetBusinessLineID uint64 `json:"target_business_line_id" binding:"required"`
Permission int `json:"permission"`
}
type BusinessLinePayload struct {
@@ -223,10 +222,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Permission != 0 && req.Permission != 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "permission must be 0 or 1"})
return
}
if !claims.IsAdmin {
var currentBinding model.BusinessLineUser
@@ -278,7 +273,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
binding = model.BusinessLineUser{
BusinessLineID: req.TargetBusinessLineID,
UserID: req.TargetUserID,
Permission: req.Permission,
Permission: 1,
}
if err := h.db.Create(&binding).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -288,30 +283,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} else if binding.Permission != req.Permission {
if err := h.db.Model(&binding).Update("permission", req.Permission).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
binding.Permission = req.Permission
}
var initializedWayne []gin.H
if created {
operatorEmail, ok := subsystemOperatorEmail(c, claims)
if !ok {
return
}
targetUsername := wayneUsernameForUser(targetUser)
if targetUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target user has no Wayne username"})
return
}
initialized, ok := h.initializeWayneVisitorForBusinessLine(c, req.TargetBusinessLineID, targetUsername, operatorEmail, claims.IsAdmin)
if !ok {
return
}
initializedWayne = initialized
}
c.JSON(http.StatusOK, gin.H{
@@ -321,7 +292,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
"permission": binding.Permission,
"created_at": binding.CreatedAt.Format(time.RFC3339),
"updated_at": binding.UpdatedAt.Format(time.RFC3339),
"wayne_init": initializedWayne,
"created": created,
})
}
+8 -2
View File
@@ -15,11 +15,17 @@ const ClaimsKey = "claims"
func AuthMiddleware(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
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"})
return
}
claims, err := auth.Parse(cfg.JWTSecret, strings.TrimPrefix(value, "Bearer "))
claims, err := auth.Parse(cfg.JWTSecret, tokenValue)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
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 (
"net/http"
"strconv"
"github.com/1024XEngineer/xinfra/server/internal/model"
@@ -32,8 +33,33 @@ func (h *UserHandler) Me(c *gin.Context) {
}
func (h *UserHandler) List(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
query := h.db.Model(&model.User{}).Where("users.deleted_at IS NULL")
if businessLineID := c.Query("business_line_id"); businessLineID != "" {
if !claims.IsAdmin {
id, err := strconv.ParseUint(businessLineID, 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid business line id"})
return
}
var binding model.BusinessLineUser
if err := h.db.Where("business_line_id = ? AND user_id = ?", id, claims.UserID).First(&binding).Error; err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "current user is not in business line"})
return
}
}
query = query.
Joins("JOIN business_line_users ON business_line_users.user_id = users.id").
Where("business_line_users.business_line_id = ?", businessLineID)
}
var users []model.User
if err := h.db.Where("deleted_at IS NULL").Order("id ASC").Find(&users).Error; err != nil {
if err := query.Order("users.id ASC").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+37
View File
@@ -71,6 +71,43 @@ type AccessToken struct {
RevokedAt *time.Time `json:"revoked_at"`
}
type WayneToken struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Account string `gorm:"size:128;not null;uniqueIndex" json:"account"`
Token string `gorm:"type:text;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type 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 {
ID uint64 `gorm:"primaryKey" json:"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)
authService := service.NewAuthService(deps.Config, deps.DB, auditService)
wayenService := service.NewWayenService(deps.Config, deps.DB)
wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config)
wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config, deps.DB)
deploymentService := service.NewDeploymentService(deps.Config, deps.DB)
healthHandler := handler.NewHealthHandler(deps.DB)
authHandler := handler.NewAuthHandler(deps.Config, authService)
@@ -81,6 +82,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
deploymentHandler := handler.NewDeploymentHandler(deps.Config, deps.DB, deploymentService)
r.GET("/healthz", healthHandler.Healthz)
r.GET("/readyz", healthHandler.Readyz)
@@ -89,6 +91,8 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
r.POST("/auth/oauth/token", oauthHandler.Token)
r.GET("/auth/oauth/jwks", oauthHandler.JWKS)
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")
{
@@ -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.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("/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)
}
}
+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
})
}
+436 -102
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -15,14 +16,19 @@ import (
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
"github.com/1024XEngineer/xinfra/server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured")
ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed")
ErrWayneRoleBindingNotConfigured = errors.New("wayne native api is not configured")
ErrWayneRoleBindingRequestFailed = errors.New("wayne native role binding request failed")
)
const wayneAdminTokenAccount = "admin"
type WayneRoleBindingRequest struct {
Username string `json:"username,omitempty"`
GroupIDs []uint64 `json:"groupIds,omitempty"`
@@ -70,14 +76,18 @@ func (e *WayneRoleBindingHTTPError) Error() string {
}
type WayneRoleBindingService struct {
cfg config.Config
client *http.Client
now func() time.Time
cfg config.Config
db *gorm.DB
client *http.Client
now func() time.Time
cachedToken string
cachedExp time.Time
}
func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
func NewWayneRoleBindingService(cfg config.Config, db *gorm.DB) *WayneRoleBindingService {
return &WayneRoleBindingService{
cfg: cfg,
db: db,
client: &http.Client{
Timeout: 10 * time.Second,
},
@@ -86,33 +96,33 @@ func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
}
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req)
return s.bindUserRoles(ctx, "namespace", namespaceID, username, req)
}
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req)
return s.unbindUserRoles(ctx, "namespace", namespaceID, username, req)
}
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req)
return s.bindUserRoles(ctx, "app", appID, username, req)
}
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req)
return s.unbindUserRoles(ctx, "app", appID, username, req)
}
func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
return s.callRaw(ctx, http.MethodGet, "/api/v1/internal/namespaces", nil)
return s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces", nil)
}
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
internalPath := "/api/v1/internal/groups"
path := "/api/v1/groups"
if groupType != nil {
values := url.Values{}
values.Set("type", strconv.Itoa(*groupType))
internalPath += "?" + values.Encode()
path += "?" + values.Encode()
}
return s.callRaw(ctx, http.MethodGet, internalPath, nil)
return s.callRaw(ctx, http.MethodGet, path, nil)
}
func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) {
@@ -146,11 +156,44 @@ func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username str
if username == "" {
return nil, ErrWayenEmailMissing
}
return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil)
user, err := s.findUser(ctx, username)
if err != nil {
return nil, err
}
namespaces, err := s.listResourceIDs(ctx, "/api/v1/namespaces")
if err != nil {
return nil, err
}
results := make([]json.RawMessage, 0, len(namespaces))
values := url.Values{}
values.Set("userId", strconv.FormatUint(user.ID, 10))
for _, namespaceID := range namespaces {
result, err := s.callRaw(ctx, http.MethodGet, roleBindingBasePath("namespace", namespaceID)+"?"+values.Encode(), nil)
if err != nil {
return result, err
}
results = append(results, rawWayneData(result.Body))
}
body, err := json.Marshal(map[string]json.RawMessage{
"namespaceRoles": mustMarshalRaw(results),
})
if err != nil {
return nil, err
}
return &WayneRoleBindingResult{StatusCode: http.StatusOK, ContentType: "application/json", Body: body}, nil
}
func (s *WayneRoleBindingService) listResourceIDs(ctx context.Context, path string) ([]uint64, error) {
result, err := s.callRaw(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
return parseWayneResourceIDs(result.Body)
}
func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail)
return s.permissions(ctx, "namespace", namespaceID)
}
func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneOperatorPermissions, error) {
@@ -162,59 +205,122 @@ func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context
}
func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/apps/%d/operator-permissions", appID), operatorEmail)
return s.permissions(ctx, "app", appID)
}
func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil {
return nil, err
}
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
func (s *WayneRoleBindingService) bindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
if strings.TrimSpace(username) == "" {
return nil, ErrWayenEmailMissing
}
req.OperatorUserID = nil
req.OperatorName = operatorEmail
req.Username = ""
body, err := json.Marshal(req)
user, err := s.findUser(ctx, username)
if err != nil {
return nil, err
}
return s.callRaw(ctx, method, internalPath, body)
}
func (s *WayneRoleBindingService) operatorPermissions(ctx context.Context, internalPath, operatorEmail string) (*WayneRoleBindingResult, error) {
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
return nil, ErrWayenEmailMissing
}
values := url.Values{}
values.Set("operatorName", operatorEmail)
return s.callRaw(ctx, http.MethodGet, internalPath+"?"+values.Encode(), nil)
}
func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalPath string, body []byte) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil {
bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID)
if err != nil {
return nil, err
}
body := nativeRoleBindingBody(scope, resourceID, user.ID, req.GroupIDs)
if len(bindings) == 0 {
return s.callRaw(ctx, http.MethodPost, roleBindingBasePath(scope, resourceID), body)
}
bodyWithID, err := nativeRoleBindingBodyWithID(body, bindings[0].ID)
if err != nil {
return nil, err
}
return s.callRaw(ctx, http.MethodPut, fmt.Sprintf("%s/%d", roleBindingBasePath(scope, resourceID), bindings[0].ID), bodyWithID)
}
func (s *WayneRoleBindingService) unbindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
if strings.TrimSpace(username) == "" {
return nil, ErrWayenEmailMissing
}
user, err := s.findUser(ctx, username)
if err != nil {
return nil, err
}
bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID)
if err != nil {
return nil, err
}
if len(bindings) == 0 {
return &WayneRoleBindingResult{StatusCode: http.StatusOK, ContentType: "application/json", Body: []byte(`{"data":null}`)}, nil
}
return s.callRaw(ctx, http.MethodDelete, fmt.Sprintf("%s/%d", roleBindingBasePath(scope, resourceID), bindings[0].ID), nil)
}
func (s *WayneRoleBindingService) permissions(ctx context.Context, scope string, resourceID uint64) (*WayneRoleBindingResult, error) {
path := fmt.Sprintf("%s/permissions/%d", roleBindingBasePath(scope, resourceID), resourceID)
result, err := s.callRaw(ctx, http.MethodGet, path, nil)
if err != nil {
return result, err
}
permissions, err := parseNativePermissions(result.Body, scope)
if err != nil {
return nil, err
}
body, err := json.Marshal(map[string]WayneOperatorPermissions{"permissions": permissions})
if err != nil {
return nil, err
}
return &WayneRoleBindingResult{StatusCode: result.StatusCode, ContentType: "application/json", Body: body}, nil
}
func (s *WayneRoleBindingService) findUser(ctx context.Context, username string) (*wayneNativeUser, error) {
values := url.Values{}
values.Set("name", strings.TrimSpace(username))
result, err := s.callRaw(ctx, http.MethodGet, "/api/v1/users?"+values.Encode(), nil)
if err != nil {
return nil, err
}
users, err := parseWayneUsers(result.Body)
if err != nil {
return nil, err
}
for _, user := range users {
if sameWayneUser(user, username) {
return &user, nil
}
}
return nil, fmt.Errorf("wayne user %q not found", username)
}
func (s *WayneRoleBindingService) listUserBindings(ctx context.Context, scope string, resourceID uint64, userID uint64) ([]wayneRoleBinding, error) {
values := url.Values{}
values.Set("userId", strconv.FormatUint(userID, 10))
result, err := s.callRaw(ctx, http.MethodGet, roleBindingBasePath(scope, resourceID)+"?"+values.Encode(), nil)
if err != nil {
return nil, err
}
return parseWayneRoleBindings(result.Body)
}
func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, nativePath string, body []byte) (*WayneRoleBindingResult, error) {
token, err := s.adminToken(ctx, false)
if err != nil {
return nil, err
}
result, err := s.callRawWithToken(ctx, method, nativePath, body, token)
if result != nil && result.StatusCode == http.StatusUnauthorized {
token, refreshErr := s.adminToken(ctx, true)
if refreshErr != nil {
return result, refreshErr
}
return s.callRawWithToken(ctx, method, nativePath, body, token)
}
return result, err
}
func (s *WayneRoleBindingService) callRawWithToken(ctx context.Context, method, nativePath string, body []byte, token string) (*WayneRoleBindingResult, error) {
if body == nil {
body = []byte{}
}
target, signingURI, err := s.requestURL(internalPath)
target, err := s.requestURL(nativePath)
if err != nil {
return nil, err
}
log.Printf(
"wayne role binding request: method=%s target=%s signing_uri=%s body_bytes=%d service_name=%s secret_configured=%t",
method,
target,
signingURI,
len(body),
s.cfg.WayneServiceName,
strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "",
)
log.Printf("wayne native request: method=%s target=%s body_bytes=%d", method, target, len(body))
httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
@@ -223,16 +329,11 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("Accept", "application/json")
headers, err := wayne.BuildSignedHeaders(s.cfg.WayneServiceName, s.cfg.WayneServiceAPISecretKey, method, signingURI, body, s.now())
if err != nil {
return nil, err
}
headers.Apply(httpReq)
httpReq.Header.Set("Authorization", "Bearer "+token)
resp, err := s.client.Do(httpReq)
if err != nil {
log.Printf("wayne role binding request failed: method=%s target=%s error=%v", method, target, err)
log.Printf("wayne native request failed: method=%s target=%s error=%v", method, target, err)
return nil, err
}
defer resp.Body.Close()
@@ -246,48 +347,154 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP
ContentType: resp.Header.Get("Content-Type"),
Body: respBody,
}
log.Printf(
"wayne role binding response: method=%s target=%s status=%d content_type=%q body=%q",
method,
target,
resp.StatusCode,
result.ContentType,
truncateForDebugLog(string(respBody), 512),
)
log.Printf("wayne native response: method=%s target=%s status=%d content_type=%q body=%q", method, target, resp.StatusCode, result.ContentType, truncateForDebugLog(string(respBody), 512))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
}
return result, nil
}
func (s *WayneRoleBindingService) adminToken(ctx context.Context, forceRefresh bool) (string, error) {
if err := s.validateConfig(); err != nil {
return "", err
}
now := s.now()
if !forceRefresh && strings.TrimSpace(s.cachedToken) != "" && s.cachedExp.After(now.Add(time.Minute)) {
return s.cachedToken, nil
}
if !forceRefresh && s.db != nil {
var saved model.WayneToken
err := s.db.WithContext(ctx).Where("account = ?", wayneAdminTokenAccount).First(&saved).Error
if err == nil && strings.TrimSpace(saved.Token) != "" && saved.ExpiresAt.After(now.Add(time.Minute)) {
s.cachedToken = saved.Token
s.cachedExp = saved.ExpiresAt
return saved.Token, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
}
token, expiresAt, err := s.loginAdmin(ctx)
if err != nil {
return "", err
}
s.cachedToken = token
s.cachedExp = expiresAt
if s.db != nil {
err = s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "account"}},
DoUpdates: clause.AssignmentColumns([]string{"token", "expires_at", "updated_at"}),
}).Create(&model.WayneToken{
Account: wayneAdminTokenAccount,
Token: token,
ExpiresAt: expiresAt,
}).Error
}
return token, err
}
func (s *WayneRoleBindingService) loginAdmin(ctx context.Context) (string, time.Time, error) {
body, err := json.Marshal(map[string]string{
"username": s.cfg.WayneAdminUsername,
"password": s.cfg.WayneAdminPassword,
})
if err != nil {
return "", time.Time{}, err
}
target, err := s.requestURL("/login/db")
if err != nil {
return "", time.Time{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", time.Time{}, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return "", time.Time{}, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
}
token, err := parseWayneLoginToken(respBody)
if err != nil {
return "", time.Time{}, err
}
expiresAt := wayneTokenExpiresAt(token, s.now().Add(time.Duration(s.cfg.WayneTokenTTLMinutes)*time.Minute))
return token, expiresAt, nil
}
func (s *WayneRoleBindingService) validateConfig() error {
baseConfigured := strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL) != ""
serviceConfigured := strings.TrimSpace(s.cfg.WayneServiceName) != ""
secretConfigured := strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != ""
if !baseConfigured || !serviceConfigured || !secretConfigured {
baseConfigured := strings.TrimSpace(s.cfg.WayneAPIBaseURL) != ""
usernameConfigured := strings.TrimSpace(s.cfg.WayneAdminUsername) != ""
passwordConfigured := strings.TrimSpace(s.cfg.WayneAdminPassword) != ""
if !baseConfigured || !usernameConfigured || !passwordConfigured {
log.Printf(
"wayne role binding config invalid: base_url_configured=%t service_name_configured=%t secret_configured=%t",
"wayne native config invalid: base_url_configured=%t admin_username_configured=%t admin_password_configured=%t",
baseConfigured,
serviceConfigured,
secretConfigured,
usernameConfigured,
passwordConfigured,
)
return ErrWayneRoleBindingNotConfigured
}
return nil
}
func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) {
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/"))
func (s *WayneRoleBindingService) requestURL(nativePath string) (string, error) {
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneAPIBaseURL), "/"))
if err != nil {
return "", "", err
return "", err
}
if base.Scheme == "" || base.Host == "" {
return "", "", fmt.Errorf("invalid wayne internal api base url: %s", s.cfg.WayneInternalAPIBaseURL)
return "", fmt.Errorf("invalid wayne api base url: %s", s.cfg.WayneAPIBaseURL)
}
path, rawQuery, _ := strings.Cut(internalPath, "?")
path, rawQuery, _ := strings.Cut(nativePath, "?")
base.Path = strings.TrimRight(base.Path, "/") + path
base.RawQuery = rawQuery
return base.String(), base.RequestURI(), nil
return base.String(), nil
}
func roleBindingBasePath(scope string, resourceID uint64) string {
switch scope {
case "app":
return fmt.Sprintf("/api/v1/apps/%d/users", resourceID)
default:
return fmt.Sprintf("/api/v1/namespaces/%d/users", resourceID)
}
}
func nativeRoleBindingBody(scope string, resourceID, userID uint64, groupIDs []uint64) []byte {
groups := make([]map[string]uint64, 0, len(groupIDs))
for _, groupID := range groupIDs {
groups = append(groups, map[string]uint64{"id": groupID})
}
body := map[string]any{
"user": map[string]uint64{"id": userID},
"groups": groups,
}
if scope == "app" {
body["app"] = map[string]uint64{"id": resourceID}
} else {
body["namespace"] = map[string]uint64{"id": resourceID}
}
raw, _ := json.Marshal(body)
return raw
}
func nativeRoleBindingBodyWithID(body []byte, id uint64) ([]byte, error) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
payload["id"] = id
return json.Marshal(payload)
}
func truncateForDebugLog(value string, limit int) string {
@@ -298,42 +505,169 @@ func truncateForDebugLog(value string, limit int) string {
return value[:limit] + "...(truncated)"
}
func parseWayneLoginToken(body []byte) (string, error) {
var wrapped struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
Token string `json:"token"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return "", err
}
token := strings.TrimSpace(wrapped.Data.Token)
if token == "" {
token = strings.TrimSpace(wrapped.Token)
}
if token == "" {
return "", fmt.Errorf("wayne login response token is empty")
}
return token, nil
}
func wayneTokenExpiresAt(token string, fallback time.Time) time.Time {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return fallback
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return fallback
}
var claims struct {
ExpiresAt int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil || claims.ExpiresAt <= 0 {
return fallback
}
return time.Unix(claims.ExpiresAt, 0)
}
func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) {
var wrapped struct {
Data []WayneRoleGroup `json:"data"`
Items []WayneRoleGroup `json:"items"`
Data struct {
List []WayneRoleGroup `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err == nil {
if wrapped.Data != nil {
return wrapped.Data, nil
}
if wrapped.Items != nil {
return wrapped.Items, nil
}
}
var direct []WayneRoleGroup
if err := json.Unmarshal(body, &direct); err != nil {
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, err
}
return direct, nil
return wrapped.Data.List, nil
}
func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) {
var wrapped struct {
Data struct {
Permissions WayneOperatorPermissions `json:"permissions"`
} `json:"data"`
Permissions WayneOperatorPermissions `json:"permissions"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, err
}
if wrapped.Data.Permissions != (WayneOperatorPermissions{}) {
return &wrapped.Data.Permissions, nil
}
return &wrapped.Permissions, nil
}
func parseNativePermissions(body []byte, scope string) (WayneOperatorPermissions, error) {
var wrapped struct {
Data map[string]map[string]bool `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return WayneOperatorPermissions{}, err
}
key := "namespaceUser"
if scope == "app" {
key = "appUser"
}
return WayneOperatorPermissions{
Create: wrapped.Data[key]["create"],
Update: wrapped.Data[key]["update"],
Delete: wrapped.Data[key]["delete"],
}, nil
}
func rawWayneData(body []byte) json.RawMessage {
var wrapped struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err == nil && len(wrapped.Data) > 0 {
return wrapped.Data
}
return json.RawMessage(body)
}
func mustMarshalRaw(value any) json.RawMessage {
raw, err := json.Marshal(value)
if err != nil {
return json.RawMessage(`null`)
}
return raw
}
func parseWayneResourceIDs(body []byte) ([]uint64, error) {
var wrapped struct {
Data struct {
List []struct {
ID uint64 `json:"id"`
} `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, err
}
return resourceIDsFromItems(wrapped.Data.List), nil
}
func resourceIDsFromItems(items []struct {
ID uint64 `json:"id"`
}) []uint64 {
ids := make([]uint64, 0, len(items))
for _, item := range items {
if item.ID != 0 {
ids = append(ids, item.ID)
}
}
return ids
}
type wayneNativeUser struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Display string `json:"display"`
}
func parseWayneUsers(body []byte) ([]wayneNativeUser, error) {
var wrapped struct {
Data struct {
List []wayneNativeUser `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, err
}
return wrapped.Data.List, nil
}
func sameWayneUser(user wayneNativeUser, username string) bool {
username = strings.TrimSpace(strings.ToLower(username))
return strings.ToLower(strings.TrimSpace(user.Name)) == username ||
strings.ToLower(strings.TrimSpace(user.Email)) == username
}
type wayneRoleBinding struct {
ID uint64 `json:"id"`
}
func parseWayneRoleBindings(body []byte) ([]wayneRoleBinding, error) {
var wrapped struct {
Data struct {
List []wayneRoleBinding `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, err
}
return wrapped.Data.List, nil
}
func isWayneVisitorRoleName(name string) bool {
normalized := strings.ToLower(strings.TrimSpace(name))
return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor")
+154 -147
View File
@@ -2,235 +2,209 @@ package service
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
)
func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testing.T) {
var requestPath string
var payload WayneRoleBindingRequest
func TestWayneRoleBindingServiceBindNamespaceUsesNativeAPI(t *testing.T) {
var got []string
var updateBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath = r.URL.RequestURI()
body := readTestBody(t, r)
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body))
got = append(got, r.Method+" "+r.URL.RequestURI())
switch r.URL.RequestURI() {
case "/login/db":
assertLoginBody(t, r)
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
case "/api/v1/users?name=target%40example.com":
assertBearer(t, r)
_, _ = w.Write([]byte(`{"data":{"list":[{"id":7,"name":"target@example.com","email":"target@example.com"}]}}`))
case "/api/v1/namespaces/1/users?userId=7":
assertBearer(t, r)
_, _ = w.Write([]byte(`{"data":{"list":[{"id":99}]}}`))
case "/api/v1/namespaces/1/users/99":
assertBearer(t, r)
body := readTestBody(t, r)
if err := json.Unmarshal(body, &updateBody); err != nil {
t.Fatalf("invalid update body: %v", err)
}
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("invalid request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
svc.now = func() time.Time { return time.Unix(1721000000, 0) }
operatorUserID := uint64(123)
replace := false
result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{
Username: "target@example.com",
GroupIDs: []uint64{10, 11},
OperatorUserID: &operatorUserID,
OperatorName: "attacker@example.com",
Replace: &replace,
RequestID: "req-001",
Reason: "grant",
})
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{10, 11}})
if err != nil {
t.Fatalf("BindNamespace() error = %v", err)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
}
if requestPath != "/api/v1/internal/namespaces/1/users/target@example.com/roles" {
t.Fatalf("requestPath = %q", requestPath)
want := []string{
"POST /login/db",
"GET /api/v1/users?name=target%40example.com",
"GET /api/v1/namespaces/1/users?userId=7",
"PUT /api/v1/namespaces/1/users/99",
}
if payload.OperatorName != "eastsales@qiniu.com" {
t.Fatalf("OperatorName = %q, want token email", payload.OperatorName)
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("requests:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
if payload.OperatorUserID != nil {
t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID)
if updateBody["id"].(float64) != 99 {
t.Fatalf("update id = %v, want 99", updateBody["id"])
}
if payload.Username != "" {
t.Fatalf("Username should be omitted from Wayne body, got %q", payload.Username)
if updateBody["namespace"].(map[string]any)["id"].(float64) != 1 {
t.Fatalf("namespace body = %#v", updateBody["namespace"])
}
if payload.Replace == nil || *payload.Replace {
t.Fatalf("Replace = %v, want false", payload.Replace)
if updateBody["user"].(map[string]any)["id"].(float64) != 7 {
t.Fatalf("user body = %#v", updateBody["user"])
}
}
func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
func TestParseWayneUsersSupportsNativePageList(t *testing.T) {
users, err := parseWayneUsers([]byte(`{"data":{"pageNo":1,"pageSize":10,"totalCount":1,"list":[{"id":7,"name":"target@example.com","email":"target@example.com"}]}}`))
if err != nil {
t.Fatalf("parseWayneUsers() error = %v", err)
}
if len(users) != 1 || users[0].ID != 7 || users[0].Name != "target@example.com" {
t.Fatalf("users = %#v", users)
}
}
func TestWayneRoleBindingServiceCreateAndDeleteNativeBindings(t *testing.T) {
tests := []struct {
name string
call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
want string
}{
{
name: "unbind namespace",
name: "create app binding",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.UnbindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
return s.BindApp(context.Background(), 3, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
},
want: "DELETE /api/v1/internal/namespaces/1/users/target@example.com/roles",
want: "POST /api/v1/apps/3/users",
},
{
name: "bind app",
name: "delete namespace binding",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
return s.UnbindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{})
},
want: "PUT /api/v1/internal/apps/3/users/target@example.com/roles",
},
{
name: "unbind app",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.UnbindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
},
want: "DELETE /api/v1/internal/apps/3/users/target@example.com/roles",
},
{
name: "list namespaces",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.ListNamespaces(context.Background())
},
want: "GET /api/v1/internal/namespaces",
},
{
name: "list namespace groups",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
groupType := 1
return s.ListGroups(context.Background(), &groupType)
},
want: "GET /api/v1/internal/groups?type=1",
},
{
name: "list all groups",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.ListGroups(context.Background(), nil)
},
want: "GET /api/v1/internal/groups",
},
{
name: "get user roles",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.GetUserRoles(context.Background(), "target@example.com")
},
want: "GET /api/v1/internal/users/target@example.com/roles",
},
{
name: "namespace operator permissions",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com",
},
{
name: "app operator permissions",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.AppOperatorPermissions(context.Background(), 3, "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/apps/3/operator-permissions?operatorName=eastsales%40qiniu.com",
want: "DELETE /api/v1/namespaces/1/users/44",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
var finalRequest string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Method + " " + r.URL.RequestURI()
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
switch {
case r.URL.RequestURI() == "/login/db":
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/users?"):
_, _ = w.Write([]byte(`{"data":{"list":[{"id":7,"name":"target@example.com"}]}}`))
case strings.Contains(r.URL.RequestURI(), "userId=7"):
if strings.Contains(tt.want, "POST ") {
_, _ = w.Write([]byte(`{"data":{"list":[]}}`))
} else {
_, _ = w.Write([]byte(`{"data":{"list":[{"id":44}]}}`))
}
default:
finalRequest = r.Method + " " + r.URL.RequestURI()
_, _ = w.Write([]byte(`{"data":{"ok":true}}`))
}
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
if _, err := tt.call(svc); err != nil {
t.Fatalf("call error = %v", err)
}
if got != tt.want {
t.Fatalf("got endpoint %q, want %q", got, tt.want)
if finalRequest != tt.want {
t.Fatalf("final request = %q, want %q", finalRequest, tt.want)
}
})
}
}
func TestWayneRoleBindingServiceQuerySignsEmptyBodyAndQueryURI(t *testing.T) {
func TestWayneRoleBindingServiceRefreshesTokenAfterUnauthorized(t *testing.T) {
loginCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r)
if len(body) != 0 {
t.Fatalf("GET body length = %d, want 0", len(body))
switch r.URL.RequestURI() {
case "/login/db":
loginCount++
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
case "/api/v1/groups?type=1":
if loginCount == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"msg":"expired"}`))
return
}
_, _ = w.Write([]byte(`{"data":{"list":[]}}`))
default:
t.Fatalf("unexpected request %s", r.URL.RequestURI())
}
if r.URL.RequestURI() != "/api/v1/internal/groups?type=1" {
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
}
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid GET signature headers: %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
groupType := 1
if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if loginCount != 2 {
t.Fatalf("loginCount = %d, want 2", loginCount)
}
}
func TestWayneRoleBindingServiceOperatorPermissionsSignsQueryURI(t *testing.T) {
func TestWayneRoleBindingServiceOperatorPermissionsFromNativePermissions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r)
if r.URL.RequestURI() != "/api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com" {
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
switch r.URL.RequestURI() {
case "/login/db":
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
case "/api/v1/namespaces/1/users/permissions/1":
_, _ = w.Write([]byte(`{"data":{"namespaceUser":{"create":true,"update":true,"delete":false}}}`))
default:
t.Fatalf("unexpected request %s", r.URL.RequestURI())
}
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid operator permissions signature headers: %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":{"permissions":{"create":true,"update":true,"delete":false}}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
if _, err := svc.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com"); err != nil {
t.Fatalf("NamespaceOperatorPermissions() error = %v", err)
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
permissions, err := svc.NamespaceOperatorPermissionsParsed(context.Background(), 1, "operator@example.com")
if err != nil {
t.Fatalf("NamespaceOperatorPermissionsParsed() error = %v", err)
}
if !permissions.Create || !permissions.Update || permissions.Delete {
t.Fatalf("permissions = %#v", permissions)
}
}
func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`))
switch r.URL.RequestURI() {
case "/login/db":
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
case "/api/v1/groups":
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`))
default:
t.Fatalf("unexpected request %s", r.URL.RequestURI())
}
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
result, err := svc.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
result, err := svc.ListGroups(context.Background(), nil)
if err == nil {
t.Fatal("expected error")
}
@@ -242,6 +216,39 @@ func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
}
}
func testWayneConfig(baseURL string) config.Config {
return config.Config{
WayneAPIBaseURL: baseURL,
WayneAdminUsername: "wayne-admin",
WayneAdminPassword: "wayne-password",
WayneTokenTTLMinutes: 60,
}
}
func assertLoginBody(t *testing.T, r *http.Request) {
t.Helper()
var body map[string]string
if err := json.Unmarshal(readTestBody(t, r), &body); err != nil {
t.Fatalf("invalid login body: %v", err)
}
if body["username"] != "wayne-admin" || body["password"] != "wayne-password" {
t.Fatalf("login body = %#v", body)
}
}
func assertBearer(t *testing.T, r *http.Request) {
t.Helper()
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
t.Fatalf("missing bearer header: %#v", r.Header)
}
}
func testJWT(exp time.Time) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"exp":` + strconv.FormatInt(exp.Unix(), 10) + `}`))
return header + "." + payload + ".sig"
}
func readTestBody(t *testing.T, r *http.Request) []byte {
t.Helper()
body, err := io.ReadAll(r.Body)