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
|
|
@ -144,8 +144,31 @@ roles:
|
|||
- type: queue
|
||||
values: [normal]
|
||||
- action: job:control
|
||||
domain:
|
||||
domain:
|
||||
- type: queue
|
||||
values: [normal]
|
||||
|
||||
# mcp_readonly: service-account role for the scheduler-agent MCP server
|
||||
# (Phase 0, read-only tools only). Deliberately excludes job:control,
|
||||
# queue:control, host:control, cluster:control, rbac:*, file:*, user:* —
|
||||
# an MCP server credential should never be able to mutate cluster state
|
||||
# or manage other users/roles.
|
||||
mcp_readonly:
|
||||
permissions:
|
||||
- action: job:view
|
||||
domain:
|
||||
- type: all
|
||||
- action: queue:view
|
||||
domain:
|
||||
- type: all
|
||||
- action: host:view
|
||||
domain:
|
||||
- type: all
|
||||
- action: resource:view
|
||||
domain:
|
||||
- type: all
|
||||
- action: cluster:view
|
||||
domain:
|
||||
- type: all
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"apiserver/common"
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -16,7 +17,7 @@ func generateAPIKey() (string, error) {
|
|||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
return models.APIKeyPrefix + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
type APIKeyController struct{}
|
||||
|
|
@ -49,13 +50,34 @@ func (a *APIKeyController) GenerateAPIKey(ctx *gin.Context) {
|
|||
expiryDays = req.ExpiryDays
|
||||
}
|
||||
|
||||
createTime := time.Now()
|
||||
expiryTime := createTime.AddDate(0, 0, expiryDays)
|
||||
|
||||
// Persist the key (by hash) so it can actually be used to authenticate.
|
||||
store.AddAPIKey(apiKey, store.APIKeyEntry{
|
||||
Username: req.Username,
|
||||
RoleName: req.RoleName,
|
||||
Description: req.Description,
|
||||
CreateTime: createTime,
|
||||
ExpiryTime: expiryTime,
|
||||
})
|
||||
|
||||
// RequirePermission resolves permissions via store.GetUserRoleNames, not
|
||||
// via the APIKeyEntry directly — register the RBAC user→role mapping too,
|
||||
// or the issued key would authenticate but be denied every permission
|
||||
// check.
|
||||
if req.RoleName != "" {
|
||||
store.AddRoleForUser(req.Username, req.RoleName)
|
||||
}
|
||||
|
||||
// Create response
|
||||
response := models.APIKeyResponse{
|
||||
APIKey: apiKey,
|
||||
Username: req.Username,
|
||||
CreateTime: time.Now(),
|
||||
ExpiryTime: time.Now().AddDate(0, 0, expiryDays),
|
||||
RoleName: req.RoleName,
|
||||
CreateTime: createTime,
|
||||
ExpiryTime: expiryTime,
|
||||
}
|
||||
|
||||
ReturnSuccess(ctx, 0, "API key generated successfully", response)
|
||||
ReturnSuccess(ctx, common.SUCCESS_CODE, "API key generated successfully", response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestIntegration_APIKey_MCPReadonly_FullMiddlewareChain verifies the
|
||||
// end-to-end story for the scheduler-agent MCP server's service account:
|
||||
// an API-key-authenticated request with the mcp_readonly role can reach a
|
||||
// job:view route but is denied a job:control route — through the exact same
|
||||
// RequirePermission middleware JWT-authenticated users go through.
|
||||
func TestIntegration_APIKey_MCPReadonly_FullMiddlewareChain(t *testing.T) {
|
||||
yamlContent := `
|
||||
permissions:
|
||||
- job:view
|
||||
- job:control
|
||||
roles:
|
||||
mcp_readonly:
|
||||
permissions:
|
||||
- action: job:view
|
||||
domain:
|
||||
- type: all
|
||||
`
|
||||
setupRBACTest(t, yamlContent)
|
||||
|
||||
raw := models.APIKeyPrefix + "mcp-integration-test"
|
||||
store.AddAPIKey(raw, store.APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
RoleName: "mcp_readonly",
|
||||
ExpiryTime: time.Now().Add(time.Hour),
|
||||
})
|
||||
store.AddRoleForUser("mcp-bot", "mcp_readonly")
|
||||
|
||||
router := newTestRouter()
|
||||
jobsGroup := router.Group("/xce/v1/jobs")
|
||||
jobsGroup.GET("", RequirePermission("job:view"), func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
jobsGroup.POST("/control", RequirePermission("job:control"), func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusAccepted, gin.H{"status": "accepted"})
|
||||
})
|
||||
|
||||
w := doRequest(router, "GET", "/xce/v1/jobs", raw)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("mcp_readonly on job:view: got %d, want 200, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = doRequest(router, "POST", "/xce/v1/jobs/control", raw)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("mcp_readonly on job:control: got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
176
src/frontend/apiserver/controllers/apikey_test.go
Normal file
176
src/frontend/apiserver/controllers/apikey_test.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
"apiserver/utils"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func resetAPIKeyControllerState() {
|
||||
store.InitStores()
|
||||
utils.Log = zap.NewNop()
|
||||
}
|
||||
|
||||
// TestGetUsernameFromToken_APIKey verifies that a valid, unexpired API key
|
||||
// resolves to its associated username via the same entry point every other
|
||||
// controller already calls for JWTs — so no call site needs to change.
|
||||
func TestGetUsernameFromToken_APIKey(t *testing.T) {
|
||||
resetAPIKeyControllerState()
|
||||
|
||||
raw := models.APIKeyPrefix + "abc123"
|
||||
store.AddAPIKey(raw, store.APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
RoleName: "mcp_readonly",
|
||||
ExpiryTime: time.Now().Add(time.Hour),
|
||||
})
|
||||
|
||||
username, err := GetUsernameFromToken(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if username != "mcp-bot" {
|
||||
t.Errorf("username = %q, want mcp-bot", username)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUsernameFromToken_APIKey_BearerPrefix verifies the "Bearer <key>"
|
||||
// header form (as sent by real HTTP clients) also resolves correctly.
|
||||
func TestGetUsernameFromToken_APIKey_BearerPrefix(t *testing.T) {
|
||||
resetAPIKeyControllerState()
|
||||
|
||||
raw := models.APIKeyPrefix + "with-bearer"
|
||||
store.AddAPIKey(raw, store.APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
ExpiryTime: time.Now().Add(time.Hour),
|
||||
})
|
||||
|
||||
username, err := GetUsernameFromToken("Bearer " + raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if username != "mcp-bot" {
|
||||
t.Errorf("username = %q, want mcp-bot", username)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUsernameFromToken_APIKey_Unknown verifies an API-key-shaped token
|
||||
// that was never issued is rejected rather than silently falling through to
|
||||
// JWT parsing (which would also fail, but with a confusing error message).
|
||||
func TestGetUsernameFromToken_APIKey_Unknown(t *testing.T) {
|
||||
resetAPIKeyControllerState()
|
||||
|
||||
_, err := GetUsernameFromToken(models.APIKeyPrefix + "never-issued")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown API key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUsernameFromToken_APIKey_Expired verifies an expired API key is
|
||||
// rejected.
|
||||
func TestGetUsernameFromToken_APIKey_Expired(t *testing.T) {
|
||||
resetAPIKeyControllerState()
|
||||
|
||||
raw := models.APIKeyPrefix + "expired"
|
||||
store.AddAPIKey(raw, store.APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
ExpiryTime: time.Now().Add(-time.Hour),
|
||||
})
|
||||
|
||||
_, err := GetUsernameFromToken(raw)
|
||||
if err == nil {
|
||||
t.Error("expected error for expired API key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAPIKey_PersistsToStore verifies the controller actually writes
|
||||
// the generated key into the store (previously it only echoed a random hex
|
||||
// string back to the caller without persisting anything, so no request
|
||||
// authenticated with the returned key could ever succeed).
|
||||
func TestGenerateAPIKey_PersistsToStore(t *testing.T) {
|
||||
resetAPIKeyControllerState()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := &APIKeyController{}
|
||||
router.POST("/xce/v1/apikeys", handler.GenerateAPIKey)
|
||||
|
||||
body := `{"username":"mcp-bot","role_name":"mcp_readonly","expiry_days":7}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/xce/v1/apikeys", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", "Bearer some-admin-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data models.APIKeyResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Data.APIKey == "" {
|
||||
t.Fatal("response did not include an api_key")
|
||||
}
|
||||
|
||||
// The returned key must actually be usable for authentication —
|
||||
// i.e. it must have been persisted to the store, not merely echoed.
|
||||
entry, ok := store.ValidateAPIKey(resp.Data.APIKey)
|
||||
if !ok {
|
||||
t.Fatal("generated API key was not persisted to the store")
|
||||
}
|
||||
if entry.Username != "mcp-bot" {
|
||||
t.Errorf("persisted username = %q, want mcp-bot", entry.Username)
|
||||
}
|
||||
if entry.RoleName != "mcp_readonly" {
|
||||
t.Errorf("persisted role = %q, want mcp_readonly", entry.RoleName)
|
||||
}
|
||||
|
||||
// RequirePermission/GetUserAllPermissionsWithType resolve permissions via
|
||||
// store.GetUserRoleNames(username), not via the APIKeyEntry.RoleName field
|
||||
// directly — so generating a key must also register the RBAC user→role
|
||||
// mapping, otherwise the issued key would authenticate but be denied every
|
||||
// permission check.
|
||||
roles := store.GetUserRoleNames("mcp-bot")
|
||||
found := false
|
||||
for _, r := range roles {
|
||||
if r == "mcp_readonly" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetUserRoleNames(mcp-bot) = %v, want to include mcp_readonly", roles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAPIKey_RequiresAuthorizationHeader verifies the endpoint still
|
||||
// rejects requests with no Authorization header at all.
|
||||
func TestGenerateAPIKey_RequiresAuthorizationHeader(t *testing.T) {
|
||||
resetAPIKeyControllerState()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := &APIKeyController{}
|
||||
router.POST("/xce/v1/apikeys", handler.GenerateAPIKey)
|
||||
|
||||
body := `{"username":"mcp-bot","role_name":"mcp_readonly"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/xce/v1/apikeys", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
@ -181,7 +181,22 @@ func (a AuthController) Logout(ctx *gin.Context) {
|
|||
}
|
||||
|
||||
func GetUsernameFromToken(tokenString string) (string, error) {
|
||||
token, err := jwt.Parse(strings.TrimPrefix(tokenString, "Bearer "), func(token *jwt.Token) (interface{}, error) {
|
||||
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
|
||||
|
||||
// Service-account API keys are a distinct credential type from JWTs
|
||||
// (recognized by a fixed prefix, see models.APIKeyPrefix) — resolve them
|
||||
// against the API key store instead of attempting a JWT parse, so every
|
||||
// existing GetUsernameFromToken call site works transparently for both
|
||||
// credential types.
|
||||
if strings.HasPrefix(tokenString, models.APIKeyPrefix) {
|
||||
entry, ok := store.ValidateAPIKey(tokenString)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid or expired API key")
|
||||
}
|
||||
return entry.Username, nil
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the algorithm
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
|
|
|
|||
52
src/frontend/apiserver/controllers/rbac_mcp_readonly_test.go
Normal file
52
src/frontend/apiserver/controllers/rbac_mcp_readonly_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"apiserver/store"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRBACYAML_McpReadonlyRole_LoadsReadOnlyPermissions verifies the
|
||||
// mcp_readonly role (added for the scheduler-agent MCP server's service
|
||||
// account, Phase 0) is present in the shipped config/rbac.yaml and grants
|
||||
// only *:view permissions — never any *:control/*:submit permission, so an
|
||||
// MCP server credential can never mutate cluster state.
|
||||
func TestRBACYAML_McpReadonlyRole_LoadsReadOnlyPermissions(t *testing.T) {
|
||||
resetRBACState()
|
||||
|
||||
path, err := filepath.Abs("../config/rbac.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve rbac.yaml path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("config/rbac.yaml not found at %s: %v", path, err)
|
||||
}
|
||||
|
||||
if err := LoadRBACConfig(path); err != nil {
|
||||
t.Fatalf("LoadRBACConfig: %v", err)
|
||||
}
|
||||
|
||||
if !store.Roles.Has("mcp_readonly") {
|
||||
t.Fatal("expected mcp_readonly role to be loaded from config/rbac.yaml")
|
||||
}
|
||||
|
||||
perms := store.GetRolePermissions("mcp_readonly")
|
||||
if len(perms) == 0 {
|
||||
t.Fatal("mcp_readonly should have at least one permission")
|
||||
}
|
||||
|
||||
for _, p := range perms {
|
||||
if !hasViewSuffix(p.Action) {
|
||||
t.Errorf("mcp_readonly grants non-view permission %q — MCP service accounts must be read-only", p.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasViewSuffix(action string) bool {
|
||||
const suffix = ":view"
|
||||
if len(action) < len(suffix) {
|
||||
return false
|
||||
}
|
||||
return action[len(action)-len(suffix):] == suffix
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,14 @@ package models
|
|||
|
||||
import "time"
|
||||
|
||||
type APIKeyController struct{}
|
||||
// APIKeyPrefix marks an Authorization header value as a service-account API
|
||||
// key rather than a JWT, so validateHeader() can branch without attempting
|
||||
// (and failing) a JWT parse first.
|
||||
const APIKeyPrefix = "xce_ak_"
|
||||
|
||||
type APIKeyRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
RoleName string `json:"role_name" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ExpiryDays int `json:"expiry_days,omitempty"`
|
||||
}
|
||||
|
|
@ -13,6 +17,7 @@ type APIKeyRequest struct {
|
|||
type APIKeyResponse struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Username string `json:"username"`
|
||||
RoleName string `json:"role_name"`
|
||||
CreateTime time.Time `json:"create_time"`
|
||||
ExpiryTime time.Time `json:"expiry_time"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,14 @@ func Router() *gin.Engine {
|
|||
permHandler := &controllers.PermController{}
|
||||
permsApi.GET("", controllers.RequirePermission("rbac:view"), permHandler.GetPermsInfo)
|
||||
}
|
||||
// ################ case: apikeys route (service-account credentials, e.g. for the
|
||||
// scheduler-agent MCP server; generation is admin-only, gated the same way role
|
||||
// management is)
|
||||
apikeysApi := r.Group("/xce/v1/apikeys")
|
||||
{
|
||||
apikeyHandler := &controllers.APIKeyController{}
|
||||
apikeysApi.POST("", controllers.RequirePermission("rbac:control"), apikeyHandler.GenerateAPIKey)
|
||||
}
|
||||
// ################ case: roles route
|
||||
rolesApi := r.Group("/xce/v1/roles")
|
||||
{
|
||||
|
|
|
|||
47
src/frontend/apiserver/store/apikey.go
Normal file
47
src/frontend/apiserver/store/apikey.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeys maps a hashed API key (hex-encoded SHA-256) to its metadata.
|
||||
// The raw key is never stored — only its hash — so a store/config dump
|
||||
// cannot leak usable credentials.
|
||||
var APIKeys *DataStore[string, APIKeyEntry]
|
||||
|
||||
// APIKeyEntry holds the metadata associated with a service-account API key.
|
||||
type APIKeyEntry struct {
|
||||
Username string
|
||||
RoleName string
|
||||
Description string
|
||||
CreateTime time.Time
|
||||
ExpiryTime time.Time
|
||||
}
|
||||
|
||||
// HashAPIKey returns the hex-encoded SHA-256 digest of a raw API key.
|
||||
// Used both when persisting a newly generated key and when validating one
|
||||
// presented by a client — the raw value is never compared or stored directly.
|
||||
func HashAPIKey(rawKey string) string {
|
||||
sum := sha256.Sum256([]byte(rawKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AddAPIKey persists a new API key by its hash.
|
||||
func AddAPIKey(rawKey string, entry APIKeyEntry) {
|
||||
APIKeys.Put(HashAPIKey(rawKey), entry)
|
||||
}
|
||||
|
||||
// ValidateAPIKey looks up a raw API key by its hash and returns its metadata
|
||||
// if present and not expired. A zero ExpiryTime never expires.
|
||||
func ValidateAPIKey(rawKey string) (APIKeyEntry, bool) {
|
||||
entry, ok := APIKeys.Get(HashAPIKey(rawKey))
|
||||
if !ok {
|
||||
return APIKeyEntry{}, false
|
||||
}
|
||||
if !entry.ExpiryTime.IsZero() && time.Now().After(entry.ExpiryTime) {
|
||||
return APIKeyEntry{}, false
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
94
src/frontend/apiserver/store/apikey_test.go
Normal file
94
src/frontend/apiserver/store/apikey_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetAPIKeyState() {
|
||||
InitStores()
|
||||
}
|
||||
|
||||
func TestHashAPIKey_Deterministic(t *testing.T) {
|
||||
h1 := HashAPIKey("xce_ak_abc123")
|
||||
h2 := HashAPIKey("xce_ak_abc123")
|
||||
if h1 != h2 {
|
||||
t.Errorf("HashAPIKey should be deterministic: %q != %q", h1, h2)
|
||||
}
|
||||
h3 := HashAPIKey("xce_ak_different")
|
||||
if h1 == h3 {
|
||||
t.Error("HashAPIKey should differ for different inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAPIKey_NeverPlaintext(t *testing.T) {
|
||||
raw := "xce_ak_secret-value"
|
||||
hashed := HashAPIKey(raw)
|
||||
if hashed == raw {
|
||||
t.Error("HashAPIKey must not return the raw key unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAPIKey_ValidateAPIKey_RoundTrip(t *testing.T) {
|
||||
resetAPIKeyState()
|
||||
|
||||
raw := "xce_ak_roundtrip"
|
||||
entry := APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
RoleName: "mcp_readonly",
|
||||
CreateTime: time.Now(),
|
||||
ExpiryTime: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
AddAPIKey(raw, entry)
|
||||
|
||||
got, ok := ValidateAPIKey(raw)
|
||||
if !ok {
|
||||
t.Fatal("expected ValidateAPIKey to succeed for a freshly-added key")
|
||||
}
|
||||
if got.Username != "mcp-bot" {
|
||||
t.Errorf("Username = %q, want mcp-bot", got.Username)
|
||||
}
|
||||
if got.RoleName != "mcp_readonly" {
|
||||
t.Errorf("RoleName = %q, want mcp_readonly", got.RoleName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIKey_Unknown(t *testing.T) {
|
||||
resetAPIKeyState()
|
||||
|
||||
_, ok := ValidateAPIKey("xce_ak_never-issued")
|
||||
if ok {
|
||||
t.Error("expected ValidateAPIKey to fail for a key that was never added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIKey_Expired(t *testing.T) {
|
||||
resetAPIKeyState()
|
||||
|
||||
raw := "xce_ak_expired"
|
||||
AddAPIKey(raw, APIKeyEntry{
|
||||
Username: "mcp-bot",
|
||||
RoleName: "mcp_readonly",
|
||||
CreateTime: time.Now().Add(-48 * time.Hour),
|
||||
ExpiryTime: time.Now().Add(-1 * time.Hour), // already expired
|
||||
})
|
||||
|
||||
_, ok := ValidateAPIKey(raw)
|
||||
if ok {
|
||||
t.Error("expected ValidateAPIKey to fail for an expired key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIKey_StoredByHashNotPlaintext(t *testing.T) {
|
||||
resetAPIKeyState()
|
||||
|
||||
raw := "xce_ak_hashed-storage"
|
||||
AddAPIKey(raw, APIKeyEntry{Username: "mcp-bot", ExpiryTime: time.Now().Add(time.Hour)})
|
||||
|
||||
if APIKeys.Has(raw) {
|
||||
t.Error("store must not contain the raw key as a map key")
|
||||
}
|
||||
if !APIKeys.Has(HashAPIKey(raw)) {
|
||||
t.Error("store should contain the hashed key")
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ var (
|
|||
UserRoles *DataStore[string, []string] // user_name → role_names
|
||||
)
|
||||
|
||||
// APIKeys is declared in apikey.go (keeps API-key store logic self-contained).
|
||||
|
||||
// RoleEntry holds a role's permissions.
|
||||
type RoleEntry struct {
|
||||
RoleName string
|
||||
|
|
@ -41,6 +43,7 @@ func InitStores() {
|
|||
JobExecHosts = NewDataStore[int64, models.JobExecHosts]()
|
||||
Roles = NewDataStore[string, RoleEntry]()
|
||||
UserRoles = NewDataStore[string, []string]()
|
||||
APIKeys = NewDataStore[string, APIKeyEntry]()
|
||||
}
|
||||
|
||||
// Helper functions for RBAC.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue