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/bridgesGET /projects/:id/pipelines/:pipeline_id/trigger_jobsGET /projects/:id/pipelines/:pipeline_id/test_reportGET /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
-
Make a project public and keep Public pipelines on (the default). 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 bridges with the token:
curl --header "PRIVATE-TOKEN: <token>" "https://gitlab.example.com/api/v4/projects/<id>/pipelines/<pipeline_id>/bridges"It answers
403withinsufficient_granular_scopeas theerrorand thiserror_description:Access denied: This operation requires a fine-grained personal access token with the following project permissions: [Pipeline: Read]. -
Repeat the request without the header, or with a classic
read_apitoken of the same user. Both answer200.
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),:308and:331(test report and summary). The bridges and trigger jobs routes authorizeread_buildon the project inpresent_pipeline_trigger_jobs, and the test report routes authorizeread_buildon the pipeline (:310,:333).Ci::PipelinePolicygrants that when the user canread_buildon 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::BoundaryPolicygrants a fine-grained personal access token a permission whenUsers::Anonymous.can?it on the boundary's project or group.config/authz/roles/public_anonymous.ymllists_read_public_buildandread_pipelineunderproject.raw_permissions, and none of the three permissions.git grepfindsread_pipeline_bridge,read_pipeline_test_reportandread_pipeline_test_report_summaryonly in their permission definitions, the assignable permission, the route decorators, the request spec and, for the bridge one, an example indoc/development/permissions/role_definitions.md.ProjectPolicyenables thepublic_anonymouspermissions on a public project (:322-324), prevents_read_public_buildwhen Public pipelines is off (:754-759), enablesread_buildfrom it (:761) and preventsread_buildwhen 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,:1796and: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.mdis built frompublic_anonymous.yml(docs_task.rb:161-169), and lists theread_pipelineroutes 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:
- Add
read_pipeline_bridge,read_pipeline_test_reportandread_pipeline_test_report_summarytoproject.raw_permissionsinconfig/authz/roles/public_anonymous.yml. - Prevent them in
ProjectPolicywhere an anonymous user losesread_build: in the~public_buildsrule next to_read_public_build, and in thebuilds_disabled | repository_disabledrule next toread_build. The bypass then grants them exactly when an anonymous user can read the project's builds, and the routes keep authorizingread_buildfor the token's user. - Run the shared granular token examples for these routes on a public project as well, so the comparison with a non-member request runs.
- 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.