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:
parent
35416d5cf4
commit
e36ed63ca0
4 changed files with 116 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
55
src/frontend/apiserver/controllers/rbac_mcp_operator_test.go
Normal file
55
src/frontend/apiserver/controllers/rbac_mcp_operator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue