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

@ -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
}
}