fix(apiserver): enforce self_ug job scopes and accept project filters (bug-1934, bug-1935)

bug-1934: every job-permission check read a single scope and compared it
only with "self", so a job:view or job:control grant scoped to self_ug acted
like "all" (list, get by ID, control), and self + self_ug collapsed to self.
userJobScope() now collects all of a user's grants of a job permission
before deduplication and adds them up (all; self; self_ug = the user and
everyone sharing a user group). The job list appends a submit_user filter
for narrower scopes, get-by-ID keeps covered rows in a new slice (removing
from the ranged slice skipped rows) and answers 403, both control paths
and job output check the owner against the scope. User groups now reach
the apiserver: the config push converts cluster_config_t.ugroups and
OnUserGroupsUpdated() replaces the new store (nested groups, all,
exclusions). The default basic_user now also sees its user-group mates'
jobs, as its configuration says.

bug-1935: project_name and license_project are accepted job filters, so
xcecli jobs list -P works.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 06:56:57 +00:00
parent 6114f16dac
commit 4a38bb3ca9
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
18 changed files with 750 additions and 79 deletions

View file

@ -0,0 +1,77 @@
package controllers
import (
"errors"
"sort"
"strings"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
)
// jobScope is what all of a user's grants of one job permission cover
// (bug-1934): every job (scope all), or the jobs of a set of owners (scope
// self: the user; scope self_ug: the user and everyone sharing a user group
// with them). Grants add up; a scope type the job object does not support
// covers nothing.
type jobScope struct {
all bool
owners store.UserSet
}
// userJobScope collects every grant of perm the user holds. It reads the
// grants before DeduplicatePermissions, which keeps one scope per
// permission and would drop the others.
func userJobScope(user, perm string) (jobScope, error) {
s := jobScope{owners: store.NewUserSet()}
found := false
for _, g := range collectUserPermissionsWithType(user) {
if g.Permission != perm {
continue
}
found = true
switch strings.ToLower(g.DomainType) {
case models.DomainTypeAll:
s.all = true
case models.DomainTypeSelf:
s.owners = store.UnionUserSets(s.owners, store.NewUserSet(user))
case models.DomainTypeSelfUG:
s.owners = store.UnionUserSets(s.owners, store.UserGroupPeers(user))
}
}
if !found {
return s, errors.New("no permission")
}
return s, nil
}
// covers reports whether the scope covers a job owned by owner.
func (s jobScope) covers(owner string) bool {
return s.all || s.owners.Has(owner)
}
// ownerFilter returns the job-query condition that keeps only covered
// owners, or ok == false when the scope covers no one.
func (s jobScope) ownerFilter() (f utils.FilterCondition, needed, ok bool) {
if s.all {
return f, false, true
}
names := make([]string, 0, len(s.owners.Names))
for n := range s.owners.Names {
names = append(names, n)
}
sort.Strings(names)
if s.owners.All {
if len(names) == 0 {
return f, false, true
}
return utils.FilterCondition{Field: "submit_user", Operator: "notin",
Value: "(" + strings.Join(names, ",") + ")"}, true, true
}
if len(names) == 0 {
return f, false, false
}
return utils.FilterCondition{Field: "submit_user", Operator: "in",
Value: "(" + strings.Join(names, ",") + ")"}, true, true
}

View file

