Fine-grained PATs are refused pipeline bridges, trigger jobs and test reports that anonymous users can read on public projects

Summary

On a public project with Public pipelines on, four pipeline routes answer 200 to an anonymous request and to a classic read_api token of a user who is not a member, and 403 insufficient_granular_scope to a fine-grained personal access token whose scopes do not grant Pipeline: Read for that project:

  • GET /projects/:id/pipelines/:pipeline_id/bridges
  • GET /projects/:id/pipelines/:pipeline_id/trigger_jobs
  • GET /projects/:id/pipelines/:pipeline_id/test_report
  • GET /projects/:id/pipelines/:pipeline_id/test_report_summary

A fine-grained token with no scopes at all gets the same 403. That is the token the shared granular token examples compare with a non-member request.

The public access bypass from !231701 (merged) grants a fine-grained personal access token a permission on a public project when an anonymous user holds that permission there. These routes declare read_pipeline_bridge, read_pipeline_test_report and read_pipeline_test_report_summary, and no role and no policy rule enables any of the three for an anonymous user, so the bypass never applies to them. GET /projects/:id/pipelines and GET /projects/:id/pipelines/:pipeline_id, which declare read_pipeline, work with the same tokens on the same project.

This is the gap #627693 (closed) reports for GET /projects/:id/pipelines/:pipeline_id/jobs (read_pipeline_job), on the other REST routes of Pipeline: Read that an anonymous user can read. One difference: these four routes authorize read_build (the bridges and trigger jobs routes on the project, the test report routes on the pipeline), not read_pipeline.

Steps to reproduce

  1. Make a project public and keep Public pipelines on (the default). 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 bridges with the token:

    curl --header "PRIVATE-TOKEN: <token>" "https://gitlab.example.com/api/v4/projects/<id>/pipelines/<pipeline_id>/bridges"

    It answers 403 with insufficient_granular_scope as the error and this error_description: Access denied: This operation requires a fine-grained personal access token with the following project permissions: [Pipeline: Read].

  4. Repeat the request without the header, or with a classic read_api token of the same user. Both answer 200.

trigger_jobs, test_report and test_report_summary behave the same way.

Example Project

I reproduced this with a request spec against master, not on GitLab.com.

What is the current bug behavior?

A fine-grained token is refused data on a public project that an anonymous caller and a classic non-member token can read. The refusal asks for Pipeline: Read, the permission the token already holds for its own group, so the user cannot tell why the same token reads the project's pipelines but not their bridges or test reports.

What is the expected correct behavior?

The same 200 an anonymous request gets. The REST implementation guide says the bypass "mirrors the access an anonymous caller has on the same resource" (rest_api_implementation_guide.md:256), and that "a granular PAT without scope grants access to a GET endpoint exactly when a legacy non-member token does" (:301).

When an anonymous user cannot read the project's builds (Public pipelines off, or CI/CD visible to project members only), all four routes already answer 403 to anonymous and classic non-member requests, and the fine-grained token should keep getting 403 there.

Relevant logs and/or screenshots

Status matrix from a request spec

A throwaway request spec, kept outside the repository and run on master at 55bfaf7fe32 with a license, created a public group and a public project in it (default feature settings, public_builds on), a pipeline with a job and a bridge, and a user who is a developer of another group only. Each cell is the status of the request with that credential. "Another group" is a fine-grained token with Pipeline: Read scoped to that other group. "No scopes" is a fine-grained token with no scopes. "All memberships" is a fine-grained token with Pipeline: Read on all memberships, as a control. I ran the spec twice: the second run added the no-scopes column and matched the first in every other cell.

Route Anonymous Classic read_api Fine-grained, another group Fine-grained, no scopes Fine-grained, 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 (#627693 (closed)) 200 200 403 403 200
GET /projects/:id/pipelines/:pipeline_id/bridges 200 200 403 403 200
GET /projects/:id/pipelines/:pipeline_id/trigger_jobs 200 200 403 403 200
GET /projects/:id/pipelines/:pipeline_id/test_report 200 200 403 403 200
GET /projects/:id/pipelines/:pipeline_id/test_report_summary 200 200 403 403 200

Every 403 in the table carried insufficient_granular_scope and asked for Pipeline: Read. The pipeline had no JUnit report artifacts. The status codes do not depend on report content: both test report routes authorize read_build before they read any report.

The same spec then turned Public pipelines off, and after that set CI/CD to project members only. In both cases the bridges, trigger jobs and both test report routes answered 403 to the anonymous and the classic non-member request.

The files involved are unchanged on master at 5228514da87, apart from an unrelated hunk in project_policy.rb (the packages_disabled condition).

Other routes with the same result

The same spec also swept every GET route under /projects/:id/ or /groups/:id/ whose only path parameter is :id and that declares permissions (210 routes), and a hand-picked list of routes with deeper paths. Besides the jobs route of #627693 (closed) and the four routes above, 22 routes answered 200 to anonymous and classic non-member requests and 403 insufficient_granular_scope to a fine-grained token holding the route's permission for another group, and to one with no scopes. I left them out of this issue to keep it to the pipeline routes.

Route Declared permission The refusal asks for
GET /groups/:id/groups/shared read_shared_group Group: Read
GET /groups/:id/invited_groups read_group_invited_group Group: Read
GET /projects/:id/ci/lint read_ci_config CI Config: Read
GET /projects/:id/groups read_ancestor_group Project: Read
GET /projects/:id/invited_groups read_project_invited_group Project: Read
GET /projects/:id/issues/:issue_iid/award_emoji read_issue_award_emoji Work Item: Read
GET /projects/:id/issues/:issue_iid/award_emoji/:award_id read_issue_award_emoji Work Item: Read
GET /projects/:id/jobs/:job_id/artifacts download_job_artifact Job Artifact: Read
GET /projects/:id/jobs/:job_id/artifacts/*artifact_path download_job_artifact Job Artifact: Read
GET /projects/:id/jobs/:job_id/artifacts/tree download_job_artifact Job Artifact: Read
GET /projects/:id/jobs/artifacts/:ref_name/download download_job_artifact Job Artifact: Read
GET /projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path download_job_artifact Job Artifact: Read
GET /projects/:id/languages read_language Project: Read
GET /projects/:id/merge_requests/:merge_request_iid/award_emoji read_merge_request_award_emoji Work Item: Read
GET /projects/:id/pages_access read_pages_access Project: Read
GET /projects/:id/protected_branches read_protected_branch Protected Branch: Read
GET /projects/:id/registry/repositories read_container_repository Container Repository: Read
GET /projects/:id/releases/:tag_name/assets/links read_release_link Release Link: Read
GET /projects/:id/releases/:tag_name/assets/links/:link_id read_release_link Release Link: Read
GET /projects/:id/share_locations read_share_location Project: Read
GET /projects/:id/snippets/:snippet_id/award_emoji read_snippet_award_emoji Work Item: Read
GET /projects/:id/users read_contributing_user Project: Read

Routes with deeper paths were measured only where they are in this table or in the one above, so the list is not complete.

A different case, on 10 of the measured routes: an anonymous request gets 401, a classic non-member token gets 200, and both fine-grained tokens get 403. The bypass is defined through an anonymous user, so it cannot grant these, while the comparison with a legacy non-member request in the guide expects it to. The routes are GET /projects/:id/jobs, GET /projects/:id/jobs/:job_id, GET /projects/:id/jobs/:job_id/trace, GET /projects/:id/milestones/:milestone_id/issues, GET /projects/:id/milestones/:milestone_id/merge_requests, GET /projects/:id/milestones/:milestone_id/burndown_events, GET /groups/:id/milestones/:milestone_id/burndown_events, GET /projects/:id/import, GET /projects/:id/ml/mlflow/api/2.0/mlflow/experiments/list and GET /projects/:id/ml/mlflow/api/2.0/mlflow/registered-models/search.

Evidence, file and line on master at 5228514da87
  • The routes declare their permissions at lib/api/ci/pipelines.rb:240 (bridges), :266 (trigger jobs), :308 and :331 (test report and summary). The bridges and trigger jobs routes authorize read_build on the project in present_pipeline_trigger_jobs, and the test report routes authorize read_build on the pipeline (:310, :333). Ci::PipelinePolicy grants that when the user can read_build on the project. Its other path, _read_external_build, is held only by the reporter role.
  • All three permissions belong to Pipeline: Read (pipeline/read.yml:9-16).
  • Authz::BoundaryPolicy grants a fine-grained personal access token a permission when Users::Anonymous.can? it on the boundary's project or group.
  • config/authz/roles/public_anonymous.yml lists _read_public_build and read_pipeline under project.raw_permissions, and none of the three permissions. git grep finds read_pipeline_bridge, read_pipeline_test_report and read_pipeline_test_report_summary only in their permission definitions, the assignable permission, the route decorators, the request spec and, for the bridge one, an example in doc/development/permissions/role_definitions.md.
  • ProjectPolicy enables the public_anonymous permissions on a public project (:322-324), prevents _read_public_build when Public pipelines is off (:754-759), enables read_build from it (:761) and prevents read_build when builds or the repository are disabled for the user (:622-625).
  • The request specs run the shared granular token examples for these routes on a private project (pipelines_spec.rb:14-16; the invocations are at :810, :1796 and :1885), and the shared example skips its comparison with a non-member request unless the boundary is public (granular_token_permissions_shared_examples.rb:153), so no spec covers this case.
  • The generated list of publicly accessible endpoints in doc/auth/tokens/fine_grained_access_tokens_rest.md is built from public_anonymous.yml (docs_task.rb:161-169), and lists the read_pipeline routes but none of these four.

Output of checks

Results of GitLab environment info

Not applicable: reproduced in a request spec on master.

Results of GitLab application Check

Not applicable.

Possible fixes

Opt the three permissions into the public access bypass the way other read permissions do, and make them follow the condition the routes check, read_build:

  1. Add read_pipeline_bridge, read_pipeline_test_report and read_pipeline_test_report_summary to project.raw_permissions in config/authz/roles/public_anonymous.yml.
  2. Prevent them in ProjectPolicy where an anonymous user loses read_build: in the ~public_builds rule next to _read_public_build, and in the builds_disabled | repository_disabled rule next to read_build. The bypass then grants them exactly when an anonymous user can read the project's builds, and the routes keep authorizing read_build for the token's user.
  3. Run the shared granular token examples for these routes on a public project as well, so the comparison with a non-member request runs.
  4. Regenerate doc/auth/tokens/fine_grained_access_tokens_rest.md.

The fix for #627693 (closed) changes the same files for read_pipeline_job.

Related: !261569 (merged), the merge request for #627693 (closed), makes the same change for the jobs route.

I'd like to work on this.

Edited by José M. Requena Plens