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>
152 lines
3.8 KiB
Go
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
|
|
}
|