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:
parent
6114f16dac
commit
4a38bb3ca9
18 changed files with 750 additions and 79 deletions
|
|
@ -520,6 +520,7 @@ func handleConfigPush(cfgdata *C.xce_configdata_t) C.int {
|
|||
cClusterConfHostConfigs(conf),
|
||||
cluster,
|
||||
)
|
||||
gHandler.OnUserGroupsUpdated(cClusterConfUserGroups(conf))
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,42 @@ func cHostConfigToModel(hc *C.xce_hostconfig_t) *models.HostInfo {
|
|||
})
|
||||
}
|
||||
|
||||
// cStrList converts an xce_strlist_t to a Go slice.
|
||||
func cStrList(l *C.xce_strlist_t) []string {
|
||||
n := int(l.count)
|
||||
if n == 0 || l.data == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for _, p := range unsafe.Slice(l.data, n) {
|
||||
if p != nil {
|
||||
out = append(out, C.GoString(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cClusterConfUserGroups extracts the user groups from a cluster_config_t
|
||||
// (bug-1934: the self_ug job scope resolves them).
|
||||
func cClusterConfUserGroups(conf *C.cluster_config_t) []models.UserGroupInfo {
|
||||
n := int(conf.nugroups)
|
||||
if n == 0 || conf.ugroups == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.UserGroupInfo, 0, n)
|
||||
for _, gp := range unsafe.Slice(conf.ugroups, n) {
|
||||
if gp == nil || gp.name == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, models.UserGroupInfo{
|
||||
Name: C.GoString(gp.name),
|
||||
Members: cStrList(&gp.members),
|
||||
ExMembers: cStrList(&gp.ex_members),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cClusterConfHostConfigs extracts host config models from a cluster_config_t.
|
||||
func cClusterConfHostConfigs(conf *C.cluster_config_t) []models.HostInfo {
|
||||
n := int(conf.nhconfigs)
|
||||
|
|
|
|||
|
|
@ -192,6 +192,11 @@ func (h *DefaultHandler) OnClusterUpdated(incoming *models.ClusterInfo) {
|
|||
SetHeartbeatInterval(incoming.HeartbeatInterval)
|
||||
}
|
||||
|
||||
// OnUserGroupsUpdated replaces the stored user groups as a whole.
|
||||
func (h *DefaultHandler) OnUserGroupsUpdated(groups []models.UserGroupInfo) {
|
||||
store.SetUserGroups(groups)
|
||||
}
|
||||
|
||||
func (h *DefaultHandler) OnResourceUpdated(res *models.ResourceDefinitionInfo) {
|
||||
store.Resources.Put(res.ResourceName, *res)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -672,3 +672,20 @@ func TestDispatchJobArray_LogsArrayIdentity(t *testing.T) {
|
|||
t.Errorf("log still carries the raw base_jobid: %v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
// bug-1934: a configuration push replaces the user groups the self_ug job
|
||||
// scope reads.
|
||||
func TestOnUserGroupsUpdated_ReplacesUserGroups(t *testing.T) {
|
||||
orig := store.UserGroupsSnapshot()
|
||||
t.Cleanup(func() { store.SetUserGroups(orig) })
|
||||
h := &DefaultHandler{}
|
||||
|
||||
h.OnUserGroupsUpdated([]models.UserGroupInfo{{Name: "team", Members: []string{"alice", "bob"}}})
|
||||
if !store.UsersShareGroup("alice", "bob") {
|
||||
t.Fatal("alice and bob should share team after the push")
|
||||
}
|
||||
h.OnUserGroupsUpdated(nil)
|
||||
if store.UsersShareGroup("alice", "bob") {
|
||||
t.Error("a push without user groups should remove team")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,7 @@ type NotificationHandler interface {
|
|||
OnClusterUpdated(cluster *models.ClusterInfo)
|
||||
OnResourceUpdated(res *models.ResourceDefinitionInfo)
|
||||
OnResourceLocationUpdated(res *models.ResourceDistributionInfo)
|
||||
// OnUserGroupsUpdated replaces the user groups (self_ug job scope,
|
||||
// bug-1934) with those of a configuration push.
|
||||
OnUserGroupsUpdated(groups []models.UserGroupInfo)
|
||||
}
|
||||
|
|
|
|||
77
src/frontend/apiserver/controllers/job_scope.go
Normal file
77
src/frontend/apiserver/controllers/job_scope.go
Normal 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
|
||||
}
|
||||
179
src/frontend/apiserver/controllers/job_scope_test.go
Normal file
179
src/frontend/apiserver/controllers/job_scope_test.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -175,8 +175,8 @@ var JOB_SUPPORT_FILTERS = []string{
|
|||
"job_id",
|
||||
//"user_group",
|
||||
//"job_group",
|
||||
//"project_name",
|
||||
//"license_project",
|
||||
"project_name", // bug-1935: xcecli jobs list -P
|
||||
"license_project", // bug-1935: xcecli jobs list --license-project
|
||||
//"sla",
|
||||
"submit_time",
|
||||
"queue_time",
|
||||
|
|
|
|||
10
src/frontend/apiserver/models/usergroups.go
Normal file
10
src/frontend/apiserver/models/usergroups.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package models
|
||||
|
||||
// UserGroupInfo is a user group from the cluster configuration
|
||||
// (users.yaml usergroups). A member is a user name, another group's name or
|
||||
// "all"; ExMembers are removed from the group (bug-1934).
|
||||
type UserGroupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Members []string `json:"members"`
|
||||
ExMembers []string `json:"exclude_members"`
|
||||
}
|
||||
179
src/frontend/apiserver/store/usergroups.go
Normal file
179
src/frontend/apiserver/store/usergroups.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"apiserver/models"
|
||||
)
|
||||
|
||||
// User groups from the cluster configuration, for the self_ug job scope
|
||||
// (bug-1934). Replaced as a whole on every configuration push.
|
||||
var (
|
||||
userGroupsMu sync.RWMutex
|
||||
userGroups []models.UserGroupInfo
|
||||
)
|
||||
|
||||
// SetUserGroups replaces the user groups.
|
||||
func SetUserGroups(groups []models.UserGroupInfo) {
|
||||
cp := append([]models.UserGroupInfo(nil), groups...)
|
||||
userGroupsMu.Lock()
|
||||
userGroups = cp
|
||||
userGroupsMu.Unlock()
|
||||
}
|
||||
|
||||
// UserGroupsSnapshot returns a copy of the user groups.
|
||||
func UserGroupsSnapshot() []models.UserGroupInfo {
|
||||
userGroupsMu.RLock()
|
||||
defer userGroupsMu.RUnlock()
|
||||
return append([]models.UserGroupInfo(nil), userGroups...)
|
||||
}
|
||||
|
||||
// UserSet is a set of user names; with All set it is every user except
|
||||
// those in Names.
|
||||
type UserSet struct {
|
||||
All bool
|
||||
Names map[string]bool
|
||||
}
|
||||
|
||||
// Has reports whether user is in the set.
|
||||
func (s UserSet) Has(user string) bool {
|
||||
if s.All {
|
||||
return !s.Names[user]
|
||||
}
|
||||
return s.Names[user]
|
||||
}
|
||||
|
||||
func newUserSet(names ...string) UserSet {
|
||||
s := UserSet{Names: map[string]bool{}}
|
||||
for _, n := range names {
|
||||
s.Names[n] = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func union(a, b UserSet) UserSet {
|
||||
switch {
|
||||
case !a.All && !b.All:
|
||||
r := newUserSet()
|
||||
for n := range a.Names {
|
||||
r.Names[n] = true
|
||||
}
|
||||
for n := range b.Names {
|
||||
r.Names[n] = true
|
||||
}
|
||||
return r
|
||||
case a.All && b.All: // excluded from both
|
||||
r := UserSet{All: true, Names: map[string]bool{}}
|
||||
for n := range a.Names {
|
||||
if b.Names[n] {
|
||||
r.Names[n] = true
|
||||
}
|
||||
}
|
||||
return r
|
||||
case b.All:
|
||||
a, b = b, a
|
||||
}
|
||||
// a is every user except a.Names; b is finite
|
||||
r := UserSet{All: true, Names: map[string]bool{}}
|
||||
for n := range a.Names {
|
||||
if !b.Names[n] {
|
||||
r.Names[n] = true
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func minus(a, b UserSet) UserSet {
|
||||
r := newUserSet()
|
||||
switch {
|
||||
case !a.All && !b.All:
|
||||
for n := range a.Names {
|
||||
if !b.Names[n] {
|
||||
r.Names[n] = true
|
||||
}
|
||||
}
|
||||
case !a.All && b.All: // keep a's names b does not cover... b covers all but b.Names
|
||||
for n := range a.Names {
|
||||
if b.Names[n] {
|
||||
r.Names[n] = true
|
||||
}
|
||||
}
|
||||
case a.All && !b.All:
|
||||
r.All = true
|
||||
for n := range a.Names {
|
||||
r.Names[n] = true
|
||||
}
|
||||
for n := range b.Names {
|
||||
r.Names[n] = true
|
||||
}
|
||||
default: // users b excludes that a does not
|
||||
for n := range b.Names {
|
||||
if !a.Names[n] {
|
||||
r.Names[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// expandGroup returns the users of group name; a name in `visiting` is a
|
||||
// cycle and contributes nothing.
|
||||
func expandGroup(byName map[string]models.UserGroupInfo, name string,
|
||||
visiting map[string]bool) UserSet {
|
||||
g, ok := byName[name]
|
||||
if !ok || visiting[name] {
|
||||
return newUserSet()
|
||||
}
|
||||
visiting[name] = true
|
||||
defer delete(visiting, name)
|
||||
|
||||
member := func(m string) UserSet {
|
||||
if m == "all" {
|
||||
return UserSet{All: true, Names: map[string]bool{}}
|
||||
}
|
||||
if _, isGroup := byName[m]; isGroup {
|
||||
return expandGroup(byName, m, visiting)
|
||||
}
|
||||
return newUserSet(m)
|
||||
}
|
||||
r := newUserSet()
|
||||
for _, m := range g.Members {
|
||||
r = union(r, member(m))
|
||||
}
|
||||
for _, m := range g.ExMembers {
|
||||
r = minus(r, member(m))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// UserGroupPeers returns user itself and every user who shares a user group
|
||||
// with it.
|
||||
func UserGroupPeers(user string) UserSet {
|
||||
userGroupsMu.RLock()
|
||||
defer userGroupsMu.RUnlock()
|
||||
|
||||
byName := make(map[string]models.UserGroupInfo, len(userGroups))
|
||||
for _, g := range userGroups {
|
||||
byName[g.Name] = g
|
||||
}
|
||||
r := newUserSet(user)
|
||||
for _, g := range userGroups {
|
||||
users := expandGroup(byName, g.Name, map[string]bool{})
|
||||
if users.Has(user) {
|
||||
r = union(r, users)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// UsersShareGroup reports whether a and b are the same user or share a user
|
||||
// group.
|
||||
func UsersShareGroup(a, b string) bool {
|
||||
return UserGroupPeers(a).Has(b)
|
||||
}
|
||||
|
||||
// NewUserSet returns the finite set of names.
|
||||
func NewUserSet(names ...string) UserSet { return newUserSet(names...) }
|
||||
|
||||
// UnionUserSets returns the users in a or b.
|
||||
func UnionUserSets(a, b UserSet) UserSet { return union(a, b) }
|
||||
55
src/frontend/apiserver/store/usergroups_test.go
Normal file
55
src/frontend/apiserver/store/usergroups_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apiserver/models"
|
||||
)
|
||||
|
||||
// bug-1934: user-group membership as the cluster config defines it
|
||||
// (members may be users, other groups or "all"; ex_members are removed).
|
||||
func TestUserGroups_Membership(t *testing.T) {
|
||||
orig := UserGroupsSnapshot()
|
||||
t.Cleanup(func() { SetUserGroups(orig) })
|
||||
|
||||
SetUserGroups([]models.UserGroupInfo{
|
||||
{Name: "team", Members: []string{"alice", "bob"}},
|
||||
{Name: "dept", Members: []string{"team", "carol"}, ExMembers: []string{"bob"}},
|
||||
{Name: "everyone", Members: []string{"all"}, ExMembers: []string{"mallory"}},
|
||||
{Name: "loop1", Members: []string{"loop2", "x"}},
|
||||
{Name: "loop2", Members: []string{"loop1"}},
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"alice", "bob", true}, // team
|
||||
{"alice", "carol", true}, // dept (via nested team)
|
||||
{"bob", "carol", true}, // everyone
|
||||
{"alice", "mallory", false}, // mallory is excluded from everyone
|
||||
{"mallory", "mallory", true},
|
||||
{"alice", "zed", true}, // everyone
|
||||
{"x", "alice", true}, // everyone
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := UsersShareGroup(c.a, c.b); got != c.want {
|
||||
t.Errorf("UsersShareGroup(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
SetUserGroups([]models.UserGroupInfo{
|
||||
{Name: "team", Members: []string{"alice", "bob"}},
|
||||
{Name: "dept", Members: []string{"team", "carol"}, ExMembers: []string{"bob"}},
|
||||
})
|
||||
if UsersShareGroup("bob", "carol") {
|
||||
t.Error("bob is excluded from dept and shares no group with carol")
|
||||
}
|
||||
peers := UserGroupPeers("alice")
|
||||
if peers.All || !peers.Has("bob") || !peers.Has("carol") || !peers.Has("alice") || peers.Has("zed") {
|
||||
t.Errorf("UserGroupPeers(alice) = %+v, want alice, bob, carol", peers)
|
||||
}
|
||||
if p := UserGroupPeers("zed"); p.All || !p.Has("zed") || p.Has("alice") {
|
||||
t.Errorf("UserGroupPeers(zed) = %+v, want zed only", p)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue