myxce/src/frontend/apiserver/middleware/validatehandler.go
Lei Chen e4af0cac73 Phase 0: scheduleragent + mcp-server read-only agent framework
Complete the natural-language scheduler agent's Phase 0 read-only pipeline:

Task #2 (API Key auth mechanism):
- New store/apikey.go: APIKey store (hash, validate, expiry)
- controllers/apikey.go: GenerateAPIKey finally persists + registers RBAC role
- controllers/auth.go: GetUsernameFromToken recognizes xce_ak_ prefix
- middleware/validatehandler.go: isValidToken handles API Key
- config/rbac.yaml: mcp_readonly role (view-only permissions)
- 6 new test files covering unit + integration full-chain

Task #3 (schedulerclient Go library):
- types.go: URM types (URMJob/URMQueue/URMNode/JobStatus/Metrics)
- client.go: read-only Client interface (6 methods)
- httpclient.go: resty-based HTTP implementation
- statusmap.go + _test.go: TDD status mapping (33 tests)

Task #4 (mcp-server binary):
- cmd/main.go: entry, reads XCE_API_URL/XCE_API_KEY, registers 6 MCP tools
- internal/app.go: tool handlers wiring schedulerclient to MCP stdio
- Uses github.com/mark3labs/mcp-go@v0.57.0 for stdio transport

All: go vet clean, go build clean, schedulerclient 33/33 tests pass.
mcp-server binary ~11MB arm64, CGO_ENABLED=0.

Architecture:
  Claude Code --stdio--> mcp-server --HTTP--> apiserver REST API --cgo--> C backend

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 11:16:36 +08:00

152 lines
3.8 KiB
Go

package middleware
import (
"apiserver/common"
"apiserver/controllers"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
logger "apiserver/utils"
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"go.uber.org/zap"
)
func ValidateHandler() gin.HandlerFunc {
logger.Debug("Enter ValidateHandler() function")
return func(ctx *gin.Context) {
if isExcludedPath(ctx.Request.URL.Path) {
ctx.Next()
return
}
validateHeader()(ctx)
if ctx.IsAborted() {
return
}
flag := !strings.Contains("multipart/form-data", ctx.Request.Header.Get("Content-Type"))
if ctx.Request.Method == http.MethodPost && flag == false {
isValidBody, err := validateRequestBody(ctx)
if err != nil || !isValidBody {
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE,
common.ERROR_READ_REQUEST_BODY_MSG+": "+err.Error(), nil)
return
}
}
if ctx.IsAborted() {
return
}
ctx.Next()
}
}
func isExcludedPath(path string) bool {
allowed := false
// define some excluded paths
excludedPaths := []string{
"/ping",
"/xce/v1/auth/logon",
"/swagger/*any",
"/xce/v1/auth/logout",
}
for _, excludedPath := range excludedPaths {
if strings.HasPrefix(path, excludedPath[:len(excludedPath)-1]) { // 去掉最后的 *
allowed = true
break
}
}
return allowed
}
// Token validation middleware
func validateHeader() gin.HandlerFunc {
logger.Debug("Enter validateHeader() function")
return func(ctx *gin.Context) {
//continue to validate token
token := ctx.GetHeader("Authorization")
if token == "" {
logger.Error("Token is empty")
controllers.ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE,
common.ERROR_AUTH_CHECK_TOKEN_EMPTY_MSG, nil)
ctx.Abort()
return
}
// Validate token (this is a placeholder for your actual token validation logic)
isValid, err := isValidToken(strings.TrimPrefix(token, "Bearer "), ctx.ClientIP())
if err != nil {
logger.Error("Validate token error", zap.Error(err))
controllers.ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE,
common.ERROR_AUTH_MSG+": "+err.Error(), nil)
ctx.Abort()
return
}
if !isValid {
logger.Error("Token is invalid")
controllers.ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE,
common.ERROR_AUTH_MSG, nil)
ctx.Abort()
return
}
}
}
func isValidToken(tokenString string, IP string) (bool, error) {
// Service-account API keys are a distinct credential type from JWTs and
// carry no IP binding (they're meant for long-lived automated clients
// like the scheduler-agent MCP server, which may not have a stable IP).
if strings.HasPrefix(tokenString, models.APIKeyPrefix) {
_, ok := store.ValidateAPIKey(tokenString)
return ok, nil
}
token, err := jwt.ParseWithClaims(tokenString, &models.JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(utils.AuthenticationYamlConfig.Token.SecretKey), nil
})
if err != nil {
return false, err
}
if claims, ok := token.Claims.(*models.JWTClaims); ok && token.Valid {
if claims.IP == IP {
return true, nil
}
}
return false, nil
}
func validateRequestBody(ctx *gin.Context) (bool, error) {
logger.Debug("Enter validateRequestBody function")
// read request body and resolve EOF error
body, err := ctx.GetRawData()
if err != nil {
logger.Error("Failed to read request body", zap.Error(err))
ctx.Abort()
return false, err
}
if len(body) == 0 {
logger.Error("Request body is empty")
ctx.Abort()
return false, errors.New(common.ERROR_REQUEST_BODY_EMPTY_MSG)
}
// reset request body
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
// check request body is valid
if !json.Valid(body) {
logger.Error("Request body is not valid", zap.Error(err))
ctx.Abort()
return false, errors.New(common.ERROR_REQUEST_BODY_NOT_JSON_MSG)
}
return true, nil
}