diff --git a/src/frontend/apiserver/config/rbac.yaml b/src/frontend/apiserver/config/rbac.yaml index 39ee7d82..4f76abcb 100644 --- a/src/frontend/apiserver/config/rbac.yaml +++ b/src/frontend/apiserver/config/rbac.yaml @@ -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 diff --git a/src/frontend/apiserver/controllers/jobs.go b/src/frontend/apiserver/controllers/jobs.go index 02359f59..1e20c807 100644 --- a/src/frontend/apiserver/controllers/jobs.go +++ b/src/frontend/apiserver/controllers/jobs.go @@ -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 { diff --git a/src/frontend/apiserver/controllers/rbac_mcp_operator_test.go b/src/frontend/apiserver/controllers/rbac_mcp_operator_test.go new file mode 100644 index 00000000..8db05855 --- /dev/null +++ b/src/frontend/apiserver/controllers/rbac_mcp_operator_test.go @@ -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) + } + } +} diff --git a/src/frontend/apiserver/middleware/whitelist.go b/src/frontend/apiserver/middleware/whitelist.go index 63ee1684..fa68b5cf 100644 --- a/src/frontend/apiserver/middleware/whitelist.go +++ b/src/frontend/apiserver/middleware/whitelist.go @@ -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