Grant read_pipeline_job through the public access bypass
What does this MR do and why?
GET /projects/:id/pipelines/:pipeline_id/jobs asks a fine-grained personal access token for read_pipeline_job. When the token's scopes do not grant a permission for a project, Authz::BoundaryPolicy still grants it through the public access bypass from !231701 (merged), if an anonymous user holds that permission on the project. No policy rule and no role in config/authz/roles/ enabled read_pipeline_job, so the bypass never applied to it.
Since !256275 (merged) removed the membership requirement, the request in the issue (a token with Pipeline: Read on all memberships, from a user who is not a member) succeeds on master. What still fails is a token whose scopes do not grant Pipeline: Read for the project, for example one scoped to another group. On a public project it gets 403 insufficient_granular_scope for the jobs of a pipeline, while an anonymous request, a legacy non-member token and the same token on GET /projects/:id/pipelines all get 200. The REST implementation guide says such a token reaches a GET endpoint on a public resource exactly when a legacy non-member token does.
The branch has five commits, each passing its specs on its own, so I left squashing off. The Changelog: fixed trailer is on the second one.
- Pins how anonymous access to pipelines follows the repository and builds settings of a public project, which no spec held.
- Adds
read_pipeline_jobtopublic_anonymous.ymlnext toread_pipeline, and prevents it inProjectPolicyin the rule that preventsread_pipeline(builds the user cannot access while internal CI is on, or a repository the user cannot access). The bypass then grants it exactly when an anonymous user can read the project's pipelines, and the fourth commit narrows that to when they can read its jobs. The endpoint itself is unchanged: it still authorizesread_pipelineand lists jobs throughCi::JobsFinder, which checksread_build. The generateddoc/auth/tokens/fine_grained_access_tokens_rest.mdnow lists the route among the publicly accessible endpoints. The jobs request spec also runs the shared granular token examples on a public project: the existing invocation uses a private project, where the shared example skips the comparison with a legacy non-member request, which is why no spec caught this. - Documents that second invocation in the REST implementation guide, so the next endpoint tested on a private project gets one too.
- Makes
read_pipeline_jobfollowread_build, as suggested in review.ProjectPolicyprevents it besideread_buildwhere builds or the repository are not accessible, which also covers the rule the second commit put it in, and in the~public_buildsrule. The bypass then grants it only where an anonymous user can read the jobs: not with Public pipelines off, and not with internal CI disabled while the repository stays readable, where the pipelines of an external CI stay visible. With Public pipelines off, a token without Pipeline: Read for the project therefore still gets403 insufficient_granular_scopefor the jobs, as onmaster, and an anonymous request still gets an empty list. - Extends the comment above the
read_pipeline_jobprevent in the~public_buildsrule, as suggested in review: it now says that the prevent applies to every user, and how a role would grantread_pipeline_joblater.
I took the first fix the issue proposes rather than making the route ask for read_pipeline. It keeps the route's own permission and follows how other read permissions opt in to the bypass, such as read_merge_request_pipeline and read_package_pipeline. If you prefer the route change, I can switch.
While checking this I found the same gap on other Pipeline: Read routes of a public project (table below). I left them out to keep this MR to the issue and reported them in #633409.
References
Closes #627693
- !231701 (merged) added the public access bypass and the shared example's comparison with a non-member request.
- !256275 (merged) removed the membership requirement from the token rule, which is why the issue's own request now succeeds.
- Related to #633409, the same gap on the bridges, trigger jobs and test report routes.
Screenshots or screen recordings
Status codes from a request spec on a public project with public pipelines, as a user who is not a member. The token holds Pipeline: Read for a group that does not contain the project.
| Request | Before | After |
|---|---|---|
GET /projects/:id/pipelines/:pipeline_id/jobs |
403 |
200 |
GET /projects/:id/pipelines |
200 |
200 |
| Same jobs request, anonymous | 200 |
200 |
Same jobs request, legacy read_api token |
200 |
200 |
Full matrix before and after
I measured this on master at 53cf8478d5e and on this branch on top of it. A throwaway request spec, not part of this MR, created a public project with public_builds on, a pipeline, a job and a bridge, and a user who is a member of another group only. Each cell is the status of the request with that credential. Only the jobs row changes with this MR.
| Route | Anonymous | Legacy read_api |
Fine-grained, Pipeline: Read on all memberships | Fine-grained, Pipeline: Read on another group | Fine-grained, Job: Read on all memberships |
|---|---|---|---|---|---|
GET /projects/:id/pipelines |
200 | 200 | 200 | 200 | 200 |
GET /projects/:id/pipelines/:pipeline_id |
200 | 200 | 200 | 200 | 200 |
GET /projects/:id/pipelines/:pipeline_id/jobs |
200 | 200 | 200 | 403, now 200 | 403, now 200 |
GET /projects/:id/pipelines/:pipeline_id/bridges |
200 | 200 | 200 | 403 | 403 |
GET /projects/:id/pipelines/:pipeline_id/trigger_jobs |
200 | 200 | 200 | 403 | 403 |
GET /projects/:id/pipelines/:pipeline_id/test_report |
200 | 200 | 200 | 403 | 403 |
GET /projects/:id/pipelines/:pipeline_id/test_report_summary |
200 | 200 | 200 | 403 | 403 |
The bridges and trigger jobs routes declare read_pipeline_bridge, and the two test report routes declare read_pipeline_test_report and read_pipeline_test_report_summary. None of the three is in public_anonymous.yml or enabled by a policy rule. All four routes authorize read_build rather than read_pipeline: the bridges and trigger jobs routes on the project, the test report routes on the pipeline. Their permissions would follow read_build and Public pipelines, not the rule used here.
Evidence, file and line on master at 53cf8478d5e
lib/api/ci/pipelines.rb:204declarespermissions: :read_pipeline_jobfor the jobs route, and:206authorizesread_pipelineon the project.app/policies/authz/boundary_policy.rb:23-30grants a permission to a fine-grained personal access token whenUsers::Anonymous.can?it on the boundary's project or group.app/policies/project_policy.rb:322enables thepublic_anonymousrole's permissions on a public project,:661and:681preventread_pipeline, and:762enables it from_read_public_pipeline.git grep read_pipeline_jobfinds only the route, the permission definition, thePipeline: Readassignable permission, an example indoc/development/permissions/role_definitions.mdand the request spec.app/finders/ci/jobs_finder.rb:68returns no jobs unless the user canread_buildon the pipeline.spec/support/shared_examples/authz/granular_token_permissions_shared_examples.rb:153skips the legacy non-member comparison unlessboundary_object.public?, andspec/requests/api/ci/pipelines_spec.rb:597runs the shared example on a private project.
How to set up and validate locally
- Make a project public, keep Public pipelines on, and run a pipeline in it.
- As a user who is not a member of the project, create a fine-grained personal access token for a group that does not contain the project, with Pipeline: Read.
- Request the pipeline's jobs:
curl --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/projects/<id>/pipelines/<pipeline_id>/jobs". Onmasterit answers403withinsufficient_granular_scope. On this branch it answers200, as it does without the header.
I ran the specs against a local test database, on this branch as rebased onto 53cf8478d5e:
spec/policies/project_policy_spec.rbandspec/requests/api/ci/pipelines_spec.rb: the new examples of the second commit fail with the policy and role of the first commit and pass with its own, and those of the fourth commit fail with the policy of the third and pass with its own. The examples of the first commit pass on their own.spec/policies/authz/boundary_policy_spec.rb,spec/lib/authz/role_definitions_spec.rbandspec/tasks/gitlab/permissions/routes/docs_task_spec.rbpass in full, and so doesbundle exec rake gitlab:permissions:validate, which includes the check that the generated reference is up to date.- Before the rebase, which left the first three commits unchanged, I ran
spec/policies/project_policy_spec.rb,ee/spec/policies/project_policy_spec.rbandspec/requests/api/ci/pipelines_spec.rbin full ond97fbc50f66. The only failures, 19 in the CE project policy spec and 9 in the pipelines request spec, failed the same way with the policy and role files ofmaster. They come from my local test setup: it has noconfig/cable.yml, andattr_encryptedraiseskey must be 32 bytes or longerwhen the cluster factory runs. - On the fourth commit I ran those three files in full again. The EE project policy spec passes, and the only failures are the same 19 in the CE project policy spec and 9 in the pipelines request spec, which fail the same way on the third commit.
- RuboCop on the changed Ruby files,
scripts/lint/sort_role_permissions.rb, both commit message linters (scripts/lint/commit_linter.rband thegitlab-dangerfiles4.12.0 one Danger runs) on every commit, and markdownlint-cli2 in the docs-lint image on both changed pages report nothing. Vale reports nothing on the lines this MR adds.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.