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:
Lei Chen 2026-07-30 11:16:36 +08:00
parent 98066db68d
commit e4af0cac73
27 changed files with 1760 additions and 7 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}
}

View 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)
}
}

View file

@ -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"])

View 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
}

View file

@ -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
})

View 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)
}
}

View file

@ -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"`
}

View file

@ -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")
{

View 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
}

View 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")
}
}

View file

@ -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.