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

@ -17761,10 +17761,10 @@
"timestamp": "2026-10-10T04:31:25Z",
"last_seen": "2026-10-10T04:31:25Z",
"occurrences": 1,
"error_message": "apiserver treats a job:view grant scoped only to self_ug as all: user sees every user's jobs\n\n**Observed:** (source reading, not yet run) a role whose `job:view` grant is\nscoped only to `self_ug` can see every user's jobs. The job-list handler\n(`src/frontend/apiserver/controllers/jobs.go`) only distinguishes\n`DomainType == self`:\n```go\nif permission.DomainType != models.DomainTypeSelf {\n submitUser = filter.Value // jobs.go:178, caller-chosen user\n}\n...\nif !specify_user || permission.DomainType == models.DomainTypeSelf { // :187\n...\nif jobPermission.DomainType == models.DomainTypeSelf { // :762, per-row owner filter\n```\nso `self_ug` falls through every check like `all`: a `submit_user` filter\nnaming any user (or `all`) is honoured and no row is removed.\n`GetRequireResourcePermission()` (`controllers/rbac_check.go:323-346`)\nreturns only the first grant matching the permission, so for the default\n`basic_user` (`config/rbac.yaml:37-40`, `job:view` with `self` then\n`self_ug`) the `self` entry wins and `self_ug` is silently ignored.\n`self_ug` is accepted by role validation (`controllers/roles.go:697-700`)\nand listed as a supported job scope (`models/roles.go:70`), but nothing\nresolves the user's user groups for it.\n\n**Expected:** a `self_ug` job grant covers exactly the jobs whose owner shares\na user group with the requester; a grant list with several scopes covers the\nunion of them; anything else is rejected or filtered out.\n**Impact:** security (authorization bypass). Any role an admin creates with\n`job:view` (or another job permission, unchecked) scoped to `self_ug` grants\nvisibility of all users' jobs. The default `basic_user` is not exposed (its\n`self` grant is matched first) but never gets the group visibility its\nconfig promises. Other job permissions using the same single-grant lookup\nwere not checked yet.\n**Found by:** review, during `/kiro-spec-requirements project-config`\n(2026-10-10, RBAC survey for project-scoped job permissions).",
"error_message": "apiserver treats a job:view grant scoped only to self_ug as all: user sees every user's jobs\n\n**Observed:** (source reading, not yet run) a role whose `job:view` grant is\nscoped only to `self_ug` can see every user's jobs. The job-list handler\n(`src/frontend/apiserver/controllers/jobs.go`) only distinguishes\n`DomainType == self`:\n```go\nif permission.DomainType != models.DomainTypeSelf {\n submitUser = filter.Value // jobs.go:178, caller-chosen user\n}\n...\nif !specify_user || permission.DomainType == models.DomainTypeSelf { // :187\n...\nif jobPermission.DomainType == models.DomainTypeSelf { // :762, per-row owner filter\n```\nso `self_ug` falls through every check like `all`: a `submit_user` filter\nnaming any user (or `all`) is honoured and no row is removed.\n`GetRequireResourcePermission()` (`controllers/rbac_check.go:323-346`)\nreturns only the first grant matching the permission, so for the default\n`basic_user` (`config/rbac.yaml:37-40`, `job:view` with `self` then\n`self_ug`) the `self` entry wins and `self_ug` is silently ignored.\n`self_ug` is accepted by role validation (`controllers/roles.go:697-700`)\nand listed as a supported job scope (`models/roles.go:70`), but nothing\nresolves the user's user groups for it.\n\n**Expected:** a `self_ug` job grant covers exactly the jobs whose owner shares\na user group with the requester; a grant list with several scopes covers the\nunion of them; anything else is rejected or filtered out.\n**Impact:** security (authorization bypass). Any role an admin creates with\n`job:view` (or another job permission, unchecked) scoped to `self_ug` grants\nvisibility of all users' jobs. The default `basic_user` is not exposed (its\n`self` grant is matched first) but never gets the group visibility its\nconfig promises. Other job permissions using the same single-grant lookup\nwere not checked yet.\n**Found by:** review, during `/kiro-spec-requirements project-config`\n(2026-10-10, RBAC survey for project-scoped job permissions).\nUPDATE (2026-10-10): confirmed by the new tests in\n`src/frontend/apiserver/controllers/job_scope_test.go` against the unfixed\nhandlers: with only `self_ug` grants, alice listed carol's job\n(`submit_user carol: rows [..907], want []`), read it by ID (200), and\nkilled it (`Job control <kill> accepted`, dispatched). `job:control` is\naffected as well as `job:view`. A user holding `self` and `self_ug` together\ngot `self` only (the second scope was dropped).",
"file": "src/frontend/apiserver/controllers/jobs.go, src/frontend/apiserver/controllers/rbac_check.go, src/frontend/apiserver/models/roles.go, src/frontend/apiserver/config/rbac.yaml",
"root_cause": "TODO: required for fixed/partial",
"fix": "TODO: required for fixed/partial\n\nVerification:\n- Test: TODO: name + file; RED before the fix, GREEN after\n- Suites: TODO: suite results and build profile\n- Live: TODO: soak/deploy run, or n/a and why",
"root_cause": "Three defects in `src/frontend/apiserver`:\n- Every job-permission check read one scope: `GetRequireResourcePermission()`\n (`controllers/rbac_check.go`) returns the first grant of the permission,\n and `DeduplicatePermissions()` keeps one entry per permission name, so a\n `self` + `self_ug` grant became one scope.\n- The checks compared that scope only with `self`\n (`controllers/jobs.go` job list ~178/187, by-ID ~762, both control paths\n ~2190 and ~2457; `output.go` compared with `all`), so any other scope,\n `self_ug` included, fell through as unrestricted in the list, by-ID and\n control paths.\n- The apiserver had no user-group data: `handleConfigPush()`\n (`backend/clib.go`) ignored `cluster_config_t.ugroups`, so `self_ug` could\n not have been resolved anyway.\nAlso in the by-ID path: rows were removed from the slice being ranged over,\nwhich skipped the row after each removal.\nSince: pre-existing; `self_ug` was added as a job scope without enforcement\n(origin not bisected).",
"fix": "- `controllers/job_scope.go` (new): `userJobScope()` collects every grant\n of a job permission before deduplication (`collectUserPermissionsWithType()`,\n split out of `GetUserAllPermissionsWithType()` in `rbac_check.go`) and adds\n them up: `all` covers every job, `self` the user's own, `self_ug` the user\n and everyone sharing a user group with them; other scope types cover\n nothing. `covers(owner)` and `ownerFilter()` (a `submit_user in/notin`\n condition for job queries) apply it.\n- `controllers/jobs.go`: the job list appends the owner filter for narrower\n scopes (a `submit_user` the scope does not cover now yields no rows); the\n by-ID path keeps covered rows in a new slice and answers 403 (was 500)\n when nothing is visible; both control paths reject an uncovered job with\n 403. `controllers/output.go`: the owner may always read; others need a\n covering `job:view` grant.\n- `store/usergroups.go` (new) holds the user groups and resolves membership\n (users, nested groups, `all`, exclusions, cycle-safe);\n `models/usergroups.go` (new) the model; `backend`: `cClusterConfUserGroups()`\n converts `cluster_config_t.ugroups`, and `handleConfigPush()` calls the new\n `NotificationHandler.OnUserGroupsUpdated()`, which replaces the store.\n- Behaviour change: the default `basic_user` (`job:view` with `self` and\n `self_ug`) now also sees the jobs of users sharing a user group with them,\n as its configuration says; before, it saw only its own.\n\nVerification:\n- Test: `src/frontend/apiserver/controllers/job_scope_test.go`:\n `TestJobScope_SelfUGListCoversGroupOnly`, `TestJobScope_SelfUGGetByID`,\n `TestJobScope_SelfUGControl`, `TestJobScope_SelfUGOutput`,\n `TestJobScope_GrantsAddUp`: all RED before the fix (carol's job listed, read,\n killed; group mates' output refused; self+self_ug collapsed to self), GREEN\n after. `store/usergroups_test.go` `TestUserGroups_Membership` and\n `backend/handler_test.go` `TestOnUserGroupsUpdated_ReplacesUserGroups`\n (new code; RED as undefined symbols before).\n- Suites: `xce-build.sh apiserver asan xce.build --incremental`: apiserver Go\n packages (store, utils, controllers, backend, backend/auth) and all xcecli\n packages ok; cgo `xce_apiserver` built; meson `apiserver` suite 1/1 OK;\n 2026-10-10.\n- Live: n/a: handler-level tests drive the real gin handlers, RBAC loading\n and stores; the only untested seam is the cgo conversion of\n `cluster_config_t.ugroups`, which compiled into `xce_apiserver`; not run\n against a live controller.",
"tags": [
"apiserver",
"rbac",
@ -17787,17 +17787,19 @@
"timestamp": "2026-10-10T04:31:27Z",
"last_seen": "2026-10-10T04:31:27Z",
"occurrences": 1,
"error_message": "xcecli jobs list -P <project> fails: apiserver rejects project_name filter (unsupported filter)\n\n**Observed:** `xcecli jobs list -P <project>` (`-P/--project`,\n`src/frontend/xcecli/cmd/jobs/list.go:356`) sends the filter\n`{project_name:eq:<project>}` (`xcecli/filters/job_filter.go:108-109`), but\n`project_name` is commented out of the apiserver's `JOB_SUPPORT_FILTERS`\n(`src/frontend/apiserver/models/jobs.go:178`), so `validateJobQueryFilters()`\n(`controllers/jobs.go:470-483`) rejects the request:\n```\nunsupported filter: project_name\n```\n(HTTP 400, INVALID_PARAMS). The same holds for `license_project`\n(`models/jobs.go:179`, `job_filter.go:112-113`). Found by source reading; not\nyet run against a live apiserver.\n\n**Expected:** `xcecli jobs list -P eng` lists the caller's visible jobs whose\nproject is `eng`.\n**Impact:** wrong result: the documented `-P` (and license-project) filter of\n`xcecli jobs list` always fails with an error; users cannot list jobs by\nproject. No data is affected.\n**Found by:** review, during `/kiro-spec-requirements project-config`\n(2026-10-10); `project-config` Requirement 7.5 needs this filter.",
"error_message": "xcecli jobs list -P <project> fails: apiserver rejects project_name filter (unsupported filter)\n\n**Observed:** `xcecli jobs list -P <project>` (`-P/--project`,\n`src/frontend/xcecli/cmd/jobs/list.go:356`) sends the filter\n`{project_name:eq:<project>}` (`xcecli/filters/job_filter.go:108-109`), but\n`project_name` is commented out of the apiserver's `JOB_SUPPORT_FILTERS`\n(`src/frontend/apiserver/models/jobs.go:178`), so `validateJobQueryFilters()`\n(`controllers/jobs.go:470-483`) rejects the request:\n```\nunsupported filter: project_name\n```\n(HTTP 400, INVALID_PARAMS). The same holds for `license_project`\n(`models/jobs.go:179`, `job_filter.go:112-113`). Found by source reading; not\nyet run against a live apiserver.\n\n**Expected:** `xcecli jobs list -P eng` lists the caller's visible jobs whose\nproject is `eng`.\n**Impact:** wrong result: the documented `-P` (and license-project) filter of\n`xcecli jobs list` always fails with an error; users cannot list jobs by\nproject. No data is affected.\n**Found by:** review, during `/kiro-spec-requirements project-config`\n(2026-10-10); `project-config` Requirement 7.5 needs this filter.\nUPDATE (2026-10-10): confirmed by `TestJobHandler_ProjectFilters`\n(`src/frontend/apiserver/controllers/job_scope_test.go`) before the fix:\n`status = 400 ... unsupported filter: project_name`.",
"file": "src/frontend/apiserver/models/jobs.go, src/frontend/apiserver/controllers/jobs.go, src/frontend/xcecli/filters/job_filter.go",
"root_cause": "TODO: required for fixed/partial",
"fix": "TODO: required for fixed/partial\n\nVerification:\n- Test: TODO: name + file; RED before the fix, GREEN after\n- Suites: TODO: suite results and build profile\n- Live: TODO: soak/deploy run, or n/a and why",
"root_cause": "`JOB_SUPPORT_FILTERS` (`src/frontend/apiserver/models/jobs.go`) had\n`project_name` and `license_project` commented out, and\n`validateJobQueryFilters()` (`controllers/jobs.go`) rejects every field not on\nthat list, while `xcecli` (`filters/job_filter.go:108-113`) sends exactly\nthose fields for `-P` and the license-project option. The job store already\ncarries both fields (`JobInfo.ProjectName`, `LicenseProject`). Since:\npre-existing, origin unknown.",
"fix": "`models/jobs.go`: `project_name` and `license_project` are now in\n`JOB_SUPPORT_FILTERS`. No other change was needed: the store query filters\nby the JSON field name. `ARRAY_JOB_SUPPORT_FILTERS` (array statistics) is\nunchanged.\n\nVerification:\n- Test: `TestJobHandler_ProjectFilters`\n (`src/frontend/apiserver/controllers/job_scope_test.go`): RED before (400\n unsupported filter), GREEN after (`{project_name:eq:eng}` and\n `{license_project:eq:lic2}` select only the matching job).\n- Suites: same run as bug-1934: apiserver and xcecli Go tests ok, meson\n `apiserver` suite 1/1 OK, asan, 2026-10-10.\n- Live: n/a: the change is a filter whitelist entry exercised through the\n real handler and store in the test.",
"tags": [
"apiserver",
"xcecli",
"filter",
"project"
],
"related_bugs": [],
"related_bugs": [
"bug-1934"
],
"fix_commits": [],
"record": ".wolf/bugs/bug-1935.md"
}

View file

@ -50,6 +50,13 @@ config promises. Other job permissions using the same single-grant lookup
were not checked yet.
**Found by:** review, during `/kiro-spec-requirements project-config`
(2026-10-10, RBAC survey for project-scoped job permissions).
UPDATE (2026-10-10): confirmed by the new tests in
`src/frontend/apiserver/controllers/job_scope_test.go` against the unfixed
handlers: with only `self_ug` grants, alice listed carol's job
(`submit_user carol: rows [..907], want []`), read it by ID (200), and
killed it (`Job control <kill> accepted`, dispatched). `job:control` is
affected as well as `job:view`. A user holding `self` and `self_ug` together
got `self` only (the second scope was dropped).
## Environment
- OS: no /etc/os-release; Docker container; NixOS kernel; kernel Linux 6.18.33 x86_64
@ -71,15 +78,64 @@ were not checked yet.
- Evidence: the code lines quoted in Description; no logs.
## Root cause
TODO: required for fixed/partial
Three defects in `src/frontend/apiserver`:
- Every job-permission check read one scope: `GetRequireResourcePermission()`
(`controllers/rbac_check.go`) returns the first grant of the permission,
and `DeduplicatePermissions()` keeps one entry per permission name, so a
`self` + `self_ug` grant became one scope.
- The checks compared that scope only with `self`
(`controllers/jobs.go` job list ~178/187, by-ID ~762, both control paths
~2190 and ~2457; `output.go` compared with `all`), so any other scope,
`self_ug` included, fell through as unrestricted in the list, by-ID and
control paths.
- The apiserver had no user-group data: `handleConfigPush()`
(`backend/clib.go`) ignored `cluster_config_t.ugroups`, so `self_ug` could
not have been resolved anyway.
Also in the by-ID path: rows were removed from the slice being ranged over,
which skipped the row after each removal.
Since: pre-existing; `self_ug` was added as a job scope without enforcement
(origin not bisected).
## Fix
TODO: required for fixed/partial
- `controllers/job_scope.go` (new): `userJobScope()` collects every grant
of a job permission before deduplication (`collectUserPermissionsWithType()`,
split out of `GetUserAllPermissionsWithType()` in `rbac_check.go`) and adds
them up: `all` covers every job, `self` the user's own, `self_ug` the user
and everyone sharing a user group with them; other scope types cover
nothing. `covers(owner)` and `ownerFilter()` (a `submit_user in/notin`
condition for job queries) apply it.
- `controllers/jobs.go`: the job list appends the owner filter for narrower
scopes (a `submit_user` the scope does not cover now yields no rows); the
by-ID path keeps covered rows in a new slice and answers 403 (was 500)
when nothing is visible; both control paths reject an uncovered job with
403. `controllers/output.go`: the owner may always read; others need a
covering `job:view` grant.
- `store/usergroups.go` (new) holds the user groups and resolves membership
(users, nested groups, `all`, exclusions, cycle-safe);
`models/usergroups.go` (new) the model; `backend`: `cClusterConfUserGroups()`
converts `cluster_config_t.ugroups`, and `handleConfigPush()` calls the new
`NotificationHandler.OnUserGroupsUpdated()`, which replaces the store.
- Behaviour change: the default `basic_user` (`job:view` with `self` and
`self_ug`) now also sees the jobs of users sharing a user group with them,
as its configuration says; before, it saw only its own.
## Verification
- Test: TODO: name + file; RED before the fix, GREEN after
- Suites: TODO: suite results and build profile
- Live: TODO: soak/deploy run, or n/a and why
- Test: `src/frontend/apiserver/controllers/job_scope_test.go`:
`TestJobScope_SelfUGListCoversGroupOnly`, `TestJobScope_SelfUGGetByID`,
`TestJobScope_SelfUGControl`, `TestJobScope_SelfUGOutput`,
`TestJobScope_GrantsAddUp`: all RED before the fix (carol's job listed, read,
killed; group mates' output refused; self+self_ug collapsed to self), GREEN
after. `store/usergroups_test.go` `TestUserGroups_Membership` and
`backend/handler_test.go` `TestOnUserGroupsUpdated_ReplacesUserGroups`
(new code; RED as undefined symbols before).
- Suites: `xce-build.sh apiserver asan xce.build --incremental`: apiserver Go
packages (store, utils, controllers, backend, backend/auth) and all xcecli
packages ok; cgo `xce_apiserver` built; meson `apiserver` suite 1/1 OK;
2026-10-10.
- Live: n/a: handler-level tests drive the real gin handlers, RBAC loading
and stores; the only untested seam is the cgo conversion of
`cluster_config_t.ugroups`, which compiled into `xce_apiserver`; not run
against a live controller.
## References
- Related: none
- Related: bug-1935 (same session, same files)
- Spec: `.kiro/specs/project-config/requirements.md` Requirement 8 (project
job scope builds on `userJobScope()`)

View file

@ -10,7 +10,7 @@ last_seen: "2026-10-10T04:31:27Z"
occurrences: 1
files: ["src/frontend/apiserver/models/jobs.go", "src/frontend/apiserver/controllers/jobs.go", "src/frontend/xcecli/filters/job_filter.go"]
tags: ["apiserver", "xcecli", "filter", "project"]
related: []
related: ["bug-1934"]
---
# bug-1935: xcecli jobs list -P <project> fails: apiserver rejects project_name filter (unsupported filter)
@ -36,6 +36,9 @@ project is `eng`.
project. No data is affected.
**Found by:** review, during `/kiro-spec-requirements project-config`
(2026-10-10); `project-config` Requirement 7.5 needs this filter.
UPDATE (2026-10-10): confirmed by `TestJobHandler_ProjectFilters`
(`src/frontend/apiserver/controllers/job_scope_test.go`) before the fix:
`status = 400 ... unsupported filter: project_name`.
## Environment
- OS: no /etc/os-release; Docker container; NixOS kernel; kernel Linux 6.18.33 x86_64
@ -55,15 +58,28 @@ project. No data is affected.
- Evidence: the code lines quoted in Description; no logs.
## Root cause
TODO: required for fixed/partial
`JOB_SUPPORT_FILTERS` (`src/frontend/apiserver/models/jobs.go`) had
`project_name` and `license_project` commented out, and
`validateJobQueryFilters()` (`controllers/jobs.go`) rejects every field not on
that list, while `xcecli` (`filters/job_filter.go:108-113`) sends exactly
those fields for `-P` and the license-project option. The job store already
carries both fields (`JobInfo.ProjectName`, `LicenseProject`). Since:
pre-existing, origin unknown.
## Fix
TODO: required for fixed/partial
`models/jobs.go`: `project_name` and `license_project` are now in
`JOB_SUPPORT_FILTERS`. No other change was needed: the store query filters
by the JSON field name. `ARRAY_JOB_SUPPORT_FILTERS` (array statistics) is
unchanged.
## Verification
- Test: TODO: name + file; RED before the fix, GREEN after
- Suites: TODO: suite results and build profile
- Live: TODO: soak/deploy run, or n/a and why
- Test: `TestJobHandler_ProjectFilters`
(`src/frontend/apiserver/controllers/job_scope_test.go`): RED before (400
unsupported filter), GREEN after (`{project_name:eq:eng}` and
`{license_project:eq:lic2}` select only the matching job).
- Suites: same run as bug-1934: apiserver and xcecli Go tests ok, meson
`apiserver` suite 1/1 OK, asan, 2026-10-10.
- Live: n/a: the change is a filter whitelist entry exercised through the
real handler and store in the test.
## References
- Related: none
- Related: bug-1934
- Spec: `.kiro/specs/project-config/requirements.md` Requirement 7.5

View file

@ -520,6 +520,7 @@ func handleConfigPush(cfgdata *C.xce_configdata_t) C.int {
cClusterConfHostConfigs(conf),
cluster,
)
gHandler.OnUserGroupsUpdated(cClusterConfUserGroups(conf))
return 0
}

View file

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

View file

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

View file

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

View file

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

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 {

View file

@ -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",

View 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"`
}

View 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) }

View 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)
}
}