fix(apiserver): apikeys whitelist + job_status type + mcp_operator queue:control

Three fixes found during scheduler-agent Phase 1 cluster verification:

- whitelist.go: add /xce/v1/apikeys to WhitelistPOSTURL (Phase 0 added the
  route but never whitelisted it → 403 "URL is not allowed"). bug-1489.
- jobs.go: fix job_status int64 assertion in job control. store.Jobs returns
  status as int/float64; the `.(int64)` assertion broke control with
  "job_status is not an int64". Add normalizeJobStatus() accepting int/int32/
  int64/float64/json.Number.
- rbac.yaml: mcp_operator role gains queue:control (the job-control handler
  also checks queue:control on the job's queue); add rbac_mcp_operator_test.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-08-01 09:54:44 +00:00
parent 35416d5cf4
commit e36ed63ca0
4 changed files with 116 additions and 4 deletions

View file

@ -171,4 +171,36 @@ roles:
domain:
- type: all
# mcp_operator: service-account role for the scheduler-agent MCP server
# (Phase 1, write operations). Same cluster visibility as mcp_readonly,
# plus queue:submit (scheduler_submit) and job:control (scheduler_cancel/
# hold/release). queue:control is required by the apiserver's job-control
# handler (jobs.go checks queue:control on the job's queue too), so it is
# granted here. An MCP server credential using this role can mutate job
# state, but still cannot manage other users/roles or cluster config.
mcp_operator:
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
- action: queue:submit
domain:
- type: all
- action: queue:control
domain:
- type: all
- action: job:control
domain:
- type: all

View file

@ -3011,10 +3011,14 @@ func validateJobControl(jobinfo utils.OrderMap, action string,
}
if rawStatus, exists := jobinfo.Data["job_status"]; exists {
if jobStatus, ok := rawStatus.(int64); ok {
db_job_status := int(jobStatus)
// job_status arrives as int (from store.Jobs), int64, or float64
// (from JSON decode paths). Normalize before use; the old `.(int64)`
// assertion broke control for every job whose status decoded as int
// or float64 ("job_status is not an int64").
if jobStatusInt, ok := normalizeJobStatus(rawStatus); ok {
db_job_status := jobStatusInt
job_status := models.JobStateStr(db_job_status)
jobStatus = int64(models.JOBSTAT[job_status])
jobStatus := int64(models.JOBSTAT[job_status])
if utils.IsControlActionAllowed(actionType, jobStatus, models.JobActionControlMap) {
if action == "kill" && signal != "" {
if !utils.IsControlActionAllowed(actionType, jobStatus, models.JobKillSignalControlMap) {
@ -3095,6 +3099,26 @@ func foundJob(jobid int64, array_idx int, jobinfos []utils.OrderMap) ([]utils.Or
}
}
// normalizeJobStatus converts a job_status value (int, int64, or float64 from
// JSON decode) into a plain int. Returns ok=false for unsupported types.
func normalizeJobStatus(v any) (int, bool) {
switch s := v.(type) {
case int:
return s, true
case int32:
return int(s), true
case int64:
return int(s), true
case float64:
return int(s), true
case json.Number:
if n, err := s.Int64(); err == nil {
return int(n), true
}
}
return 0, false
}
func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
logger.Debug("Enter processJobData() function", zap.Any("rows", rows))
for _, row := range rows {

View file

@ -0,0 +1,55 @@
package controllers
import (
"apiserver/store"
"os"
"path/filepath"
"testing"
)
// TestRBACYAML_McpOperatorRole_LoadsWritePermissions verifies the
// mcp_operator role (added for the scheduler-agent MCP server's service
// account, Phase 1) is present in the shipped config/rbac.yaml and grants the
// write permissions the Phase 1 tools need: queue:submit (scheduler_submit)
// and job:control (scheduler_cancel/hold/release) — all on domain type "all".
func TestRBACYAML_McpOperatorRole_LoadsWritePermissions(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_operator") {
t.Fatal("expected mcp_operator role to be loaded from config/rbac.yaml")
}
perms := store.GetRolePermissions("mcp_operator")
if len(perms) == 0 {
t.Fatal("mcp_operator should have at least one permission")
}
// Write permissions required by the Phase 1 tools must be present and
// scoped to "all" domains.
writePerms := map[string]bool{
"queue:submit": false,
"job:control": false,
}
for _, p := range perms {
if _, needed := writePerms[p.Action]; needed && p.DomainType == "all" {
writePerms[p.Action] = true
}
}
for action, found := range writePerms {
if !found {
t.Errorf("mcp_operator should grant %s with domain type all", action)
}
}
}

View file

@ -36,6 +36,7 @@ var WhitelistGETURL = []string{
var WhitelistPOSTURL = []string{
"/xce/v1/auth/logon", // logon
"/xce/v1/auth/logout", // logout
"/xce/v1/apikeys", // generate service-account API key (Phase 0)
"/xce/v1/hosts/control", // control hosts
"/xce/v1/hosts/select", // filter host with -R select string
"/xce/v1/queues/control", // control queues