@ -0,0 +1,179 @@
package controllers
import (
"net/http"
"testing"
"apiserver/models"
"apiserver/store"
)
// jobScopeTestRBAC: alice's job permissions reach only her user groups'
// jobs (self_ug); dave holds self and self_ug together (bug-1934).
const jobScopeTestRBAC = `
permissions:
- job:view
- job:control
- queue:submit
- queue:control
- queue:view
roles:
group_member:
permissions:
- action: job:view
domain:
- type: self_ug
- action: job:control
domain:
- type: self_ug
- action: queue:control
domain:
- type: all
- action: queue:view
domain:
- type: all
self_and_group:
permissions:
- action: job:view
domain:
- type: self
- type: self_ug
- action: job:control
domain:
- type: self
- type: self_ug
- action: queue:control
domain:
- type: all
- action: queue:view
domain:
- type: all
users:
alice:
roles:
- name: group_member
domain:
- type: all
dave:
roles:
- name: self_and_group
domain:
- type: all
`
// jobScopeJobs are one RUN job per owner; alice and bob share user group
// "team", carol and dave are in no group with alice, dave and erin share
// "pair".
type jobScopeJobs struct{ alice, bob, carol, dave, erin uint64 }
func seedJobScope(f *handlerFixture) jobScopeJobs {
f.t.Helper()
store.SetUserGroups([]models.UserGroupInfo{
{Name: "team", Members: []string{"alice", "bob"}},
{Name: "pair", Members: []string{"dave", "erin"}},
})
base := uint64(3)<<40 | uint64(1)<<20
j := jobScopeJobs{alice: base + 1, bob: base + 2, carol: base + 3, dave: base + 4, erin: base + 5}
for id, owner := range map[uint64]string{j.alice: "alice", j.bob: "bob",
j.carol: "carol", j.dave: "dave", j.erin: "erin"} {
f.seedJob(models.JobInfo{Unique_JobID: int64(id), JobID: int64(id),
JobName: owner + "_job", JobStatus: models.JOBSTAT["RUN"], SubmitUser: owner})
}
return j
}
// A self_ug grant lists the jobs of the requester's user groups and no
// one else's, whatever submit_user the request names.
func TestJobScope_SelfUGListCoversGroupOnly(t *testing.T) {
f := newHandlerFixtureAs(t, jobScopeTestRBAC, "alice", "group_member")
j := seedJobScope(f)
assertIDs(t, "submit_user all", rowIDs(t, f.listJobs("{submit_user:eq:all}")),
[]uint64{j.alice, j.bob})
assertIDs(t, "submit_user bob (group mate)", rowIDs(t, f.listJobs("{submit_user:eq:bob}")),
[]uint64{j.bob})
assertIDs(t, "submit_user carol (outside the group)",
rowIDs(t, f.listJobs("{submit_user:eq:carol}")), nil)
assertIDs(t, "no submit_user: own jobs", rowIDs(t, f.listJobs("{job_status:eq:RUN}")),
[]uint64{j.alice})
}
// GET /jobs/{ids} returns only covered jobs.
func TestJobScope_SelfUGGetByID(t *testing.T) {
f := newHandlerFixtureAs(t, jobScopeTestRBAC, "alice", "group_member")
j := seedJobScope(f)
if w := f.getJobs(jobIDString(j.carol)); w.Code != http.StatusForbidden {
t.Errorf("carol's job: status = %d, want 403; body: %s", w.Code, w.Body.String())
}
assertIDs(t, "bob's job", rowIDs(t, f.getJobs(jobIDString(j.bob))), []uint64{j.bob})
assertIDs(t, "bob's and carol's jobs",
rowIDs(t, f.getJobs(jobIDString(j.bob)+","+jobIDString(j.carol))), []uint64{j.bob})
}
// Job output follows the same scope: no group, no output.
func TestJobScope_SelfUGOutput(t *testing.T) {
f := newHandlerFixtureAs(t, jobScopeTestRBAC, "alice", "group_member")
j := seedJobScope(f)
if w := f.do(http.MethodGet, "/xce/v1/jobs/"+jobIDString(j.carol)+"/output", nil); w.Code != http.StatusForbidden {
t.Errorf("carol's output: status = %d, want 403; body: %s", w.Code, w.Body.String())
}
if w := f.do(http.MethodGet, "/xce/v1/jobs/"+jobIDString(j.bob)+"/output", nil); w.Code == http.StatusForbidden {
t.Errorf("bob's output: status = 403, want access; body: %s", w.Body.String())
}
}
// job:control with self_ug controls group mates' jobs only.
func TestJobScope_SelfUGControl(t *testing.T) {
f := newHandlerFixtureAs(t, jobScopeTestRBAC, "alice", "group_member")
j := seedJobScope(f)
if w := f.controlByID(jobIDString(j.carol), "kill"); w.Code != http.StatusForbidden {
t.Errorf("kill carol's job: status = %d, want 403; body: %s", w.Code, w.Body.String())
}
if got := f.dispatchedIDs(); len(got) != 0 {
t.Fatalf("carol's job was dispatched: %v", got)
}
if w := f.controlByID(jobIDString(j.bob), "kill"); w.Code != http.StatusOK {
t.Fatalf("kill bob's job: status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if got := f.dispatchedIDs(); len(got) != 1 || got[0] != j.bob {
t.Errorf("dispatched = %v, want [%d]", got, j.bob)
}
if w := f.controlByFilter(models.JobControlRequest{Action: "kill",
Filter: models.JobControlFilter{Users: "carol"}}); w.Code != http.StatusForbidden {
t.Errorf("filter kill of carol's jobs: status = %d, want 403; body: %s", w.Code, w.Body.String())
}
if got := f.dispatchedIDs(); len(got) != 1 {
t.Errorf("filter control dispatched carol's job: %v", got)
}
}
// Several scopes of one permission add up: self and self_ug together cover
// the requester's own jobs and the group's.
func TestJobScope_GrantsAddUp(t *testing.T) {
f := newHandlerFixtureAs(t, jobScopeTestRBAC, "dave", "self_and_group")
j := seedJobScope(f)
assertIDs(t, "submit_user all", rowIDs(t, f.listJobs("{submit_user:eq:all}")),
[]uint64{j.dave, j.erin})
assertIDs(t, "erin's job by id", rowIDs(t, f.getJobs(jobIDString(j.erin))), []uint64{j.erin})
}
// bug-1935: the project and license-project filters xcecli sends are
// accepted and select by the job's project.
func TestJobHandler_ProjectFilters(t *testing.T) {
f := newHandlerFixture(t)
base := uint64(4)<<40 | uint64(1)<<20
f.seedJob(models.JobInfo{Unique_JobID: int64(base + 1), JobID: int64(base + 1),
JobStatus: models.JOBSTAT["RUN"], ProjectName: "eng", LicenseProject: "lic1"})
f.seedJob(models.JobInfo{Unique_JobID: int64(base + 2), JobID: int64(base + 2),
JobStatus: models.JOBSTAT["RUN"], ProjectName: "ops", LicenseProject: "lic2"})
assertIDs(t, "project_name eng", rowIDs(t, f.listJobs("{project_name:eq:eng}")),
[]uint64{base + 1})
assertIDs(t, "license_project lic2", rowIDs(t, f.listJobs("{license_project:eq:lic2}")),
[]uint64{base + 2})
}

View file

@ -157,13 +157,18 @@ func (j JobController) GetJobsInfo(ctx *gin.Context) {
}
logger.Debug("get job info accessQueues", zap.Any("accessQueues", accessQueues),
zap.Any("permission", permission))
// check job:view permission
permission, err = GetRequireResourcePermission(submitUser, "job:view")
logger.Debug("get job info for job:view permission:", zap.Any("permission", permission))
// check job:view permission: every grant counts (bug-1934)
scope, err := userJobScope(submitUser, "job:view")
logger.Debug("get job info for job:view scope:", zap.Any("scope", scope))
if err != nil || len(accessQueues) == 0 {
ReturnError(ctx, common.ERROR_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
ownerFilter, needOwnerFilter, anyOwner := scope.ownerFilter()
if !anyOwner {
ReturnError(ctx, common.ERROR_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
// if submit_user is not specified, use query request submission user
var specify_user bool = false
@ -175,16 +180,17 @@ func (j JobController) GetJobsInfo(ctx *gin.Context) {
if filter.Value == models.DomainTypeAll {
delindex = i
}
if permission.DomainType != models.DomainTypeSelf {
submitUser = filter.Value
}
break
}
}
if delindex != -1 {
filters = append(filters[:delindex], filters[delindex+1:]...)
}
if !specify_user || permission.DomainType == models.DomainTypeSelf {
// a narrower scope keeps only the jobs of the owners it covers
if needOwnerFilter {
filters = append(filters, ownerFilter)
}
if !specify_user {
logger.Debug("submit_user is not specified in query filter, add submit_user into filter: filter=[{submit_user:eq:" + submitUser)
filters = append(filters, utils.FilterCondition{
Field: "submit_user",
@ -743,27 +749,29 @@ func (j JobController) GetJobByIDs(ctx *gin.Context) {
} // end for jobid
logger.Debug("the specific jobid query reply after merge:", zap.Any("jqueryReply", mergeJqueryReply))
// check rbac
// check rbac: every job:view grant counts (bug-1934); rows are kept in
// a new slice, since removing them from the one being ranged over
// skipped the row after each removal
removeFlag := false
for i, row := range mergeJqueryReply {
jobPermission, err := GetRequireResourcePermission(logonUser, "job:view")
if err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
queuePermission, err := GetRequireResourcePermission(logonUser, "queue:view")
if err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
jobScope, err := userJobScope(logonUser, "job:view")
if err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
queuePermission, err := GetRequireResourcePermission(logonUser, "queue:view")
if err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
visible := mergeJqueryReply[:0:0]
for _, row := range mergeJqueryReply {
if job_owner, exist := row.Data["submit_user"]; exist {
if jobPermission.DomainType == models.DomainTypeSelf {
if logonUser != job_owner.(string) {
removeFlag = true
mergeJqueryReply = append(mergeJqueryReply[:i], mergeJqueryReply[i+1:]...)
}
owner, _ := job_owner.(string)
if !jobScope.covers(owner) {
removeFlag = true
continue
}
}
if typeValue != "array_stats" {
@ -773,15 +781,16 @@ func (j JobController) GetJobByIDs(ctx *gin.Context) {
permissionQueueValues := queuePermission.DomainValues
if !utils.ContainsField(permissionQueueValues, queueName) {
removeFlag = true
mergeJqueryReply = append(mergeJqueryReply[:i], mergeJqueryReply[i+1:]...)
continue
}
}
}
}
visible = append(visible, row)
}
mergeJqueryReply = visible
if removeFlag && len(mergeJqueryReply) == 0 {
ReturnError(ctx, common.ERROR_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
@ -2181,18 +2190,17 @@ func (j JobController) ControlJobByID(ctx *gin.Context) {
// check rbac
if job_owner, exist := jobInfo.Data["submit_user"]; exist {
jobPermission, err := GetRequireResourcePermission(submitter, "job:control")
jobScope, err := userJobScope(submitter, "job:control")
if err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
if jobPermission.DomainType == models.DomainTypeSelf {
if job_owner.(string) != submitter {
logger.Error("Permission denied", zap.String("perm", "job:control"), zap.String("logonUser", submitter))
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
// bug-1934: every job:control grant counts
if owner, _ := job_owner.(string); !jobScope.covers(owner) {
logger.Error("Permission denied", zap.String("perm", "job:control"), zap.String("logonUser", submitter))
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
}
@ -2448,18 +2456,17 @@ func (j JobController) ControlJob(ctx *gin.Context) {
// check rbac begin
if job_owner, exist := job.Data["submit_user"]; exist {
jobPermission, err := GetRequireResourcePermission(submitter, "job:control")
jobScope, err := userJobScope(submitter, "job:control")
if err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
if jobPermission.DomainType == models.DomainTypeSelf {
if job_owner.(string) != submitter {
logger.Error("Permission denied", zap.String("perm", "job:control"), zap.String("logonUser", submitter))
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
// bug-1934: every job:control grant counts
if owner, _ := job_owner.(string); !jobScope.covers(owner) {
logger.Error("Permission denied", zap.String("perm", "job:control"), zap.String("logonUser", submitter))
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}
}

View file

@ -125,6 +125,13 @@ type handlerFixture struct {
// restored in t.Cleanup.
func newHandlerFixture(t *testing.T) *handlerFixture {
t.Helper()
return newHandlerFixtureAs(t, handlerTestRBAC, handlerTestUser, "handler_tester")
}
// newHandlerFixtureAs is newHandlerFixture with the RBAC config and the
// signed user and role given (bug-1934: tests of narrower job scopes).
func newHandlerFixtureAs(t *testing.T, rbac, user, role string) *handlerFixture {
t.Helper()
// Save every global this fixture (or setupRBACTest) replaces.
origJobs, origArrayJobs, origQueues, origHosts := store.Jobs, store.ArrayJobs, store.Queues, store.Hosts
@ -134,6 +141,7 @@ func newHandlerFixture(t *testing.T) *handlerFixture {
origSubmit, origControl := submitJob, controlJob
origJobCfg, origLog, origAuthCfg := utils.JobYamlConfig, utils.Log, utils.AuthenticationYamlConfig
origRBAC := rbacConfig
origUserGroups := store.UserGroupsSnapshot()
t.Cleanup(func() {
store.Jobs, store.ArrayJobs, store.Queues, store.Hosts = origJobs, origArrayJobs, origQueues, origHosts
@ -143,15 +151,16 @@ func newHandlerFixture(t *testing.T) *handlerFixture {
submitJob, controlJob = origSubmit, origControl
utils.JobYamlConfig, utils.Log, utils.AuthenticationYamlConfig = origJobCfg, origLog, origAuthCfg
rbacConfig = origRBAC
store.SetUserGroups(origUserGroups)
})
// Auth/RBAC (replaces the stores with empty ones), then config.
setupRBACTest(t, handlerTestRBAC)
setupRBACTest(t, rbac)
utils.JobYamlConfig = &utils.JobConfig{MaxArraySize: 1000}
f := &handlerFixture{
t: t,
token: signTestJWT(handlerTestUser, "handler_tester", time.Now().Add(time.Hour)),
token: signTestJWT(user, role, time.Now().Add(time.Hour)),
nextID: handlerTestOrdinaryBase,
nextArray: handlerTestArrayBase,
}
@ -202,6 +211,7 @@ func newHandlerFixture(t *testing.T) *handlerFixture {
g := r.Group("/xce/v1/jobs")
g.GET("", jc.GetJobsInfo)
g.GET("/:jobids", jc.GetJobByIDs)
g.GET("/:jobids/output", jc.GetJobOutput)
g.POST("/create", jc.CreateJob)
g.POST("/:jobids/control", jc.ControlJobByID)
g.POST("/:jobids/restart", jc.RestartJobByID)

View file

@ -88,10 +88,11 @@ func (j JobController) GetJobOutput(ctx *gin.Context) {
return
}
// Check ownership: users with domain_type=all can view any job
permission, perr := GetRequireResourcePermission(submitUser, "job:view")
if perr != nil || permission.DomainType != models.DomainTypeAll {
if job.SubmitUser != submitUser {
// Check ownership: the owner may always read; anyone else needs a
// job:view grant covering the job (bug-1934)
if job.SubmitUser != submitUser {
scope, perr := userJobScope(submitUser, "job:view")
if perr != nil || !scope.covers(job.SubmitUser) {
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
return
}

View file

@ -168,6 +168,25 @@ func GetBuiltinRolePermissions(roleName string) []Permission {
// get all user permission,
// if user not exist in store, use basic_user permission
func GetUserAllPermissionsWithType(userName string) []models.RolePermission {
perms, fromRoles := collectUserPermissions(userName)
if !fromRoles {
return perms
}
permissionListsType := DeduplicatePermissions(perms)
logger.Debug("case 4 DeduplicatePermissions", zap.Any("permissionListsType", permissionListsType))
return permissionListsType
}
// collectUserPermissionsWithType returns every grant of the user, one entry
// per scope, before DeduplicatePermissions (bug-1934: job scopes add up).
func collectUserPermissionsWithType(userName string) []models.RolePermission {
perms, _ := collectUserPermissions(userName)
return perms
}
// collectUserPermissions returns the user's grants; fromRoles is false when
// the user has no roles and gets the builtin basic_user grants.
func collectUserPermissions(userName string) ([]models.RolePermission, bool) {
userRoles := store.GetUserRoleNames(userName)
if len(userRoles) == 0 {
// case 1: user not in store, use basic_user
@ -184,7 +203,7 @@ func GetUserAllPermissionsWithType(userName string) []models.RolePermission {
}
}
logger.Debug("case 1, assign basic_user permission to unregistered user")
return permissionTransform
return permissionTransform, false
}
builtInRoles := utils.IncludeBuiltinRoles(userRoles)
@ -212,9 +231,7 @@ func GetUserAllPermissionsWithType(userName string) []models.RolePermission {
permissionListsType = append(permissionListsType, GetRoleAllPermissionsWithType(userRole)...)
}
logger.Debug("case 3, before remove duplicated permissions", zap.Any("permissionListsType", permissionListsType))
permissionListsType = DeduplicatePermissions(permissionListsType)
logger.Debug("case 4 DeduplicatePermissions", zap.Any("permissionListsType", permissionListsType))
return permissionListsType
return permissionListsType, true
}
func GetRoleAllPermissionsWithType(roleName string) []models.RolePermission {