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>
This commit is contained in:
parent
98066db68d
commit
e4af0cac73
27 changed files with 1760 additions and 7 deletions
|
|
@ -4,6 +4,7 @@ import (
|
|||
"apiserver/common"
|
||||
"apiserver/controllers"
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
"apiserver/utils"
|
||||
logger "apiserver/utils"
|
||||
"bytes"
|
||||
|
|
@ -99,6 +100,14 @@ func validateHeader() gin.HandlerFunc {
|
|||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
|
|
|||
109
src/frontend/apiserver/middleware/validatehandler_test.go
Normal file
109
src/frontend/apiserver/middleware/validatehandler_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
"apiserver/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func resetValidateHandlerState() {
|
||||
store.InitStores()
|
||||
utils.Log = zap.NewNop()
|
||||
utils.AuthenticationYamlConfig = &utils.Authentication{
|
||||
Token: utils.TokenConfig{
|
||||
SecretKey: "test-secret",
|
||||
Type: "HS256",
|
||||
TokenExpireTime: 3600,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newValidateHandlerRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(ValidateHandler())
|
||||
r.GET("/xce/v1/jobs", func(ctx *gin.Context) { ctx.Status(http.StatusOK) })
|
||||
return r
|
||||
}
|
||||
|
||||
// TestValidateHeader_APIKey_Valid verifies a valid, unexpired API key in the
|
||||
// Authorization header passes the middleware (no JWT involved at all).
|
||||
func TestValidateHeader_APIKey_Valid(t *testing.T) {
|
||||
resetValidateHandlerState()
|
||||
|
||||
raw := models.APIKeyPrefix + "valid-key"
|
||||
store.AddAPIKey(raw, store.APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
ExpiryTime: time.Now().Add(time.Hour),
|
||||
})
|
||||
|
||||
router := newValidateHandlerRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/xce/v1/jobs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHeader_APIKey_Unknown verifies an API-key-shaped token that was
|
||||
// never issued is rejected with 401, not a confusing JWT parse error.
|
||||
func TestValidateHeader_APIKey_Unknown(t *testing.T) {
|
||||
resetValidateHandlerState()
|
||||
|
||||
router := newValidateHandlerRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/xce/v1/jobs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+models.APIKeyPrefix+"never-issued")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHeader_APIKey_Expired verifies an expired API key is rejected.
|
||||
func TestValidateHeader_APIKey_Expired(t *testing.T) {
|
||||
resetValidateHandlerState()
|
||||
|
||||
raw := models.APIKeyPrefix + "expired-key"
|
||||
store.AddAPIKey(raw, store.APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
ExpiryTime: time.Now().Add(-time.Hour),
|
||||
})
|
||||
|
||||
router := newValidateHandlerRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/xce/v1/jobs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHeader_NoToken_StillRejected is a regression guard: adding the
|
||||
// API key branch must not weaken the existing "no token" rejection.
|
||||
func TestValidateHeader_NoToken_StillRejected(t *testing.T) {
|
||||
resetValidateHandlerState()
|
||||
|
||||
router := newValidateHandlerRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/xce/v1/jobs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue