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