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:
|
domain:
|
||||||
- type: all
|
- 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 rawStatus, exists := jobinfo.Data["job_status"]; exists {
|
||||||
if jobStatus, ok := rawStatus.(int64); ok {
|
// job_status arrives as int (from store.Jobs), int64, or float64
|
||||||
db_job_status := int(jobStatus)
|
// (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)
|
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 utils.IsControlActionAllowed(actionType, jobStatus, models.JobActionControlMap) {
|
||||||
if action == "kill" && signal != "" {
|
if action == "kill" && signal != "" {
|
||||||
if !utils.IsControlActionAllowed(actionType, jobStatus, models.JobKillSignalControlMap) {
|
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 {
|
func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
|
||||||
logger.Debug("Enter processJobData() function", zap.Any("rows", rows))
|
logger.Debug("Enter processJobData() function", zap.Any("rows", rows))
|
||||||
for _, row := range 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{
|
var WhitelistPOSTURL = []string{
|
||||||
"/xce/v1/auth/logon", // logon
|
"/xce/v1/auth/logon", // logon
|
||||||
"/xce/v1/auth/logout", // logout
|
"/xce/v1/auth/logout", // logout
|
||||||
|
"/xce/v1/apikeys", // generate service-account API key (Phase 0)
|
||||||
"/xce/v1/hosts/control", // control hosts
|
"/xce/v1/hosts/control", // control hosts
|
||||||
"/xce/v1/hosts/select", // filter host with -R select string
|
"/xce/v1/hosts/select", // filter host with -R select string
|
||||||
"/xce/v1/queues/control", // control queues
|
"/xce/v1/queues/control", // control queues
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue