diff --git a/frontend/components.d.ts b/frontend/components.d.ts
index 9fc6636..6d63c3e 100644
--- a/frontend/components.d.ts
+++ b/frontend/components.d.ts
@@ -12,14 +12,10 @@ declare module 'vue' {
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
- ElDivider: typeof import('element-plus/es')['ElDivider']
- ElForm: typeof import('element-plus/es')['ElForm']
- ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
- ElSpace: typeof import('element-plus/es')['ElSpace']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 87418d1..c1dd884 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -4,8 +4,7 @@
diff --git a/server/README.md b/server/README.md
index 37c0c92..4740566 100644
--- a/server/README.md
+++ b/server/README.md
@@ -283,6 +283,64 @@ Wayne 会把回调地址拼成:
因此 `OAUTH_WAYNE_REDIRECT_URI` 必须和 Wayne 实际回调地址完全一致。浏览器访问 Wayne OAuth 登录入口后,如果 AuthServer 还没有登录态,会先跳内部 SAML;SAML 成功后再回到 OAuth authorize,签发 code 给 Wayne。
`WAYEN_OAUTH_REF` 是 AuthServer 发起 Wayne 登录时写入 Wayne `next` 参数的登录完成页,默认 `/portal/namespace/1/app`,对应 Wayne `DemoNamespaceId = 1` 的默认 namespace。不要配置成 `oauth` 或 `/oauth`,否则 Wayne 回调会把它当成前端路由跳到 `/oauth`。
+## Wayne 授权代理接口
+
+AuthServer 的 Wayne 授权代理接口不要求调用方传 Wayne user ID。后端会从当前 `authserver_token` 里取 `email`,把它作为 Wayne username 传给 Wayne internal API。
+
+对外接口:
+
+```text
+GET /auth/api/v1/wayne/namespaces
+GET /auth/api/v1/wayne/groups
+GET /auth/api/v1/wayne/users/me/roles
+GET /auth/api/v1/wayne/namespaces/:namespaceid/operator-permissions
+GET /auth/api/v1/wayne/apps/:appid/operator-permissions
+PUT /auth/api/v1/wayne/namespaces/:namespaceid/roles
+DELETE /auth/api/v1/wayne/namespaces/:namespaceid/roles
+PUT /auth/api/v1/wayne/apps/:appid/roles
+DELETE /auth/api/v1/wayne/apps/:appid/roles
+```
+
+示例:
+
+```http
+PUT /auth/api/v1/wayne/namespaces/1/roles
+Authorization: Bearer
+Content-Type: application/json
+
+{
+ "groupIds": [10, 11],
+ "replace": false,
+ "requestId": "req-001",
+ "reason": "grant namespace access"
+}
+```
+
+AuthServer 转发到 Wayne internal API 时会使用 token email:
+
+```text
+PUT /api/v1/internal/namespaces/1/users//roles
+```
+
+并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。
+
+相关配置:
+
+```env
+WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
+WAYNE_SERVICE_NAME=xinfra
+WAYNE_SERVICE_API_SECRET_KEY=
+```
+
+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
+```
+
管理员可查看当前 SAML metadata 配置:
```text
diff --git a/server/internal/config/config.go b/server/internal/config/config.go
index 9e58da7..9e4aa35 100644
--- a/server/internal/config/config.go
+++ b/server/internal/config/config.go
@@ -14,39 +14,43 @@ type OAuthClient struct {
}
type Config struct {
- AppEnv string
- HTTPAddr string
- PublicBaseURL string
- MySQLDSN string
- AutoMigrate bool
- JWTSecret string
- JWTIssuer string
- JWTTTLMinutes int
- SAMLEntityID string
- SAMLACSURL string
- SAMLSPCert string
- SAMLSPKey string
- SAMLIDPMetaURL string
- WayenLoginURL string
- WayenTargetURL string
- WayenUsernameKey string
- WayenPasswordKey string
- WayenLoginFormat string
- WayenLoginValue string
- WayenOAuthRef 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
+ JWTSecret string
+ JWTIssuer string
+ JWTTTLMinutes int
+ SAMLEntityID string
+ SAMLACSURL string
+ SAMLSPCert string
+ SAMLSPKey string
+ SAMLIDPMetaURL 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
}
func Load() Config {
@@ -59,39 +63,43 @@ 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),
- 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"),
- 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"),
- 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),
+ 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"),
+ 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", ""),
}
}
diff --git a/server/internal/handler/saml.go b/server/internal/handler/saml.go
index 4c21ed9..bfcac2d 100644
--- a/server/internal/handler/saml.go
+++ b/server/internal/handler/saml.go
@@ -62,6 +62,22 @@ func (h *SAMLHandler) Login(c *gin.Context) {
c.Redirect(http.StatusFound, redirectURL)
}
+func (h *SAMLHandler) Logout(c *gin.Context) {
+ expired := time.Unix(0, 0)
+ for _, path := range []string{"/auth/", "/"} {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: AuthSessionCookieName,
+ Value: "",
+ Path: path,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: -1,
+ Expires: expired,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
func (h *SAMLHandler) ACS(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -97,15 +113,12 @@ func (h *SAMLHandler) ACS(c *gin.Context) {
func ssoRedirectURL(relayState, token string) string {
target := strings.TrimSpace(relayState)
- if target == "" || !strings.HasPrefix(target, "/auth/") {
- target = "/auth/"
- }
- if strings.HasPrefix(target, "//") {
- target = "/auth/"
+ if target == "" {
+ target = "/"
}
parsed, err := url.Parse(target)
- if err != nil || parsed.IsAbs() {
- parsed = &url.URL{Path: "/auth/"}
+ if err != nil || parsed.IsAbs() || !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") || strings.HasPrefix(parsed.Path, "/auth/api/") {
+ parsed = &url.URL{Path: "/"}
}
values := parsed.Query()
values.Set("sso_token", token)
diff --git a/server/internal/handler/saml_test.go b/server/internal/handler/saml_test.go
new file mode 100644
index 0000000..c78eead
--- /dev/null
+++ b/server/internal/handler/saml_test.go
@@ -0,0 +1,53 @@
+package handler
+
+import "testing"
+
+func TestSSORedirectURLAllowsFrontendRoutes(t *testing.T) {
+ tests := []struct {
+ name string
+ relay string
+ want string
+ }{
+ {
+ name: "root",
+ relay: "/",
+ want: "/?sso_token=token-001",
+ },
+ {
+ name: "frontend route",
+ relay: "/subsystem?open_app=wayne#section",
+ want: "/subsystem?open_app=wayne&sso_token=token-001#section",
+ },
+ {
+ name: "oauth authorize route",
+ relay: "/auth/oauth/authorize?client_id=wayne",
+ want: "/auth/oauth/authorize?client_id=wayne&sso_token=token-001",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ssoRedirectURL(tt.relay, "token-001"); got != tt.want {
+ t.Fatalf("ssoRedirectURL(%q) = %q, want %q", tt.relay, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSSORedirectURLRejectsUnsafeRelayState(t *testing.T) {
+ tests := []string{
+ "",
+ "https://evil.example.com/",
+ "//evil.example.com/",
+ "dashboard",
+ "/auth/api/v1/users/me",
+ }
+
+ for _, relay := range tests {
+ t.Run(relay, func(t *testing.T) {
+ if got := ssoRedirectURL(relay, "token-001"); got != "/?sso_token=token-001" {
+ t.Fatalf("ssoRedirectURL(%q) = %q, want root fallback", relay, got)
+ }
+ })
+ }
+}
diff --git a/server/internal/handler/wayne_role_binding.go b/server/internal/handler/wayne_role_binding.go
new file mode 100644
index 0000000..04c036d
--- /dev/null
+++ b/server/internal/handler/wayne_role_binding.go
@@ -0,0 +1,315 @@
+package handler
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/1024XEngineer/xinfra/server/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type WayneRoleBindingHandler struct {
+ wayne *service.WayneRoleBindingService
+ audit *service.AuditService
+}
+
+func NewWayneRoleBindingHandler(wayne *service.WayneRoleBindingService, audit *service.AuditService) *WayneRoleBindingHandler {
+ return &WayneRoleBindingHandler{wayne: wayne, audit: audit}
+}
+
+func (h *WayneRoleBindingHandler) BindNamespace(c *gin.Context) {
+ h.handle(c, "namespace", http.MethodPut)
+}
+
+func (h *WayneRoleBindingHandler) UnbindNamespace(c *gin.Context) {
+ h.handle(c, "namespace", http.MethodDelete)
+}
+
+func (h *WayneRoleBindingHandler) BindApp(c *gin.Context) {
+ h.handle(c, "app", http.MethodPut)
+}
+
+func (h *WayneRoleBindingHandler) UnbindApp(c *gin.Context) {
+ h.handle(c, "app", http.MethodDelete)
+}
+
+func (h *WayneRoleBindingHandler) ListNamespaces(c *gin.Context) {
+ h.handleQuery(c, "namespaces", 0, "")
+}
+
+func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) {
+ var groupType *int
+ if raw := strings.TrimSpace(c.Query("type")); raw != "" {
+ value, err := strconv.Atoi(raw)
+ if err != nil || (value != 0 && value != 1) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"})
+ return
+ }
+ groupType = &value
+ }
+ result, err := h.wayne.ListGroups(c.Request.Context(), groupType)
+ if err != nil {
+ h.writeQueryAudit(c, "groups", 0, "deny", err.Error())
+ writeWayneRoleBindingError(c, result, err)
+ return
+ }
+ h.writeQueryAudit(c, "groups", 0, "allow", "")
+ writeWayneRoleBindingResult(c, result)
+}
+
+func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) {
+ username, ok := currentTokenEmail(c)
+ if !ok {
+ return
+ }
+ h.handleQuery(c, "user_roles", 0, username)
+}
+
+func (h *WayneRoleBindingHandler) NamespaceOperatorPermissions(c *gin.Context) {
+ namespaceID, ok := parseUintPathParam(c, "namespaceid")
+ if !ok {
+ return
+ }
+ h.handleOperatorPermissions(c, "namespace", namespaceID)
+}
+
+func (h *WayneRoleBindingHandler) AppOperatorPermissions(c *gin.Context) {
+ appID, ok := parseUintPathParam(c, "appid")
+ if !ok {
+ return
+ }
+ h.handleOperatorPermissions(c, "app", appID)
+}
+
+func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method string) {
+ claims, ok := CurrentClaims(c)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
+ return
+ }
+ operatorEmail := strings.TrimSpace(claims.Email)
+ if operatorEmail == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
+ return
+ }
+
+ resourceParam := "namespaceid"
+ if scope == "app" {
+ resourceParam = "appid"
+ }
+ resourceID, ok := parseUintPathParam(c, resourceParam)
+ if !ok {
+ return
+ }
+ req, ok := parseRoleBindingRequest(c)
+ if !ok {
+ return
+ }
+ if method == http.MethodPut && len(req.GroupIDs) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"})
+ return
+ }
+
+ result, err := h.call(c, scope, method, resourceID, operatorEmail, req)
+ if err != nil {
+ h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "deny", req.RequestID, err.Error())
+ writeWayneRoleBindingError(c, result, err)
+ return
+ }
+
+ h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "allow", req.RequestID, "")
+ writeWayneRoleBindingResult(c, result)
+}
+
+func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, username string) {
+ var result *service.WayneRoleBindingResult
+ var err error
+ switch resourceType {
+ case "namespaces":
+ result, err = h.wayne.ListNamespaces(c.Request.Context())
+ case "user_roles":
+ result, err = h.wayne.GetUserRoles(c.Request.Context(), username)
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported query resource"})
+ return
+ }
+ if err != nil {
+ h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error())
+ writeWayneRoleBindingError(c, result, err)
+ return
+ }
+ h.writeQueryAudit(c, resourceType, resourceID, "allow", "")
+ writeWayneRoleBindingResult(c, result)
+}
+
+func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scope string, resourceID uint64) {
+ claims, ok := CurrentClaims(c)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
+ return
+ }
+ operatorEmail := strings.TrimSpace(claims.Email)
+ if operatorEmail == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
+ return
+ }
+
+ var result *service.WayneRoleBindingResult
+ var err error
+ if scope == "namespace" {
+ result, err = h.wayne.NamespaceOperatorPermissions(c.Request.Context(), resourceID, operatorEmail)
+ } else {
+ result, err = h.wayne.AppOperatorPermissions(c.Request.Context(), resourceID, operatorEmail)
+ }
+ resourceType := scope + "_operator_permissions"
+ if err != nil {
+ h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error())
+ writeWayneRoleBindingError(c, result, err)
+ return
+ }
+ h.writeQueryAudit(c, resourceType, resourceID, "allow", "")
+ writeWayneRoleBindingResult(c, result)
+}
+
+func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) {
+ if scope == "namespace" {
+ if method == http.MethodPut {
+ return h.wayne.BindNamespace(c.Request.Context(), resourceID, operatorEmail, req)
+ }
+ return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, operatorEmail, req)
+ }
+ if method == http.MethodPut {
+ return h.wayne.BindApp(c.Request.Context(), resourceID, operatorEmail, req)
+ }
+ return h.wayne.UnbindApp(c.Request.Context(), resourceID, operatorEmail, req)
+}
+
+func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) {
+ var req service.WayneRoleBindingRequest
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
+ body, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return req, false
+ }
+ if strings.TrimSpace(string(body)) == "" {
+ return req, true
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return req, false
+ }
+ return req, true
+}
+
+func parseUintPathParam(c *gin.Context, name string) (uint64, bool) {
+ raw := strings.TrimSpace(c.Param(name))
+ value, err := strconv.ParseUint(raw, 10, 64)
+ if err != nil || value == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s", name)})
+ return 0, false
+ }
+ return value, true
+}
+
+func writeWayneRoleBindingResult(c *gin.Context, result *service.WayneRoleBindingResult) {
+ if result == nil {
+ c.JSON(http.StatusOK, gin.H{"data": gin.H{}})
+ return
+ }
+ contentType := result.ContentType
+ if contentType == "" {
+ contentType = "application/json"
+ }
+ if len(result.Body) == 0 {
+ c.Status(result.StatusCode)
+ return
+ }
+ c.Data(result.StatusCode, contentType, result.Body)
+}
+
+func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBindingResult, err error) {
+ if result != nil && len(result.Body) > 0 {
+ contentType := result.ContentType
+ if contentType == "" {
+ contentType = "application/json"
+ }
+ c.Data(result.StatusCode, contentType, result.Body)
+ return
+ }
+ var httpErr *service.WayneRoleBindingHTTPError
+ switch {
+ case errors.Is(err, service.ErrWayneRoleBindingNotConfigured):
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrWayenEmailMissing):
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ case errors.As(err, &httpErr):
+ c.JSON(httpErr.StatusCode, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+ }
+}
+
+func currentTokenEmail(c *gin.Context) (string, bool) {
+ claims, ok := CurrentClaims(c)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
+ return "", false
+ }
+ email := strings.TrimSpace(claims.Email)
+ if email == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
+ return "", false
+ }
+ return email, true
+}
+
+func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) {
+ h.audit.Write(service.AuditEntry{
+ RequestID: requestID,
+ ActorUserID: userID,
+ ActorUsername: operatorEmail,
+ ClientIP: c.ClientIP(),
+ UserAgent: c.Request.UserAgent(),
+ Action: "wayne.role_binding." + decision,
+ ResourceType: "wayne_" + scope,
+ ResourceID: strconv.FormatUint(resourceID, 10),
+ ScopeType: scope,
+ ScopeID: resourceID,
+ Decision: decision,
+ Reason: reason,
+ Metadata: map[string]any{
+ "targetUsername": targetUsername,
+ },
+ })
+}
+
+func (h *WayneRoleBindingHandler) writeQueryAudit(c *gin.Context, resourceType string, resourceID uint64, decision, reason string) {
+ claims, _ := CurrentClaims(c)
+ var actorUserID uint64
+ var actorUsername string
+ if claims != nil {
+ actorUserID = claims.UserID
+ actorUsername = strings.TrimSpace(claims.Email)
+ if actorUsername == "" {
+ actorUsername = claims.Username
+ }
+ }
+ h.audit.Write(service.AuditEntry{
+ ActorUserID: actorUserID,
+ ActorUsername: actorUsername,
+ ClientIP: c.ClientIP(),
+ UserAgent: c.Request.UserAgent(),
+ Action: "wayne.role_query." + decision,
+ ResourceType: "wayne_" + resourceType,
+ ResourceID: strconv.FormatUint(resourceID, 10),
+ Decision: decision,
+ Reason: reason,
+ })
+}
diff --git a/server/internal/router/router.go b/server/internal/router/router.go
index 9ccc733..7c7e687 100644
--- a/server/internal/router/router.go
+++ b/server/internal/router/router.go
@@ -69,10 +69,12 @@ 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)
healthHandler := handler.NewHealthHandler(deps.DB)
userHandler := handler.NewUserHandler()
wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService)
+ wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService)
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
@@ -88,6 +90,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
v1 := r.Group("/auth/api/v1")
{
v1.GET("/login/internal-sso", samlHandler.Login)
+ v1.POST("/logout", samlHandler.Logout)
v1.GET("/saml/metadata", samlHandler.Metadata)
v1.POST("/saml/acs", samlHandler.ACS)
@@ -97,6 +100,15 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
protected.GET("/wayen/login", wayenHandler.Login)
protected.GET("/wayen/credential", wayenHandler.GetCredential)
protected.PUT("/wayen/credential", wayenHandler.SaveCredential)
+ protected.GET("/wayne/namespaces", wayneRoleBindingHandler.ListNamespaces)
+ protected.GET("/wayne/groups", wayneRoleBindingHandler.ListGroups)
+ protected.GET("/wayne/users/me/roles", wayneRoleBindingHandler.GetCurrentUserRoles)
+ protected.GET("/wayne/namespaces/:namespaceid/operator-permissions", wayneRoleBindingHandler.NamespaceOperatorPermissions)
+ protected.GET("/wayne/apps/:appid/operator-permissions", wayneRoleBindingHandler.AppOperatorPermissions)
+ protected.PUT("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.BindNamespace)
+ protected.DELETE("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.UnbindNamespace)
+ protected.PUT("/wayne/apps/:appid/roles", wayneRoleBindingHandler.BindApp)
+ protected.DELETE("/wayne/apps/:appid/roles", wayneRoleBindingHandler.UnbindApp)
protected.GET("/clouddm/login", clouddmHandler.Login)
}
}
diff --git a/server/internal/service/wayen.go b/server/internal/service/wayen.go
index 996ef6b..f60e4e9 100644
--- a/server/internal/service/wayen.go
+++ b/server/internal/service/wayen.go
@@ -53,8 +53,12 @@ func (s *WayenService) Login(email, username string) (*WayenLoginResult, error)
if email == "" {
return nil, ErrWayenEmailMissing
}
- if strings.TrimSpace(s.cfg.OAuthRedirectURI) != "" && strings.TrimSpace(s.cfg.WayenTargetURL) != "" {
- target, err := s.oauthLoginURL(s.cfg.OAuthRedirectURI, s.cfg.WayenTargetURL)
+ oauthLoginURL := strings.TrimSpace(s.cfg.WayenOAuthLoginURL)
+ if oauthLoginURL == "" {
+ oauthLoginURL = strings.TrimSpace(s.cfg.OAuthRedirectURI)
+ }
+ if oauthLoginURL != "" && strings.TrimSpace(s.cfg.WayenTargetURL) != "" {
+ target, err := s.oauthLoginURL(oauthLoginURL, s.cfg.WayenTargetURL)
if err != nil {
return nil, err
}
diff --git a/server/internal/service/wayen_test.go b/server/internal/service/wayen_test.go
new file mode 100644
index 0000000..8fbdc8e
--- /dev/null
+++ b/server/internal/service/wayen_test.go
@@ -0,0 +1,69 @@
+package service
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/1024XEngineer/xinfra/server/internal/config"
+)
+
+func TestWayenLoginUsesDedicatedOAuthLoginURL(t *testing.T) {
+ service := NewWayenService(config.Config{
+ WayenOAuthLoginURL: "http://218.11.5.223:32000/login/oauth2/oauth2",
+ OAuthRedirectURI: "http://218.11.5.223:30008/login/oauth2/oauth2",
+ WayenTargetURL: "http://218.11.5.223:32000/",
+ WayenOAuthRef: "/portal/namespace/1/app",
+ }, nil)
+
+ result, err := service.Login("eastsales@qiniu.com", "eastsales@qiniu.com")
+ if err != nil {
+ t.Fatalf("Login() error = %v", err)
+ }
+ parsed, err := url.Parse(result.TargetURL)
+ if err != nil {
+ t.Fatalf("invalid target url: %v", err)
+ }
+ if parsed.Host != "218.11.5.223:32000" {
+ t.Fatalf("target host = %q, want Wayne frontend 32000", parsed.Host)
+ }
+ if parsed.Path != "/login/oauth2/oauth2" {
+ t.Fatalf("target path = %q", parsed.Path)
+ }
+
+ next := parsed.Query().Get("next")
+ if next == "" {
+ t.Fatal("next is empty")
+ }
+ parsedNext, err := url.Parse(next)
+ if err != nil {
+ t.Fatalf("invalid next url: %v", err)
+ }
+ if parsedNext.Host != "218.11.5.223:32000" {
+ t.Fatalf("next host = %q, want Wayne frontend 32000", parsedNext.Host)
+ }
+ if parsedNext.Path != "/sign-in" {
+ t.Fatalf("next path = %q, want /sign-in", parsedNext.Path)
+ }
+ if parsedNext.Query().Get("ref") != "/portal/namespace/1/app" {
+ t.Fatalf("next ref = %q", parsedNext.Query().Get("ref"))
+ }
+}
+
+func TestWayenLoginFallsBackToOAuthRedirectURI(t *testing.T) {
+ service := NewWayenService(config.Config{
+ OAuthRedirectURI: "http://218.11.5.223:32000/login/oauth2/oauth2",
+ WayenTargetURL: "http://218.11.5.223:32000/",
+ }, nil)
+
+ result, err := service.Login("eastsales@qiniu.com", "eastsales@qiniu.com")
+ if err != nil {
+ t.Fatalf("Login() error = %v", err)
+ }
+ parsed, err := url.Parse(result.TargetURL)
+ if err != nil {
+ t.Fatalf("invalid target url: %v", err)
+ }
+ if parsed.Host != "218.11.5.223:32000" {
+ t.Fatalf("target host = %q, want fallback redirect host", parsed.Host)
+ }
+}
diff --git a/server/internal/service/wayne_role_binding.go b/server/internal/service/wayne_role_binding.go
new file mode 100644
index 0000000..d331f3f
--- /dev/null
+++ b/server/internal/service/wayne_role_binding.go
@@ -0,0 +1,250 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/1024XEngineer/xinfra/server/internal/config"
+ "github.com/1024XEngineer/xinfra/server/internal/wayne"
+)
+
+var (
+ ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured")
+ ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed")
+)
+
+type WayneRoleBindingRequest struct {
+ GroupIDs []uint64 `json:"groupIds,omitempty"`
+ OperatorUserID *uint64 `json:"operatorUserId,omitempty"`
+ OperatorName string `json:"operatorName,omitempty"`
+ Replace *bool `json:"replace,omitempty"`
+ RequestID string `json:"requestId,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ DryRun bool `json:"dryRun,omitempty"`
+}
+
+type WayneRoleBindingResult struct {
+ StatusCode int
+ ContentType string
+ Body []byte
+}
+
+type WayneRoleBindingHTTPError struct {
+ StatusCode int
+ Body []byte
+}
+
+func (e *WayneRoleBindingHTTPError) Error() string {
+ body := strings.TrimSpace(string(e.Body))
+ if body == "" {
+ return fmt.Sprintf("%s: status %d", ErrWayneRoleBindingRequestFailed, e.StatusCode)
+ }
+ if len(body) > 512 {
+ body = body[:512]
+ }
+ return fmt.Sprintf("%s: status %d: %s", ErrWayneRoleBindingRequestFailed, e.StatusCode, body)
+}
+
+type WayneRoleBindingService struct {
+ cfg config.Config
+ client *http.Client
+ now func() time.Time
+}
+
+func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
+ return &WayneRoleBindingService{
+ cfg: cfg,
+ client: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ now: time.Now,
+ }
+}
+
+func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
+ return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
+}
+
+func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
+ return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
+}
+
+func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
+ return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
+}
+
+func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
+ return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
+}
+
+func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
+ return s.callRaw(ctx, http.MethodGet, "/api/v1/internal/namespaces", nil)
+}
+
+func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
+ internalPath := "/api/v1/internal/groups"
+ if groupType != nil {
+ values := url.Values{}
+ values.Set("type", strconv.Itoa(*groupType))
+ internalPath += "?" + values.Encode()
+ }
+ return s.callRaw(ctx, http.MethodGet, internalPath, nil)
+}
+
+func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username string) (*WayneRoleBindingResult, error) {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return nil, ErrWayenEmailMissing
+ }
+ return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil)
+}
+
+func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
+ return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail)
+}
+
+func (s *WayneRoleBindingService) 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)
+}
+
+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 == "" {
+ return nil, ErrWayenEmailMissing
+ }
+
+ req.OperatorUserID = nil
+ req.OperatorName = operatorEmail
+
+ body, err := json.Marshal(req)
+ 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 {
+ return nil, err
+ }
+ if body == nil {
+ body = []byte{}
+ }
+ target, signingURI, err := s.requestURL(internalPath)
+ 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) != "",
+ )
+ httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ if len(body) > 0 {
+ 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)
+
+ 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)
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return nil, err
+ }
+ result := &WayneRoleBindingResult{
+ StatusCode: resp.StatusCode,
+ 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),
+ )
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
+ return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
+ }
+ return result, 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 {
+ log.Printf(
+ "wayne role binding config invalid: base_url_configured=%t service_name_configured=%t secret_configured=%t",
+ baseConfigured,
+ serviceConfigured,
+ secretConfigured,
+ )
+ return ErrWayneRoleBindingNotConfigured
+ }
+ return nil
+}
+
+func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) {
+ base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/"))
+ if err != nil {
+ return "", "", err
+ }
+ if base.Scheme == "" || base.Host == "" {
+ return "", "", fmt.Errorf("invalid wayne internal api base url: %s", s.cfg.WayneInternalAPIBaseURL)
+ }
+ path, rawQuery, _ := strings.Cut(internalPath, "?")
+ base.Path = strings.TrimRight(base.Path, "/") + path
+ base.RawQuery = rawQuery
+ return base.String(), base.RequestURI(), nil
+}
+
+func truncateForDebugLog(value string, limit int) string {
+ value = strings.TrimSpace(value)
+ if limit <= 0 || len(value) <= limit {
+ return value
+ }
+ return value[:limit] + "...(truncated)"
+}
diff --git a/server/internal/service/wayne_role_binding_test.go b/server/internal/service/wayne_role_binding_test.go
new file mode 100644
index 0000000..c665046
--- /dev/null
+++ b/server/internal/service/wayne_role_binding_test.go
@@ -0,0 +1,248 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "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
+ 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))
+ }
+ 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, "eastsales@qiniu.com", WayneRoleBindingRequest{
+ GroupIDs: []uint64{10, 11},
+ OperatorUserID: &operatorUserID,
+ OperatorName: "attacker@example.com",
+ Replace: &replace,
+ RequestID: "req-001",
+ Reason: "grant",
+ })
+ 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/eastsales@qiniu.com/roles" {
+ t.Fatalf("requestPath = %q", requestPath)
+ }
+ if payload.OperatorName != "eastsales@qiniu.com" {
+ t.Fatalf("OperatorName = %q, want token email", payload.OperatorName)
+ }
+ if payload.OperatorUserID != nil {
+ t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID)
+ }
+ if payload.Replace == nil || *payload.Replace {
+ t.Fatalf("Replace = %v, want false", payload.Replace)
+ }
+}
+
+func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
+ tests := []struct {
+ name string
+ call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
+ want string
+ }{
+ {
+ name: "unbind namespace",
+ call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
+ return s.UnbindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
+ },
+ want: "DELETE /api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles",
+ },
+ {
+ name: "bind app",
+ call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
+ return s.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
+ },
+ want: "PUT /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
+ },
+ {
+ name: "unbind app",
+ call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
+ return s.UnbindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
+ },
+ want: "DELETE /api/v1/internal/apps/3/users/eastsales@qiniu.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(), "eastsales@qiniu.com")
+ },
+ want: "GET /api/v1/internal/users/eastsales@qiniu.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 {
+ t.Run(tt.name, func(t *testing.T) {
+ var got 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}}`))
+ }))
+ defer server.Close()
+
+ svc := NewWayneRoleBindingService(config.Config{
+ WayneInternalAPIBaseURL: server.URL,
+ WayneServiceName: "xinfra",
+ WayneServiceAPISecretKey: "service-secret",
+ })
+ 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)
+ }
+ })
+ }
+}
+
+func TestWayneRoleBindingServiceQuerySignsEmptyBodyAndQueryURI(t *testing.T) {
+ 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))
+ }
+ 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",
+ })
+ groupType := 1
+ if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
+ t.Fatalf("ListGroups() error = %v", err)
+ }
+}
+
+func TestWayneRoleBindingServiceOperatorPermissionsSignsQueryURI(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())
+ }
+ 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)
+ }
+}
+
+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"}`))
+ }))
+ defer server.Close()
+
+ svc := NewWayneRoleBindingService(config.Config{
+ WayneInternalAPIBaseURL: server.URL,
+ WayneServiceName: "xinfra",
+ WayneServiceAPISecretKey: "service-secret",
+ })
+ result, err := svc.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if result == nil || result.StatusCode != http.StatusForbidden {
+ t.Fatalf("result = %#v, want 403", result)
+ }
+ if !strings.Contains(err.Error(), "denied") {
+ t.Fatalf("error = %q, want denied body", err.Error())
+ }
+}
+
+func readTestBody(t *testing.T, r *http.Request) []byte {
+ t.Helper()
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ return body
+}
diff --git a/server/internal/sso/login.go b/server/internal/sso/login.go
index c863fe4..14e6f77 100644
--- a/server/internal/sso/login.go
+++ b/server/internal/sso/login.go
@@ -27,6 +27,13 @@ import (
const (
samlHTTPRedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
samlHTTPPostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+
+ xmlEncAES128CBC = "http://www.w3.org/2001/04/xmlenc#aes128-cbc"
+ xmlEncAES192CBC = "http://www.w3.org/2001/04/xmlenc#aes192-cbc"
+ xmlEncAES256CBC = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
+ xmlEncAES128GCM = "http://www.w3.org/2009/xmlenc11#aes128-gcm"
+ xmlEncAES192GCM = "http://www.w3.org/2009/xmlenc11#aes192-gcm"
+ xmlEncAES256GCM = "http://www.w3.org/2009/xmlenc11#aes256-gcm"
)
type LoginConfig struct {
@@ -178,7 +185,7 @@ func decryptEncryptedAssertion(encrypted encryptedAssertion, privateKeyFile stri
if err != nil {
return "", assertion{}, fmt.Errorf("decode saml encrypted assertion: %w", err)
}
- plain, err := decryptAESCBC(encryptedValue, sessionKey)
+ plain, err := decryptAssertionCipherValue(encryptedValue, sessionKey, encrypted.EncryptedData.EncryptionMethod.Algorithm)
if err != nil {
return "", assertion{}, err
}
@@ -216,6 +223,17 @@ func readRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
return key, nil
}
+func decryptAssertionCipherValue(value, key []byte, algorithm string) ([]byte, error) {
+ switch strings.TrimSpace(algorithm) {
+ case "", xmlEncAES128CBC, xmlEncAES192CBC, xmlEncAES256CBC:
+ return decryptAESCBC(value, key)
+ case xmlEncAES128GCM, xmlEncAES192GCM, xmlEncAES256GCM:
+ return decryptAESGCM(value, key)
+ default:
+ return nil, fmt.Errorf("unsupported saml assertion encryption algorithm: %s", algorithm)
+ }
+}
+
func decryptAESCBC(value, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -235,6 +253,27 @@ func decryptAESCBC(value, key []byte) ([]byte, error) {
return plain, nil
}
+func decryptAESGCM(value, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("init saml assertion cipher: %w", err)
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("init saml assertion gcm: %w", err)
+ }
+ if len(value) <= aead.NonceSize()+aead.Overhead() {
+ return nil, errors.New("invalid saml encrypted assertion gcm length")
+ }
+ nonce := value[:aead.NonceSize()]
+ cipherText := value[aead.NonceSize():]
+ plain, err := aead.Open(nil, nonce, cipherText, nil)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt saml assertion gcm: %w", err)
+ }
+ return plain, nil
+}
+
func pkcs7Unpad(value []byte, blockSize int) ([]byte, error) {
if len(value) == 0 || len(value)%blockSize != 0 {
return nil, errors.New("invalid saml assertion padding length")
diff --git a/server/internal/sso/login_test.go b/server/internal/sso/login_test.go
index eb1d3c3..d61350f 100644
--- a/server/internal/sso/login_test.go
+++ b/server/internal/sso/login_test.go
@@ -143,6 +143,53 @@ func TestDecodeSAMLResponseDecryptsEncryptedAssertion(t *testing.T) {
}
}
+func TestDecodeSAMLResponseDecryptsGCMEncryptedAssertion(t *testing.T) {
+ t.Parallel()
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ keyFile := writeTestPrivateKey(t, key)
+
+ assertion := `
+ bob@example.com
+
+ bob@example.com
+
+`
+ sessionKey := []byte("0123456789abcdef0123456789abcdef")
+ encryptedAssertion := encryptTestAssertionGCM(t, []byte(assertion), sessionKey)
+ encryptedKey, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &key.PublicKey, sessionKey, nil)
+ if err != nil {
+ t.Fatalf("encrypt key: %v", err)
+ }
+ response := `
+
+
+
+
+
+ ` + base64.StdEncoding.EncodeToString(encryptedKey) + `
+
+
+ ` + base64.StdEncoding.EncodeToString(encryptedAssertion) + `
+
+
+`
+
+ info, err := DecodeSAMLResponse(base64.StdEncoding.EncodeToString([]byte(response)), keyFile)
+ if err != nil {
+ t.Fatalf("DecodeSAMLResponse returned error: %v", err)
+ }
+ if info.NameID != "bob@example.com" {
+ t.Fatalf("unexpected name id: %q", info.NameID)
+ }
+ if got := info.Attributes["email"]; len(got) != 1 || got[0] != "bob@example.com" {
+ t.Fatalf("unexpected email attribute: %#v", got)
+ }
+}
+
func writeTestPrivateKey(t *testing.T, key *rsa.PrivateKey) string {
t.Helper()
file, err := os.CreateTemp(t.TempDir(), "sp-*.key")
@@ -174,6 +221,24 @@ func encryptTestAssertion(t *testing.T, plain, key []byte) []byte {
return out
}
+func encryptTestAssertionGCM(t *testing.T, plain, key []byte) []byte {
+ t.Helper()
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ t.Fatalf("init cipher: %v", err)
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ t.Fatalf("init gcm: %v", err)
+ }
+ nonce := bytes.Repeat([]byte{2}, aead.NonceSize())
+ cipherText := aead.Seal(nil, nonce, plain, nil)
+ out := make([]byte, len(nonce)+len(cipherText))
+ copy(out, nonce)
+ copy(out[len(nonce):], cipherText)
+ return out
+}
+
func pkcs7Pad(value []byte, blockSize int) []byte {
padding := blockSize - len(value)%blockSize
return append(value, bytes.Repeat([]byte{byte(padding)}, padding)...)
diff --git a/server/internal/wayne/signature.go b/server/internal/wayne/signature.go
new file mode 100644
index 0000000..3a2762d
--- /dev/null
+++ b/server/internal/wayne/signature.go
@@ -0,0 +1,123 @@
+package wayne
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ HeaderService = "X-Wayne-Service"
+ HeaderTimestamp = "X-Wayne-Timestamp"
+ HeaderNonce = "X-Wayne-Nonce"
+ HeaderSignature = "X-Wayne-Signature"
+
+ SignaturePrefix = "sha256="
+)
+
+var (
+ ErrMissingSecret = errors.New("wayne service api secret key is empty")
+ ErrMissingHeader = errors.New("wayne service header value is empty")
+)
+
+type SignedHeaders struct {
+ Service string
+ Timestamp string
+ Nonce string
+ Signature string
+}
+
+func BodySHA256Hex(body []byte) string {
+ sum := sha256.Sum256(body)
+ return hex.EncodeToString(sum[:])
+}
+
+func SigningPayload(method, uri, timestamp, nonce string, body []byte) string {
+ return strings.Join([]string{
+ strings.ToUpper(strings.TrimSpace(method)),
+ uri,
+ timestamp,
+ nonce,
+ BodySHA256Hex(body),
+ }, "\n")
+}
+
+func Sign(secret, method, uri, timestamp, nonce string, body []byte) (string, error) {
+ secret = secretValue(secret)
+ if secret == "" {
+ return "", ErrMissingSecret
+ }
+ payload := SigningPayload(method, uri, timestamp, nonce, body)
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = mac.Write([]byte(payload))
+ return SignaturePrefix + hex.EncodeToString(mac.Sum(nil)), nil
+}
+
+func Verify(secret, signature, method, uri, timestamp, nonce string, body []byte) bool {
+ expected, err := Sign(secret, method, uri, timestamp, nonce, body)
+ if err != nil {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(normalizeSignature(signature)), []byte(expected)) == 1
+}
+
+func BuildSignedHeaders(service, secret, method, uri string, body []byte, now time.Time) (SignedHeaders, error) {
+ service = strings.TrimSpace(service)
+ if service == "" {
+ return SignedHeaders{}, fmt.Errorf("%w: service", ErrMissingHeader)
+ }
+ timestamp := strconv.FormatInt(now.Unix(), 10)
+ nonce, err := NewNonce()
+ if err != nil {
+ return SignedHeaders{}, err
+ }
+ signature, err := Sign(secret, method, uri, timestamp, nonce, body)
+ if err != nil {
+ return SignedHeaders{}, err
+ }
+ return SignedHeaders{
+ Service: service,
+ Timestamp: timestamp,
+ Nonce: nonce,
+ Signature: signature,
+ }, nil
+}
+
+func (h SignedHeaders) Apply(req *http.Request) {
+ req.Header.Set(HeaderService, h.Service)
+ req.Header.Set(HeaderTimestamp, h.Timestamp)
+ req.Header.Set(HeaderNonce, h.Nonce)
+ req.Header.Set(HeaderSignature, h.Signature)
+}
+
+func NewNonce() (string, error) {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+func normalizeSignature(signature string) string {
+ signature = strings.TrimSpace(signature)
+ if strings.HasPrefix(signature, SignaturePrefix) {
+ return signature
+ }
+ return SignaturePrefix + signature
+}
+
+func secretValue(secret string) string {
+ secret = strings.TrimSpace(secret)
+ if _, value, ok := strings.Cut(secret, ":"); ok {
+ return strings.TrimSpace(value)
+ }
+ return secret
+}
diff --git a/server/internal/wayne/signature_test.go b/server/internal/wayne/signature_test.go
new file mode 100644
index 0000000..5c87635
--- /dev/null
+++ b/server/internal/wayne/signature_test.go
@@ -0,0 +1,100 @@
+package wayne
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestBodySHA256Hex(t *testing.T) {
+ got := BodySHA256Hex([]byte("hello"))
+ want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
+ if got != want {
+ t.Fatalf("BodySHA256Hex() = %q, want %q", got, want)
+ }
+}
+
+func TestSigningPayload(t *testing.T) {
+ body := []byte(`{"groupIds":[10]}`)
+ got := SigningPayload("put", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
+ want := strings.Join([]string{
+ "PUT",
+ "/api/v1/internal/namespaces/1/users/2001/roles",
+ "1721000000",
+ "nonce-001",
+ "cf296504b2a434969ee151c1a48aa07fabb64634c77e569af74260cf234080f2",
+ }, "\n")
+ if got != want {
+ t.Fatalf("SigningPayload() = %q, want %q", got, want)
+ }
+}
+
+func TestSignAndVerify(t *testing.T) {
+ body := []byte(`{"groupIds":[10]}`)
+ signature, err := Sign("test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
+ if err != nil {
+ t.Fatalf("Sign() error = %v", err)
+ }
+ want := "sha256=6da70ea095fde90b534d0677da62db867d0b8092f6b15ed86cb52d77571b5b1d"
+ if signature != want {
+ t.Fatalf("Sign() = %q, want %q", signature, want)
+ }
+ if !Verify("test-secret", signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) {
+ t.Fatal("Verify() rejected valid signature")
+ }
+ if !Verify("test-secret", strings.TrimPrefix(signature, SignaturePrefix), "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) {
+ t.Fatal("Verify() rejected valid signature without prefix")
+ }
+ if Verify("test-secret", signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", []byte(`{"groupIds":[11]}`)) {
+ t.Fatal("Verify() accepted tampered body")
+ }
+}
+
+func TestSignAcceptsServicePrefixedSecret(t *testing.T) {
+ body := []byte(`{"groupIds":[10]}`)
+ plainSecretSignature, err := Sign("test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
+ if err != nil {
+ t.Fatalf("Sign() with plain secret error = %v", err)
+ }
+ prefixedSecretSignature, err := Sign("xinfra:test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
+ if err != nil {
+ t.Fatalf("Sign() with prefixed secret error = %v", err)
+ }
+ if prefixedSecretSignature != plainSecretSignature {
+ t.Fatalf("prefixed secret signature = %q, want %q", prefixedSecretSignature, plainSecretSignature)
+ }
+}
+
+func TestBuildSignedHeadersAndApply(t *testing.T) {
+ body := []byte(`{"groupIds":[10]}`)
+ now := time.Unix(1721000000, 0)
+ headers, err := BuildSignedHeaders("xinfra", "test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", body, now)
+ if err != nil {
+ t.Fatalf("BuildSignedHeaders() error = %v", err)
+ }
+ if headers.Service != "xinfra" {
+ t.Fatalf("headers.Service = %q, want xinfra", headers.Service)
+ }
+ if headers.Timestamp != "1721000000" {
+ t.Fatalf("headers.Timestamp = %q, want 1721000000", headers.Timestamp)
+ }
+ if headers.Nonce == "" {
+ t.Fatal("headers.Nonce is empty")
+ }
+ if !Verify("test-secret", headers.Signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", headers.Timestamp, headers.Nonce, body) {
+ t.Fatal("generated headers signature is invalid")
+ }
+
+ req, err := http.NewRequest(http.MethodPut, "http://wayne.example.com/api/v1/internal/namespaces/1/users/2001/roles", strings.NewReader(string(body)))
+ if err != nil {
+ t.Fatalf("NewRequest() error = %v", err)
+ }
+ headers.Apply(req)
+ if req.Header.Get(HeaderService) != headers.Service ||
+ req.Header.Get(HeaderTimestamp) != headers.Timestamp ||
+ req.Header.Get(HeaderNonce) != headers.Nonce ||
+ req.Header.Get(HeaderSignature) != headers.Signature {
+ t.Fatalf("Apply() did not write expected signed headers: %#v", req.Header)
+ }
+}