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.

  1. Pins how anonymous access to pipelines follows the repository and builds settings of a public project, which no spec held.
  2. Adds read_pipeline_job to public_anonymous.yml next to read_pipeline, and prevents it in ProjectPolicy in the rule that prevents read_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 authorizes read_pipeline and lists jobs through Ci::JobsFinder, which checks read_build. The generated doc/auth/tokens/fine_grained_access_tokens_rest.md now 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.
  3. Documents that second invocation in the REST implementation guide, so the next endpoint tested on a private project gets one too.
  4. Makes read_pipeline_job follow read_build, as suggested in review. ProjectPolicy prevents it beside read_build where builds or the repository are not accessible, which also covers the rule the second commit put it in, and in the ~public_builds rule. 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 gets 403 insufficient_granular_scope for the jobs, as on master, and an anonymous request still gets an empty list.
  5. Extends the comment above the read_pipeline_job prevent in the ~public_builds rule, as suggested in review: it now says that the prevent applies to every user, and how a role would grant read_pipeline_job later.

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:204 declares permissions: :read_pipeline_job for the jobs route, and :206 authorizes read_pipeline on the project.
  • app/policies/authz/boundary_policy.rb:23-30 grants a permission to a fine-grained personal access token when Users::Anonymous.can? it on the boundary's project or group.
  • app/policies/project_policy.rb:322 enables the public_anonymous role's permissions on a public project, :661 and :681 prevent read_pipeline, and :762 enables it from _read_public_pipeline.
  • git grep read_pipeline_job finds only the route, the permission definition, the Pipeline: Read assignable permission, an example in doc/development/permissions/role_definitions.md and the request spec.
  • app/finders/ci/jobs_finder.rb:68 returns no jobs unless the user can read_build on the pipeline.
  • spec/support/shared_examples/authz/granular_token_permissions_shared_examples.rb:153 skips the legacy non-member comparison unless boundary_object.public?, and spec/requests/api/ci/pipelines_spec.rb:597 runs the shared example on a private project.

How to set up and validate locally

  1. Make a project public, keep Public pipelines on, and run a pipeline in it.
  2. 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.
  3. Request the pipeline's jobs: curl --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/projects/<id>/pipelines/<pipeline_id>/jobs". On master it answers 403 with insufficient_granular_scope. On this branch it answers 200, 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.rb and spec/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.rb and spec/tasks/gitlab/permissions/routes/docs_task_spec.rb pass in full, and so does bundle 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.rb and spec/requests/api/ci/pipelines_spec.rb in full on d97fbc50f66. 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 of master. They come from my local test setup: it has no config/cable.yml, and attr_encrypted raises key must be 32 bytes or longer when 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.rb and the gitlab-dangerfiles 4.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.

Edited by José M. Requena Plens

Merge request reports

Loading
Loading