From 1b79cc3e031b305423b91344894da94e4ce2ad92 Mon Sep 17 00:00:00 2001 From: Alex Buijs <abuijs@gitlab.com> Date: Mon, 20 Jan 2025 14:55:15 +0000 Subject: [PATCH] Add Ci/CD job token policies static analysis Verify CI/CD job token policy existence and validity on API routes. Changelog: added --- .gitlab/ci/rules.gitlab-ci.yml | 14 + .gitlab/ci/static-analysis.gitlab-ci.yml | 13 + doc/ci/jobs/fine_grained_permissions.md | 181 +++++++++++++ .../internal/app_sec/dast/site_validations.rb | 1 + ee/lib/ee/api/deployments.rb | 1 + ee/lib/ee/api/releases.rb | 1 + ee/spec/requests/api/deployments_spec.rb | 8 + ee/spec/requests/api/releases_spec.rb | 8 + lefthook.yml | 5 + lib/api/ci/catalog.rb | 1 + lib/api/ci/job_artifacts.rb | 4 + lib/api/ci/jobs.rb | 2 + lib/api/ci/pipelines.rb | 1 + lib/api/ci/runner.rb | 2 + lib/api/ci/secure_files.rb | 5 + lib/api/composer_packages.rb | 7 + lib/api/conan/v2/project_packages.rb | 3 + .../packages/conan/shared_endpoints.rb | 2 + .../concerns/packages/conan/v1_endpoints.rb | 17 ++ lib/api/concerns/packages/npm_endpoints.rb | 5 + .../packages/npm_namespace_endpoints.rb | 3 + lib/api/deployments.rb | 5 + lib/api/environments.rb | 7 + lib/api/generic_packages.rb | 6 + lib/api/go_proxy.rb | 1 + lib/api/helpers.rb | 13 +- lib/api/helpers/packages_helpers.rb | 2 + lib/api/maven_packages.rb | 12 +- lib/api/npm_project_packages.rb | 3 + lib/api/package_files.rb | 2 + lib/api/project_container_repositories.rb | 1 + lib/api/project_packages.rb | 4 + lib/api/pypi_packages.rb | 20 +- lib/api/release/links.rb | 5 + lib/api/releases.rb | 7 + lib/api/repositories.rb | 1 + lib/api/terraform/state.rb | 5 + lib/api/terraform/state_version.rb | 2 + lib/tasks/ci/job_tokens.rake | 37 +++ lib/tasks/ci/job_tokens_task.rb | 170 ++++++++++++ spec/lib/api/helpers_spec.rb | 8 + spec/requests/api/ci/catalog_spec.rb | 10 + spec/requests/api/ci/job_artifacts_spec.rb | 36 ++- spec/requests/api/ci/pipelines_spec.rb | 7 + .../api/ci/runner/jobs_artifacts_spec.rb | 5 + spec/requests/api/ci/secure_files_spec.rb | 39 +++ spec/requests/api/composer_packages_spec.rb | 20 +- .../api/conan/v1/instance_packages_spec.rb | 22 +- .../api/conan/v1/project_packages_spec.rb | 16 +- .../api/conan/v2/project_packages_spec.rb | 6 + spec/requests/api/deployments_spec.rb | 38 ++- spec/requests/api/environments_spec.rb | 58 +++- spec/requests/api/generic_packages_spec.rb | 26 ++ spec/requests/api/go_proxy_spec.rb | 24 ++ spec/requests/api/maven_packages_spec.rb | 28 ++ .../requests/api/npm_project_packages_spec.rb | 10 +- spec/requests/api/package_files_spec.rb | 12 + spec/requests/api/project_packages_spec.rb | 22 +- spec/requests/api/pypi_packages_spec.rb | 30 ++- spec/requests/api/release/links_spec.rb | 40 +++ spec/requests/api/releases_spec.rb | 50 ++++ spec/requests/api/repositories_spec.rb | 12 + spec/requests/api/terraform/state_spec.rb | 26 ++ .../api/terraform/state_version_spec.rb | 10 + .../ci/job_token_policies_shared_examples.rb | 25 +- .../api/conan_packages_shared_examples.rb | 36 ++- .../api/npm_packages_shared_examples.rb | 30 ++- .../requests/api/packages_shared_examples.rb | 6 + spec/tasks/ci/job_tokens_rake_spec.rb | 64 +++++ spec/tasks/ci/job_tokens_task_spec.rb | 249 ++++++++++++++++++ .../templates/fine_grained_permissions.md.erb | 30 +++ 71 files changed, 1525 insertions(+), 57 deletions(-) create mode 100644 doc/ci/jobs/fine_grained_permissions.md create mode 100644 lib/tasks/ci/job_tokens.rake create mode 100644 lib/tasks/ci/job_tokens_task.rb create mode 100644 spec/tasks/ci/job_tokens_rake_spec.rb create mode 100644 spec/tasks/ci/job_tokens_task_spec.rb create mode 100644 tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index ab82a1b31aabd..c4ef7ff11d705 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -725,6 +725,12 @@ - "tooling/custom_roles/docs/templates/custom_abilities.md.erb" - "ee/{lib/,spec/}tasks/gitlab/custom_roles/*" +.ci-job-token-policies-patterns: &ci-job-token-policies-patterns + - "{,ee/}lib/api/*.rb" + - "app/validators/json_schemas/ci_job_token_policies.json" + - "doc/ci/jobs/fine_grained_permissions.md" + - "tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb" + .cng-orchestrator-patterns: &cng-orchestrator-patterns - "qa/gems/gitlab-cng/**/*.rb" - "qa/gems/gitlab-cng/{Gemfile,Gemfile.lock}" @@ -1262,6 +1268,14 @@ - <<: *if-default-refs changes: *custom-roles-patterns +############################# +# CI job token policy rules # +############################# +.ci-job-token-policies:rules:ci-job-token-policies-verify: + rules: + - <<: *if-default-refs + changes: *ci-job-token-policies-patterns + ################## # Frontend rules # ################## diff --git a/.gitlab/ci/static-analysis.gitlab-ci.yml b/.gitlab/ci/static-analysis.gitlab-ci.yml index a540f634cffd0..197add3f1428e 100644 --- a/.gitlab/ci/static-analysis.gitlab-ci.yml +++ b/.gitlab/ci/static-analysis.gitlab-ci.yml @@ -243,6 +243,19 @@ custom-roles-verify: script: - bundle exec rake gitlab:custom_roles:check_docs +ci-job-token-policies-verify: + variables: + SETUP_DB: "false" + extends: + - .default-retry + - .ruby-cache + - .default-before_script + - .ci-job-token-policies:rules:ci-job-token-policies-verify + stage: lint + needs: [] + script: + - bundle exec rake ci:job_tokens:check_policies + templates-shellcheck: extends: - .ci-templates:rules:shellcheck diff --git a/doc/ci/jobs/fine_grained_permissions.md b/doc/ci/jobs/fine_grained_permissions.md new file mode 100644 index 0000000000000..e6820f8bf4df3 --- /dev/null +++ b/doc/ci/jobs/fine_grained_permissions.md @@ -0,0 +1,181 @@ +--- +stage: Software Supply Chain Security +group: Pipeline Security +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +<!-- + This documentation is auto generated by a Rake task. + + Please do not edit this file directly. To update this file, run: + `bundle exec rake ci:job_tokens:compile_docs`. + + To make changes to the output of the Rake task, + edit `tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb`. +--> + +# Fine-grained permissions for CI/CD job tokens + +DETAILS: +**Tier:** Free, Premium, Ultimate +**Offering:** GitLab.com, GitLab Self-Managed, GitLab Dedicated + +## Available API endpoints + +The following endpoints are available for CI job tokens. +You can use fine-grained permissions to explicitly allow access to a limited set of the following API endpoints. + +`None` means fine-grained permissions cannot control access to this endpoint. + +| Permissions | Permission Names | Path | Description | +| ----------- | ---------------- | ---- | ----------- | +| Deployments: Read and write | `ADMIN_DEPLOYMENTS` | `DELETE /projects/:id/deployments/:deployment_id` | Delete a specific deployment | +| Deployments: Read and write | `ADMIN_DEPLOYMENTS` | `POST /projects/:id/deployments/:deployment_id/approval` | Approve or reject a blocked deployment | +| Deployments: Read and write | `ADMIN_DEPLOYMENTS` | `PUT /projects/:id/deployments/:deployment_id` | Update a deployment | +| Deployments: Read and write, Environments: Read and write | `ADMIN_DEPLOYMENTS`, `ADMIN_ENVIRONMENTS` | `POST /projects/:id/deployments` | Create a deployment | +| Deployments: Read | `READ_DEPLOYMENTS` | `GET /projects/:id/deployments/:deployment_id/merge_requests` | List of merge requests associated with a deployment | +| Deployments: Read | `READ_DEPLOYMENTS` | `GET /projects/:id/deployments/:deployment_id` | Get a specific deployment | +| Deployments: Read | `READ_DEPLOYMENTS` | `GET /projects/:id/deployments` | List project deployments | +| Environments: Read and write | `ADMIN_ENVIRONMENTS` | `DELETE /projects/:id/environments/:environment_id` | Delete an environment | +| Environments: Read and write | `ADMIN_ENVIRONMENTS` | `DELETE /projects/:id/environments/review_apps` | Delete multiple stopped review apps | +| Environments: Read and write | `ADMIN_ENVIRONMENTS` | `POST /projects/:id/environments/:environment_id/stop` | Stop an environment | +| Environments: Read and write | `ADMIN_ENVIRONMENTS` | `POST /projects/:id/environments/stop_stale` | Stop stale environments | +| Environments: Read and write | `ADMIN_ENVIRONMENTS` | `POST /projects/:id/environments` | Create a new environment | +| Environments: Read and write | `ADMIN_ENVIRONMENTS` | `PUT /projects/:id/environments/:environment_id` | Update an existing environment | +| Environments: Read | `READ_ENVIRONMENTS` | `GET /projects/:id/environments/:environment_id` | Get a specific environment | +| Environments: Read | `READ_ENVIRONMENTS` | `GET /projects/:id/environments` | List environments | +| Jobs: Read and write | `ADMIN_JOBS` | `PUT /projects/:id/pipelines/:pipeline_id/metadata` | Updates pipeline metadata | +| Jobs: Read | `READ_JOBS` | `GET /jobs/:id/artifacts` | Download the artifacts file for job | +| Jobs: Read | `READ_JOBS` | `GET /projects/:id/jobs/:job_id/artifacts/*artifact_path` | Download a specific file from artifacts archive | +| Jobs: Read | `READ_JOBS` | `GET /projects/:id/jobs/:job_id/artifacts` | Download the artifacts archive from a job | +| Jobs: Read | `READ_JOBS` | `GET /projects/:id/jobs/artifacts/:ref_name/download` | Download the artifacts archive from a job | +| Jobs: Read | `READ_JOBS` | `GET /projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path` | Download a specific file from artifacts archive from a ref | +| None | | `DELETE /projects/:id/registry/repositories/:repository_id/tags/:tag_name` | Delete repository tag | +| None | | `DELETE /projects/:id/registry/repositories/:repository_id/tags` | Delete repository tags (in bulk) | +| None | | `DELETE /projects/:id/registry/repositories/:repository_id` | Delete repository | +| None | | `GET /group/:id/-/packages/composer/*package_name` | Composer packages endpoint at group level for package versions metadata | +| None | | `GET /group/:id/-/packages/composer/p/:sha` | Composer packages endpoint at group level for packages list | +| None | | `GET /group/:id/-/packages/composer/p2/*package_name` | Composer v2 packages p2 endpoint at group level for package versions metadata | +| None | | `GET /group/:id/-/packages/composer/packages` | Composer packages endpoint at group level | +| None | | `GET /groups/:id/-/packages/pypi/simple/*package_name` | The PyPi Simple Group Package Endpoint | +| None | | `GET /groups/:id/-/packages/pypi/simple` | The PyPi Simple Group Index Endpoint | +| None | | `GET /job/allowed_agents` | Get current agents | +| None | | `GET /job` | Get current job using job token | +| None | | `GET /packages/conan/v1/conans/search` | Search for packages | +| None | | `GET /packages/conan/v1/ping` | Ping the Conan API | +| None | | `GET /packages/conan/v1/users/authenticate` | Authenticate user against conan CLI | +| None | | `GET /packages/conan/v1/users/check_credentials` | Check for valid user credentials per conan CLI | +| None | | `GET /projects/:id/packages/conan/v1/conans/search` | Search for packages | +| None | | `GET /projects/:id/packages/conan/v1/ping` | Ping the Conan API | +| None | | `GET /projects/:id/packages/conan/v1/users/authenticate` | Authenticate user against conan CLI | +| None | | `GET /projects/:id/packages/conan/v1/users/check_credentials` | Check for valid user credentials per conan CLI | +| None | | `GET /projects/:id/packages/conan/v2/conans/search` | Search for packages | +| None | | `GET /projects/:id/packages/conan/v2/users/check_credentials` | Check for valid user credentials per conan CLI | +| None | | `GET /projects/:id/registry/repositories/:repository_id/tags/:tag_name` | Get details about a repository tag | +| None | | `GET /projects/:id/registry/repositories/:repository_id/tags` | List tags of a repository | +| None | | `GET /projects/:id/registry/repositories` | List container repositories within a project | +| None | | `POST /internal/dast/site_validations/:id/transition` | Transitions a DAST site validation to a new state. | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /groups/:id/-/packages/npm/-/package/*package_name/dist-tags/:tag` | Deletes the given tag | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel` | Delete Package | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /packages/npm/-/package/*package_name/dist-tags/:tag` | Deletes the given tag | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /projects/:id/packages/:package_id/package_files/:package_file_id` | Delete a package file | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /projects/:id/packages/:package_id` | Delete a project package | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel` | Delete Package | +| Packages: Read and write | `ADMIN_PACKAGES` | `DELETE /projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag` | Deletes the given tag | +| Packages: Read and write | `ADMIN_PACKAGES` | `POST /projects/:id/packages/composer` | Composer packages endpoint for registering packages | +| Packages: Read and write | `ADMIN_PACKAGES` | `POST /projects/:id/packages/pypi/authorize` | Authorize the PyPi package upload from workhorse | +| Packages: Read and write | `ADMIN_PACKAGES` | `POST /projects/:id/packages/pypi` | The PyPi Package upload endpoint | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /groups/:id/-/packages/npm/-/package/*package_name/dist-tags/:tag` | Create or Update the given tag for the given NPM package and version | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize` | Workhorse authorize the conan recipe file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/export/:file_name` | Upload recipe package files | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name/authorize` | Workhorse authorize the conan package file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name` | Upload package files | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /packages/npm/-/package/*package_name/dist-tags/:tag` | Create or Update the given tag for the given NPM package and version | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize` | Workhorse authorize the conan recipe file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/export/:file_name` | Upload recipe package files | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name/authorize` | Workhorse authorize the conan package file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name` | Upload package files | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/generic/:package_name/*package_version/(*path/):file_name/authorize` | Workhorse authorize generic package file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/generic/:package_name/*package_version/(*path/):file_name` | Upload package file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/maven/*path/:file_name/authorize` | Workhorse authorize the maven package file upload | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/maven/*path/:file_name` | Upload the maven package file | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag` | Create or Update the given tag for the given NPM package and version | +| Packages: Read and write | `ADMIN_PACKAGES` | `PUT /projects/:id/packages/npm/:package_name` | Create or deprecate NPM package | +| Packages: Read | `READ_PACKAGES` | `GET /groups/:id/-/packages/maven/*path/:file_name` | Download the maven package file at a group level | +| Packages: Read | `READ_PACKAGES` | `GET /groups/:id/-/packages/npm/*package_name` | NPM registry metadata endpoint | +| Packages: Read | `READ_PACKAGES` | `GET /groups/:id/-/packages/npm/-/package/*package_name/dist-tags` | Get all tags for a given an NPM package | +| Packages: Read | `READ_PACKAGES` | `GET /groups/:id/-/packages/pypi/files/:sha256/*file_identifier` | Download a package file from a group | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/digest` | Recipe Digest | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/download_urls` | Recipe Download Urls | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference/digest` | Package Digest | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls` | Package Download Urls | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference` | Package Snapshot | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel` | Recipe Snapshot | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/export/:file_name` | Download recipe files | +| Packages: Read | `READ_PACKAGES` | `GET /packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name` | Download package files | +| Packages: Read | `READ_PACKAGES` | `GET /packages/maven/*path/:file_name` | Download the maven package file at instance level | +| Packages: Read | `READ_PACKAGES` | `GET /packages/npm/*package_name` | NPM registry metadata endpoint | +| Packages: Read | `READ_PACKAGES` | `GET /packages/npm/-/package/*package_name/dist-tags` | Get all tags for a given an NPM package | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/:package_id/package_files` | List package files | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/:package_id/pipelines` | Get the pipelines for a single project package | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/:package_id` | Get a single project package | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/composer/archives/*package_name` | Composer package endpoint to download a package archive | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/digest` | Recipe Digest | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/download_urls` | Recipe Download Urls | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference/digest` | Package Digest | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls` | Package Download Urls | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference` | Package Snapshot | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel` | Recipe Snapshot | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/export/:file_name` | Download recipe files | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name` | Download package files | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/conan/v2/conans/:package_name/:package_version/:package_username/:package_channel/revisions/:recipe_revision/files/:file_name` | Download recipe files | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/generic/:package_name/*package_version/(*path/):file_name` | Download package file | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/go/*module_name/@v/:module_version.info` | Version metadata | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/go/*module_name/@v/:module_version.mod` | Download module file | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/go/*module_name/@v/:module_version.zip` | Download module source | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/go/*module_name/@v/list` | List | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/maven/*path/:file_name` | Download the maven package file at a project level | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/npm/*package_name/-/*file_name` | Download the NPM tarball | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/npm/*package_name` | NPM registry metadata endpoint | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/npm/-/package/*package_name/dist-tags` | Get all tags for a given an NPM package | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/pypi/files/:sha256/*file_identifier` | The PyPi package download endpoint | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/pypi/simple/*package_name` | The PyPi Simple Project Package Endpoint | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages/pypi/simple` | The PyPi Simple Project Index Endpoint | +| Packages: Read | `READ_PACKAGES` | `GET /projects/:id/packages` | Get a list of project packages | +| Packages: Read | `READ_PACKAGES` | `POST /groups/:id/-/packages/npm/-/npm/v1/security/advisories/bulk` | NPM registry bulk advisory endpoint | +| Packages: Read | `READ_PACKAGES` | `POST /groups/:id/-/packages/npm/-/npm/v1/security/audits/quick` | NPM registry quick audit endpoint | +| Packages: Read | `READ_PACKAGES` | `POST /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls` | Package Upload Urls | +| Packages: Read | `READ_PACKAGES` | `POST /packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/upload_urls` | Recipe Upload Urls | +| Packages: Read | `READ_PACKAGES` | `POST /packages/npm/-/npm/v1/security/advisories/bulk` | NPM registry bulk advisory endpoint | +| Packages: Read | `READ_PACKAGES` | `POST /packages/npm/-/npm/v1/security/audits/quick` | NPM registry quick audit endpoint | +| Packages: Read | `READ_PACKAGES` | `POST /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls` | Package Upload Urls | +| Packages: Read | `READ_PACKAGES` | `POST /projects/:id/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel/upload_urls` | Recipe Upload Urls | +| Packages: Read | `READ_PACKAGES` | `POST /projects/:id/packages/npm/-/npm/v1/security/advisories/bulk` | NPM registry bulk advisory endpoint | +| Packages: Read | `READ_PACKAGES` | `POST /projects/:id/packages/npm/-/npm/v1/security/audits/quick` | NPM registry quick audit endpoint | +| Releases: Read and write | `ADMIN_RELEASES` | `DELETE /projects/:id/releases/:tag_name/assets/links/:link_id` | Delete a release link | +| Releases: Read and write | `ADMIN_RELEASES` | `DELETE /projects/:id/releases/:tag_name` | Delete a release | +| Releases: Read and write | `ADMIN_RELEASES` | `POST /projects/:id/catalog/publish` | Publish a new component project release as version to the CI/CD catalog | +| Releases: Read and write | `ADMIN_RELEASES` | `POST /projects/:id/releases/:tag_name/assets/links` | Create a release link | +| Releases: Read and write | `ADMIN_RELEASES` | `POST /projects/:id/releases/:tag_name/evidence` | Collect release evidence | +| Releases: Read and write | `ADMIN_RELEASES` | `POST /projects/:id/releases` | Create a release | +| Releases: Read and write | `ADMIN_RELEASES` | `PUT /projects/:id/releases/:tag_name/assets/links/:link_id` | Update a release link | +| Releases: Read and write | `ADMIN_RELEASES` | `PUT /projects/:id/releases/:tag_name` | Update a release | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/releases/:tag_name/assets/links/:link_id` | Get a release link | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/releases/:tag_name/assets/links` | List links of a release | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/releases/:tag_name/downloads/*direct_asset_path` | Download a project release asset file | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/releases/:tag_name` | Get a release by a tag name | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/releases/permalink/latest(/)(*suffix_path)` | Get the latest project release | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/releases` | List Releases | +| Releases: Read | `READ_RELEASES` | `GET /projects/:id/repository/changelog` | Generates a changelog section for a release and returns it | +| Secure files: Read and write | `ADMIN_SECURE_FILES` | `DELETE /projects/:id/secure_files/:secure_file_id` | Remove a secure file | +| Secure files: Read and write | `ADMIN_SECURE_FILES` | `POST /projects/:id/secure_files` | Create a secure file | +| Secure files: Read | `READ_SECURE_FILES` | `GET /projects/:id/secure_files/:secure_file_id/download` | Download secure file | +| Secure files: Read | `READ_SECURE_FILES` | `GET /projects/:id/secure_files/:secure_file_id` | Get the details of a specific secure file in a project | +| Secure files: Read | `READ_SECURE_FILES` | `GET /projects/:id/secure_files` | Get list of secure files in a project | +| Terraform state: Read and write | `ADMIN_TERRAFORM_STATE` | `DELETE /projects/:id/terraform/state/:name/lock` | Unlock a Terraform state of a certain name | +| Terraform state: Read and write | `ADMIN_TERRAFORM_STATE` | `DELETE /projects/:id/terraform/state/:name/versions/:serial` | Delete a Terraform state version | +| Terraform state: Read and write | `ADMIN_TERRAFORM_STATE` | `DELETE /projects/:id/terraform/state/:name` | Delete a Terraform state of a certain name | +| Terraform state: Read and write | `ADMIN_TERRAFORM_STATE` | `POST /projects/:id/terraform/state/:name/lock` | Lock a Terraform state of a certain name | +| Terraform state: Read and write | `ADMIN_TERRAFORM_STATE` | `POST /projects/:id/terraform/state/:name` | Add a new Terraform state or update an existing one | +| Terraform state: Read | `READ_TERRAFORM_STATE` | `GET /projects/:id/terraform/state/:name/versions/:serial` | Get a Terraform state version | +| Terraform state: Read | `READ_TERRAFORM_STATE` | `GET /projects/:id/terraform/state/:name` | Get a Terraform state by its name | diff --git a/ee/lib/api/internal/app_sec/dast/site_validations.rb b/ee/lib/api/internal/app_sec/dast/site_validations.rb index e94911062c5ef..ad637906f1ba7 100644 --- a/ee/lib/api/internal/app_sec/dast/site_validations.rb +++ b/ee/lib/api/internal/app_sec/dast/site_validations.rb @@ -17,6 +17,7 @@ class SiteValidations < ::API::Base resource :site_validations do desc 'Transitions a DAST site validation to a new state.' route_setting :authentication, job_token_allowed: true + route_setting :authorization, skip_job_token_policies: true params do requires :event, type: Symbol, values: %i[start fail_op retry pass], desc: 'The transition event.' end diff --git a/ee/lib/ee/api/deployments.rb b/ee/lib/ee/api/deployments.rb index 4f5c22262c826..48929fb3b02cb 100644 --- a/ee/lib/ee/api/deployments.rb +++ b/ee/lib/ee/api/deployments.rb @@ -22,6 +22,7 @@ module Deployments optional :represented_as, type: String, desc: 'The name of the User/Group/Role to use for the approval, when the user belongs to multiple approval rules' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_deployments post ':id/deployments/:deployment_id/approval' do authorize! :read_deployment, user_project diff --git a/ee/lib/ee/api/releases.rb b/ee/lib/ee/api/releases.rb index 90718b1471840..5502410f68ff5 100644 --- a/ee/lib/ee/api/releases.rb +++ b/ee/lib/ee/api/releases.rb @@ -20,6 +20,7 @@ module Releases requires :tag_name, type: String, desc: 'The Git tag the release is associated with', as: :tag end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases post ':id/releases/:tag_name/evidence', requirements: ::API::Releases::RELEASE_ENDPOINT_REQUIREMENTS do authorize_create_evidence! diff --git a/ee/spec/requests/api/deployments_spec.rb b/ee/spec/requests/api/deployments_spec.rb index 5c9b55de1a4d6..0e33c7b43a8a1 100644 --- a/ee/spec/requests/api/deployments_spec.rb +++ b/ee/spec/requests/api/deployments_spec.rb @@ -369,6 +369,14 @@ ) end + it_behaves_like 'enforcing job token policies', :admin_deployments do + before do + source_project.add_maintainer(user) + end + + let(:request) { post(api(path), params: { status: 'approved', job_token: target_job.token }) } + end + context 'when user is authorized to read project' do before do project.add_developer(user) diff --git a/ee/spec/requests/api/releases_spec.rb b/ee/spec/requests/api/releases_spec.rb index e555b6c25d67a..2cf3323148c29 100644 --- a/ee/spec/requests/api/releases_spec.rb +++ b/ee/spec/requests/api/releases_spec.rb @@ -272,6 +272,14 @@ ) end + it_behaves_like 'enforcing job token policies', :admin_releases do + let(:user) { maintainer } + let(:request) do + post(api("/projects/#{source_project.id}/releases/#{tag_name}/evidence"), + params: { job_token: target_job.token }) + end + end + it 'accepts the request' do post api("/projects/#{project.id}/releases/#{tag_name}/evidence", maintainer) diff --git a/lefthook.yml b/lefthook.yml index e1e957fc0872a..e948f9d71f242 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -122,6 +122,11 @@ pre-push: files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD glob: '{ee/config/custom_abilities/*yml,doc/user/custom_roles/abilities.md,tooling/custom_roles/docs/templates/custom_abilities.md.erb}' run: bundle exec rake gitlab:custom_roles:check_docs + verify_job_token_policies: + tags: backend + files: git diff --name-only $(git merge-base origin/master HEAD)..HEAD + glob: '{{,ee/}lib/api/*.rb,app/validators/json_schemas/ci_job_token_policies.json,doc/ci/jobs/granular_permissions.md,tooling/ci/job_tokens/docs/templates/granular_permissions.md.erb}' + run: bundle exec rake ci:job_tokens:check_policies rubocop: tags: backend style files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD diff --git a/lib/api/ci/catalog.rb b/lib/api/ci/catalog.rb index abdcab90adac3..2acca534d7766 100644 --- a/lib/api/ci/catalog.rb +++ b/lib/api/ci/catalog.rb @@ -25,6 +25,7 @@ class Catalog < ::API::Base requires :metadata, type: Hash, desc: 'The metadata for the release' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases # Note: This endpoint should only be used by `release-cli` and should be authenticated with a job token. # For this reason, we should not document the endpoint in the API docs. post ':id/catalog/publish' do diff --git a/lib/api/ci/job_artifacts.rb b/lib/api/ci/job_artifacts.rb index 71ebdb9da9c4d..e64e8c348f6df 100644 --- a/lib/api/ci/job_artifacts.rb +++ b/lib/api/ci/job_artifacts.rb @@ -39,6 +39,7 @@ def audit_download(build, filename); end 'available only on Premium and Ultimate tiers.' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_jobs get ':id/jobs/artifacts/:ref_name/download', urgency: :low, requirements: { ref_name: /.+/ } do @@ -69,6 +70,7 @@ def audit_download(build, filename); end 'available only on Premium and Ultimate tiers.' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_jobs get ':id/jobs/artifacts/:ref_name/raw/*artifact_path', urgency: :low, format: false, @@ -101,6 +103,7 @@ def audit_download(build, filename); end 'available only on Premium and Ultimate tiers.' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_jobs get ':id/jobs/:job_id/artifacts', urgency: :low do authorize_download_artifacts! @@ -127,6 +130,7 @@ def audit_download(build, filename); end 'available only on Premium and Ultimate tiers.' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_jobs get ':id/jobs/:job_id/artifacts/*artifact_path', urgency: :low, format: false do authorize_download_artifacts! diff --git a/lib/api/ci/jobs.rb b/lib/api/ci/jobs.rb index 88b1d1b6bfb8f..94ee4020e5e3d 100644 --- a/lib/api/ci/jobs.rb +++ b/lib/api/ci/jobs.rb @@ -240,6 +240,7 @@ class Jobs < ::API::Base ] end route_setting :authentication, job_token_allowed: true + route_setting :authorization, skip_job_token_policies: true get '', feature_category: :continuous_integration, urgency: :low do validate_current_authenticated_job @@ -256,6 +257,7 @@ class Jobs < ::API::Base ] end route_setting :authentication, job_token_allowed: true + route_setting :authorization, skip_job_token_policies: true get '/allowed_agents', urgency: :default, feature_category: :deployment_management do validate_current_authenticated_job diff --git a/lib/api/ci/pipelines.rb b/lib/api/ci/pipelines.rb index 267798dd80d0c..d46f0f7f6406d 100644 --- a/lib/api/ci/pipelines.rb +++ b/lib/api/ci/pipelines.rb @@ -287,6 +287,7 @@ class Pipelines < ::API::Base requires :name, type: String, desc: 'The name of the pipeline', documentation: { example: 'Deployment to production' } end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_jobs put ':id/pipelines/:pipeline_id/metadata', urgency: :low, feature_category: :continuous_integration do authorize! :update_pipeline, pipeline diff --git a/lib/api/ci/runner.rb b/lib/api/ci/runner.rb index 43c03fd01aaef..de71077ad1153 100644 --- a/lib/api/ci/runner.rb +++ b/lib/api/ci/runner.rb @@ -395,8 +395,10 @@ class Runner < ::API::Base optional :direct_download, default: false, type: Boolean, desc: 'Perform direct download from remote storage instead of proxying artifacts' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_jobs get '/:id/artifacts', feature_category: :job_artifacts do authenticate_job_via_dependent_job! + authorize_job_token_policies!(current_job.project) audit_download(current_job, current_job.artifacts_file&.filename) if current_job.artifacts_file present_artifacts_file!(current_job.artifacts_file, supports_direct_download: params[:direct_download]) diff --git a/lib/api/ci/secure_files.rb b/lib/api/ci/secure_files.rb index a5f4a604dc859..eca1e282b7466 100644 --- a/lib/api/ci/secure_files.rb +++ b/lib/api/ci/secure_files.rb @@ -29,6 +29,7 @@ class SecureFiles < ::API::Base use :pagination end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_secure_files get ':id/secure_files' do secure_files = user_project.secure_files.order_by_created_at present paginate(secure_files), with: Entities::Ci::SecureFile @@ -44,6 +45,7 @@ class SecureFiles < ::API::Base end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_secure_files get ':id/secure_files/:secure_file_id' do secure_file = user_project.secure_files.find(params[:secure_file_id]) present secure_file, with: Entities::Ci::SecureFile @@ -54,6 +56,7 @@ class SecureFiles < ::API::Base tags %w[secure_files] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_secure_files get ':id/secure_files/:secure_file_id/download' do secure_file = user_project.secure_files.find(params[:secure_file_id]) @@ -79,6 +82,7 @@ class SecureFiles < ::API::Base requires :file, types: [Rack::Multipart::UploadedFile, ::API::Validations::Types::WorkhorseFile], desc: 'The secure file being uploaded', documentation: { type: 'file' } end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_secure_files post ':id/secure_files' do secure_file = user_project.secure_files.new( name: Gitlab::PathTraversal.check_path_traversal!(params[:name]) @@ -101,6 +105,7 @@ class SecureFiles < ::API::Base failure [{ code: 404, message: '404 Not found' }] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_secure_files delete ':id/secure_files/:secure_file_id' do secure_file = user_project.secure_files.find(params[:secure_file_id]) diff --git a/lib/api/composer_packages.rb b/lib/api/composer_packages.rb index 4a3be01e3d8fc..736a27ae8ff34 100644 --- a/lib/api/composer_packages.rb +++ b/lib/api/composer_packages.rb @@ -79,6 +79,7 @@ def presenter tags %w[composer_packages] end route_setting :authentication, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, skip_job_token_policies: true get ':id/-/packages/composer/packages', urgency: :low do presenter.root end @@ -96,6 +97,7 @@ def presenter requires :sha, type: String, desc: 'Shasum of current json', documentation: { example: '673594f85a55fe3c0eb45df7bd2fa9d95a1601ab' } end route_setting :authentication, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, skip_job_token_policies: true get ':id/-/packages/composer/p/:sha', urgency: :low do presenter.provider end @@ -113,6 +115,7 @@ def presenter requires :package_name, type: String, file_path: true, desc: 'The Composer package name', documentation: { example: 'my-composer-package' } end route_setting :authentication, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, skip_job_token_policies: true get ':id/-/packages/composer/p2/*package_name', requirements: COMPOSER_ENDPOINT_REQUIREMENTS, file_path: true, urgency: :low do not_found! if packages.empty? @@ -132,6 +135,7 @@ def presenter requires :package_name, type: String, file_path: true, desc: 'The Composer package name', documentation: { example: 'my-composer-package' } end route_setting :authentication, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, skip_job_token_policies: true get ':id/-/packages/composer/*package_name', requirements: COMPOSER_ENDPOINT_REQUIREMENTS, file_path: true, urgency: :low do not_found! if packages.empty? not_found! if params[:sha].blank? @@ -147,6 +151,7 @@ def presenter resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do namespace ':id/packages/composer' do route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages desc 'Composer packages endpoint for registering packages' do detail 'This feature was introduced in GitLab 13.1' @@ -199,8 +204,10 @@ def presenter requires :package_name, type: String, file_path: true, desc: 'The Composer package name', documentation: { example: 'my-composer-package' } end route_setting :authentication, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get 'archives/*package_name', urgency: :default do project = authorized_user_project(action: :read_package) + authorize_job_token_policies!(project) package = ::Packages::Composer::Package .for_projects(project) diff --git a/lib/api/conan/v2/project_packages.rb b/lib/api/conan/v2/project_packages.rb index d69e7f75257a8..ca4d5e025873d 100644 --- a/lib/api/conan/v2/project_packages.rb +++ b/lib/api/conan/v2/project_packages.rb @@ -58,8 +58,11 @@ class ProjectPackages < ::API::Base end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'revisions/:recipe_revision/files/:file_name', requirements: FILE_NAME_REQUIREMENTS do + authorize_job_token_policies!(project) + render_api_error!('Not supported', :not_found) end end diff --git a/lib/api/concerns/packages/conan/shared_endpoints.rb b/lib/api/concerns/packages/conan/shared_endpoints.rb index 0f7c52f93ab53..2b8f941318937 100644 --- a/lib/api/concerns/packages/conan/shared_endpoints.rb +++ b/lib/api/concerns/packages/conan/shared_endpoints.rb @@ -67,6 +67,7 @@ module SharedEndpoints end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, skip_job_token_policies: true get 'check_credentials', urgency: :default do :ok @@ -87,6 +88,7 @@ module SharedEndpoints end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, skip_job_token_policies: true get 'conans/search', urgency: :low do service = ::Packages::Conan::SearchService.new(search_project, current_user, query: params[:q]).execute diff --git a/lib/api/concerns/packages/conan/v1_endpoints.rb b/lib/api/concerns/packages/conan/v1_endpoints.rb index 555b06465249f..cae70eb2de957 100644 --- a/lib/api/concerns/packages/conan/v1_endpoints.rb +++ b/lib/api/concerns/packages/conan/v1_endpoints.rb @@ -38,6 +38,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, skip_job_token_policies: true get 'ping', urgency: :default do header 'X-Conan-Server-Capabilities', x_conan_server_capabilities_header.join(',') @@ -62,6 +63,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, skip_job_token_policies: true get 'authenticate', urgency: :low do unauthorized! unless token @@ -108,6 +110,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'packages/:conan_package_reference', urgency: :low do authorize_read_package!(project) @@ -134,6 +137,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get urgency: :low do authorize_read_package!(project) @@ -164,6 +168,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'packages/:conan_package_reference/digest', urgency: :low do present_package_download_urls @@ -181,6 +186,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'digest', urgency: :low do present_recipe_download_urls @@ -209,6 +215,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'packages/:conan_package_reference/download_urls', urgency: :low do present_package_download_urls @@ -226,6 +233,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'download_urls', urgency: :low do present_recipe_download_urls @@ -255,6 +263,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages post 'packages/:conan_package_reference/upload_urls', urgency: :low do authorize_read_package!(project) @@ -275,6 +284,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages post 'upload_urls', urgency: :low do authorize_read_package!(project) @@ -295,6 +305,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages delete urgency: :low do authorize!(:destroy_package, project) @@ -347,6 +358,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get urgency: :low do download_package_file(:recipe_file) @@ -371,6 +383,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages put urgency: :low do upload_package_file(:recipe_file) @@ -389,6 +402,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages put 'authorize', urgency: :low do authorize_workhorse!(subject: project, maximum_size: project.actual_limits.conan_max_file_size) @@ -416,6 +430,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get urgency: :low do download_package_file(:package_file) @@ -434,6 +449,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages put 'authorize', urgency: :low do authorize_workhorse!(subject: project, maximum_size: project.actual_limits.conan_max_file_size) @@ -458,6 +474,7 @@ def x_conan_server_capabilities_header end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages put urgency: :low do upload_package_file(:package_file) diff --git a/lib/api/concerns/packages/npm_endpoints.rb b/lib/api/concerns/packages/npm_endpoints.rb index c267419ad3caf..15053871925ca 100644 --- a/lib/api/concerns/packages/npm_endpoints.rb +++ b/lib/api/concerns/packages/npm_endpoints.rb @@ -79,6 +79,7 @@ def bad_request_missing_attribute!(attribute) end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, authenticate_non_public: true + route_setting :authorization, job_token_policies: :read_packages get 'dist-tags', format: false, requirements: ::API::Helpers::Packages::Npm::NPM_ENDPOINT_REQUIREMENTS do package_name = params[:package_name] @@ -113,6 +114,7 @@ def bad_request_missing_attribute!(attribute) tags %w[npm_packages] end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages put format: false do package_name = params[:package_name] version = env['api.request.body'] @@ -150,6 +152,7 @@ def bad_request_missing_attribute!(attribute) tags %w[npm_packages] end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages delete format: false do package_name = params[:package_name] tag = params[:tag] @@ -191,6 +194,7 @@ def bad_request_missing_attribute!(attribute) tags %w[npm_packages] end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages post '-/npm/v1/security/advisories/bulk' do redirect_or_present_audit_report end @@ -210,6 +214,7 @@ def bad_request_missing_attribute!(attribute) tags %w[npm_packages] end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages post '-/npm/v1/security/audits/quick' do redirect_or_present_audit_report end diff --git a/lib/api/concerns/packages/npm_namespace_endpoints.rb b/lib/api/concerns/packages/npm_namespace_endpoints.rb index 53e85ed0f0104..f3224702a3c84 100644 --- a/lib/api/concerns/packages/npm_namespace_endpoints.rb +++ b/lib/api/concerns/packages/npm_namespace_endpoints.rb @@ -59,6 +59,7 @@ def project end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, authenticate_non_public: true + route_setting :authorization, job_token_policies: :read_packages get '*package_name', format: false, requirements: ::API::Helpers::Packages::Npm::NPM_ENDPOINT_REQUIREMENTS do package_name = declared_params[:package_name] packages = @@ -79,6 +80,8 @@ def project target: project_or_nil, package_name: package_name ) do + authorize_job_token_policies!(project_or_nil) if project_or_nil + if Feature.enabled?(:npm_allow_packages_in_multiple_projects, group_or_namespace) available_packages_to_user = ::Packages::Npm::PackagesForUserFinder.new( current_user, diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index d36120e2bc16c..614cd55ca30f3 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -69,6 +69,7 @@ class Deployments < ::API::Base end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_deployments get ':id/deployments' do authorize! :read_deployment, user_project @@ -94,6 +95,7 @@ class Deployments < ::API::Base requires :deployment_id, type: Integer, desc: 'The ID of the deployment' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_deployments get ':id/deployments/:deployment_id' do authorize! :read_deployment, user_project @@ -182,6 +184,7 @@ class Deployments < ::API::Base values: %w[running success failed canceled] end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_deployments put ':id/deployments/:deployment_id' do authorize!(:read_deployment, user_project) @@ -215,6 +218,7 @@ class Deployments < ::API::Base requires :deployment_id, type: Integer, desc: 'The ID of the deployment' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_deployments delete ':id/deployments/:deployment_id' do deployment = user_project.deployments.find(params[:deployment_id]) @@ -249,6 +253,7 @@ class Deployments < ::API::Base use :merge_requests_base_params end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_deployments get ':id/deployments/:deployment_id/merge_requests' do authorize! :read_deployment, user_project diff --git a/lib/api/environments.rb b/lib/api/environments.rb index 4e1d091706120..95e27867a3718 100644 --- a/lib/api/environments.rb +++ b/lib/api/environments.rb @@ -74,6 +74,7 @@ class Environments < ::API::Base optional :auto_stop_setting, type: String, default: "always", values: Environment.auto_stop_settings.keys, desc: 'The auto stop setting for the environment. Allowed values are `always` and `with_action`' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_environments post ':id/environments' do authorize! :create_environment, user_project @@ -116,6 +117,7 @@ class Environments < ::API::Base optional :auto_stop_setting, type: String, values: Environment.auto_stop_settings.keys, desc: 'The auto stop setting for the environment. Allowed values are `always` and `with_action`' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_environments put ':id/environments/:environment_id' do authorize! :update_environment, user_project @@ -155,6 +157,7 @@ class Environments < ::API::Base optional :dry_run, type: Boolean, desc: "Defaults to true for safety reasons. It performs a dry run where no actual deletion will be performed. Set to false to actually delete the environment", default: true end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_environments delete ":id/environments/review_apps" do authorize! :read_environment, user_project @@ -186,6 +189,7 @@ class Environments < ::API::Base requires :environment_id, type: Integer, desc: 'The ID of the environment' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_environments delete ':id/environments/:environment_id' do authorize! :read_environment, user_project @@ -209,6 +213,7 @@ class Environments < ::API::Base optional :force, type: Boolean, default: false, desc: 'Force environment to stop without executing `on_stop` actions' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_environments post ':id/environments/:environment_id/stop' do authorize! :read_environment, user_project @@ -234,6 +239,7 @@ class Environments < ::API::Base desc: 'Stop all environments that were last modified or deployed to before this date.' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_environments post ':id/environments/stop_stale' do authorize! :stop_environment, user_project @@ -262,6 +268,7 @@ class Environments < ::API::Base requires :environment_id, type: Integer, desc: 'The ID of the environment' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_environments get ':id/environments/:environment_id' do authorize! :read_environment, user_project diff --git a/lib/api/generic_packages.rb b/lib/api/generic_packages.rb index e41e062c38cec..9404e5e1397fd 100644 --- a/lib/api/generic_packages.rb +++ b/lib/api/generic_packages.rb @@ -38,6 +38,7 @@ class GenericPackages < ::API::Base end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages params do requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.generic_package_name_regex, file_path: true @@ -47,6 +48,7 @@ class GenericPackages < ::API::Base end put 'authorize' do + authorize_job_token_policies!(authorized_user_project) authorize_workhorse!(**authorize_workhorse_params) end @@ -76,11 +78,13 @@ class GenericPackages < ::API::Base end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages put do project = authorized_user_project authorize_upload!(project) + authorize_job_token_policies!(project) bad_request!('File is too large') if max_file_size_exceeded? track_package_event('push_package', :generic, project: project, namespace: project.namespace) @@ -124,11 +128,13 @@ class GenericPackages < ::API::Base end route_setting :authentication, job_token_allowed: %i[request basic_auth], basic_auth_personal_access_token: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get do project = authorized_user_project(action: :read_package) authorize_read_package!(project) + authorize_job_token_policies!(project) package = ::Packages::Generic::PackageFinder.new(project).execute!(params[:package_name], params[:package_version]) package_file = ::Packages::PackageFileFinder.new(package, encoded_file_name).execute! diff --git a/lib/api/go_proxy.rb b/lib/api/go_proxy.rb index fb3d6d005a696..eaedc1531db48 100755 --- a/lib/api/go_proxy.rb +++ b/lib/api/go_proxy.rb @@ -69,6 +69,7 @@ def find_version end route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true, authenticate_non_public: true + route_setting :authorization, job_token_policies: :read_packages resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do before do authorize_read_package!(project) diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 5f8d88293f6f3..31525ad523553 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -169,7 +169,7 @@ def find_project!(id) return handle_job_token_failure!(project) end - return forbidden!(job_token_policies_unauthorized_message(project)) unless job_token_policies_authorized?(project) + authorize_job_token_policies!(project) && return if project_moved?(id, project) return not_allowed!('Non GET methods are not allowed for moved projects') unless request.get? @@ -180,6 +180,10 @@ def find_project!(id) project end + def authorize_job_token_policies!(project) + forbidden!(job_token_policies_unauthorized_message(project)) unless job_token_policies_authorized?(project) + end + def read_project_ability :read_project end @@ -1021,6 +1025,7 @@ def handle_job_token_failure!(project) def job_token_policies_authorized?(project) return true unless current_user&.from_ci_job_token? return true unless Feature.enabled?(:enforce_job_token_policies, current_user) + return true if skip_job_token_policies? current_user.ci_job_token_scope.policies_allowed?(project, job_token_policies) end @@ -1046,6 +1051,12 @@ def job_token_policies Array(route_setting(:authorization).try(:fetch, :job_token_policies, nil)) end + + def skip_job_token_policies? + return false unless respond_to?(:route_setting) + + route_setting(:authorization).try(:fetch, :skip_job_token_policies, false) + end end end diff --git a/lib/api/helpers/packages_helpers.rb b/lib/api/helpers/packages_helpers.rb index ca02ed3e704d6..479811e510cd5 100644 --- a/lib/api/helpers/packages_helpers.rb +++ b/lib/api/helpers/packages_helpers.rb @@ -87,6 +87,8 @@ def user_project_with_read_package return forbidden! unless authorized_project_scope?(project) + project && authorize_job_token_policies!(project) && return + return project if can?(current_user, :read_package, project&.packages_policy_subject) # guest users can have :read_project but not :read_package return forbidden! if can?(current_user, :read_project, project) diff --git a/lib/api/maven_packages.rb b/lib/api/maven_packages.rb index a62e84f51efbd..d10a2f12fcbb8 100644 --- a/lib/api/maven_packages.rb +++ b/lib/api/maven_packages.rb @@ -102,6 +102,7 @@ def find_and_present_package_file(package, file_name, format, params) use :path_and_file_name end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get 'packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do # return a similar failure to authorize_read_package!(project) @@ -115,6 +116,7 @@ def find_and_present_package_file(package, file_name, format, params) project = find_project_by_path(params[:path]) authorize_read_package!(project) + authorize_job_token_policies!(project) package = fetch_package(file_name: file_name, project: project) @@ -156,6 +158,7 @@ def find_and_present_package_file(package, file_name, format, params) use :path_and_file_name end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get ':id/-/packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do # return a similar failure to group = find_group(params[:id]) group = find_authorized_group!(action: :read_package_within_public_registries) @@ -167,7 +170,10 @@ def find_and_present_package_file(package, file_name, format, params) file_name, format = extract_format(params[:file_name]) package = fetch_package(file_name: file_name, group: group) - authorize_read_package!(package.project) if package + if package + authorize_read_package!(package.project) + authorize_job_token_policies!(package.project) + end find_and_present_package_file(package, file_name, format, params.merge(target: group)) end @@ -194,6 +200,7 @@ def find_and_present_package_file(package, file_name, format, params) use :path_and_file_name end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :read_packages get ':id/packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do project = authorized_user_project(action: :read_package) @@ -203,6 +210,7 @@ def find_and_present_package_file(package, file_name, format, params) end authorize_read_package!(project) + authorize_job_token_policies!(project) file_name, format = extract_format(params[:file_name]) @@ -227,6 +235,7 @@ def find_and_present_package_file(package, file_name, format, params) requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.maven_file_name_regex, documentation: { example: 'mypkg-1.0-SNAPSHOT.pom' } end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages put ':id/packages/maven/*path/:file_name/authorize', requirements: MAVEN_ENDPOINT_REQUIREMENTS do authorize_upload! @@ -253,6 +262,7 @@ def find_and_present_package_file(package, file_name, format, params) requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)', documentation: { type: 'file' } end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, basic_auth_personal_access_token: true + route_setting :authorization, job_token_policies: :admin_packages put ':id/packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do unprocessable_entity! if Gitlab::FIPS.enabled? && params[:file].md5 authorize_upload! diff --git a/lib/api/npm_project_packages.rb b/lib/api/npm_project_packages.rb index fa05fd2dfedc2..3152934390188 100644 --- a/lib/api/npm_project_packages.rb +++ b/lib/api/npm_project_packages.rb @@ -61,6 +61,7 @@ def project_id_or_nil requires :file_name, type: String, desc: 'Package file name' end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get '*package_name/-/*file_name', format: false do authorize_read_package!(project) @@ -94,6 +95,7 @@ def project_id_or_nil requires :versions, type: Hash, desc: 'Package version info' end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages put ':package_name', requirements: ::API::Helpers::Packages::Npm::NPM_ENDPOINT_REQUIREMENTS do if headers['Npm-Command'] == 'deprecate' authorize_destroy_package!(project) @@ -139,6 +141,7 @@ def project_id_or_nil end route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true, authenticate_non_public: true + route_setting :authorization, job_token_policies: :read_packages get '*package_name', format: false, requirements: ::API::Helpers::Packages::Npm::NPM_ENDPOINT_REQUIREMENTS do package_name = declared_params[:package_name] packages = ::Packages::Npm::PackageFinder.new(project: project_or_nil, params: { package_name: package_name }).execute diff --git a/lib/api/package_files.rb b/lib/api/package_files.rb index 6a02769519f2c..df0ceddb5eb3f 100644 --- a/lib/api/package_files.rb +++ b/lib/api/package_files.rb @@ -31,6 +31,7 @@ class PackageFiles < ::API::Base use :pagination end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get ':id/packages/:package_id/package_files' do package = ::Packages::PackageFinder .new(user_project, params[:package_id]).execute @@ -54,6 +55,7 @@ class PackageFiles < ::API::Base requires :package_file_id, type: Integer, desc: 'ID of a package file' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages delete ':id/packages/:package_id/package_files/:package_file_id' do authorize_destroy_package!(user_project) diff --git a/lib/api/project_container_repositories.rb b/lib/api/project_container_repositories.rb index f6f9b96b068f6..ef47245aa514b 100644 --- a/lib/api/project_container_repositories.rb +++ b/lib/api/project_container_repositories.rb @@ -20,6 +20,7 @@ class ProjectContainerRepositories < ::API::Base requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' end route_setting :authentication, job_token_allowed: true, job_token_scope: :project + route_setting :authorization, skip_job_token_policies: true resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'List container repositories within a project' do detail 'This feature was introduced in GitLab 11.8.' diff --git a/lib/api/project_packages.rb b/lib/api/project_packages.rb index d9a8613069a56..9be1a97a4e74d 100644 --- a/lib/api/project_packages.rb +++ b/lib/api/project_packages.rb @@ -54,6 +54,7 @@ def package desc: 'Return packages with specified status' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get ':id/packages' do packages = ::Packages::PackagesFinder.new( user_project, @@ -76,6 +77,7 @@ def package requires :package_id, type: Integer, desc: 'The ID of a package' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get ':id/packages/:package_id' do render_api_error!('Package not found', 404) unless package.detailed_info? @@ -102,6 +104,7 @@ def package values: 1..20 end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_packages get ':id/packages/:package_id/pipelines' do not_found!('Package not found') unless package.detailed_info? @@ -129,6 +132,7 @@ def package requires :package_id, type: Integer, desc: 'The ID of a package' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_packages delete ':id/packages/:package_id' do authorize_destroy_package!(user_project) diff --git a/lib/api/pypi_packages.rb b/lib/api/pypi_packages.rb index 9cd114994b263..d50ce9a679814 100644 --- a/lib/api/pypi_packages.rb +++ b/lib/api/pypi_packages.rb @@ -131,6 +131,7 @@ def validate_fips! end route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :read_packages get 'files/:sha256/*file_identifier' do group = find_authorized_group! authorize_read_package!(group) @@ -139,6 +140,7 @@ def validate_fips! package = Packages::Pypi::PackageFinder.new(current_user, group, { filename: filename, sha256: params[:sha256] }).execute package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute + authorize_job_token_policies!(package.project) track_package_event('pull_package', :pypi, namespace: group, project: package.project) present_package_file!(package_file, supports_direct_download: true) @@ -158,6 +160,7 @@ def validate_fips! # An API entry point but returns an HTML file instead of JSON. # PyPi simple API returns a list of packages as a simple HTML file. route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, skip_job_token_policies: true get 'simple', format: :txt do present_simple_index(find_authorized_group!) end @@ -180,6 +183,7 @@ def validate_fips! # An API entry point but returns an HTML file instead of JSON. # PyPi simple API returns the package descriptor as a simple HTML file. route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, skip_job_token_policies: true get 'simple/*package_name', format: :txt do present_simple_package(find_authorized_group!) end @@ -208,8 +212,10 @@ def validate_fips! end route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :read_packages get 'files/:sha256/*file_identifier' do project = project! + authorize_job_token_policies!(project) filename = "#{params[:file_identifier]}.#{params[:format]}" package = Packages::Pypi::PackageFinder.new(current_user, project, { filename: filename, sha256: params[:sha256] }).execute @@ -234,8 +240,11 @@ def validate_fips! # An API entry point but returns an HTML file instead of JSON. # PyPi simple API returns a list of packages as a simple HTML file. route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :read_packages get 'simple', format: :txt do - present_simple_index(project!) + project = project! + authorize_job_token_policies!(project) + present_simple_index(project) end desc 'The PyPi Simple Project Package Endpoint' do @@ -256,8 +265,11 @@ def validate_fips! # An API entry point but returns an HTML file instead of JSON. # PyPi simple API returns the package descriptor as a simple HTML file. route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :read_packages get 'simple/*package_name', format: :txt do - present_simple_package(project!) + project = project! + authorize_job_token_policies!(project) + present_simple_package(project) end desc 'The PyPi Package upload endpoint' do @@ -290,9 +302,11 @@ def validate_fips! end route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_packages post do project = project!(action: :read_project) authorize_upload!(project) + authorize_job_token_policies!(project) if project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size) bad_request!('File is too large') @@ -331,8 +345,10 @@ def validate_fips! end route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_packages post 'authorize' do project = project!(action: :read_project) + authorize_job_token_policies!(project) authorize_workhorse!( subject: project, has_length: false, diff --git a/lib/api/release/links.rb b/lib/api/release/links.rb index d023443905779..06dd5e067546b 100644 --- a/lib/api/release/links.rb +++ b/lib/api/release/links.rb @@ -38,6 +38,7 @@ class Links < ::API::Base use :pagination end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get 'links' do authorize! :read_release, release @@ -65,6 +66,7 @@ class Links < ::API::Base desc: 'The type of the link: `other`, `runbook`, `image`, or `package`. Defaults to `other`' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases post 'links' do result = ::Releases::Links::CreateService .new(release, current_user, declared_params(include_missing: false)) @@ -93,6 +95,7 @@ class Links < ::API::Base tags release_links_tags end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get do authorize! :read_release, release @@ -122,6 +125,7 @@ class Links < ::API::Base at_least_one_of :name, :url end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases put do result = ::Releases::Links::UpdateService .new(release, current_user, declared_params(include_missing: false)) @@ -146,6 +150,7 @@ class Links < ::API::Base tags release_links_tags end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases delete do result = ::Releases::Links::DestroyService .new(release, current_user) diff --git a/lib/api/releases.rb b/lib/api/releases.rb index 1289ecb026d42..e02aad88d3146 100644 --- a/lib/api/releases.rb +++ b/lib/api/releases.rb @@ -98,6 +98,7 @@ class Releases < ::API::Base optional :updated_after, type: DateTime, desc: 'Return releases updated after the specified datetime. Format: ISO 8601 YYYY-MM-DDTHH:MM:SSZ' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get ':id/releases' do releases = ::ReleasesFinder.new(user_project, current_user, declared_params.slice(:order_by, :sort, :updated_before, :updated_after)).execute @@ -133,6 +134,7 @@ class Releases < ::API::Base desc: 'If `true`, a response includes HTML rendered markdown of the release description' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get ':id/releases/:tag_name', requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_read_code! @@ -160,6 +162,7 @@ class Releases < ::API::Base as: :filepath end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get ':id/releases/:tag_name/downloads/*direct_asset_path', format: false, requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_read_code! @@ -188,6 +191,7 @@ class Releases < ::API::Base desc: 'The path to be suffixed to the latest release' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get ':id/releases/permalink/latest(/)(*suffix_path)', format: false, requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_read_code! @@ -272,6 +276,7 @@ class Releases < ::API::Base 'and the release will published to the CI catalog as it was before this parameter was introduced.' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases post ':id/releases' do authorize_create_release! @@ -317,6 +322,7 @@ class Releases < ::API::Base mutually_exclusive :milestones, :milestone_ids, message: 'Cannot specify milestones and milestone_ids at the same time' end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases put ':id/releases/:tag_name', requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_update_release! @@ -347,6 +353,7 @@ class Releases < ::API::Base requires :tag_name, type: String, desc: 'The Git tag the release is associated with', as: :tag end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :admin_releases delete ':id/releases/:tag_name', requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_destroy_release! diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 39ac79705ac95..d41fc0b63e806 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -287,6 +287,7 @@ def compare_cache_key(current_user, user_project, target_project, params) desc: "The file path to the configuration file as stored in the project's Git repository. Defaults to '.gitlab/changelog_config.yml'" end route_setting :authentication, job_token_allowed: true + route_setting :authorization, job_token_policies: :read_releases get ':id/repository/changelog' do check_rate_limit!(:project_repositories_changelog, scope: [current_user, user_project]) do render_api_error!({ error: 'This changelog has been requested too many times. Try again later.' }, 429) diff --git a/lib/api/terraform/state.rb b/lib/api/terraform/state.rb index 88f8c309023b9..4826f6405ce94 100644 --- a/lib/api/terraform/state.rb +++ b/lib/api/terraform/state.rb @@ -69,6 +69,7 @@ def remote_state_handler tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :read_terraform_state get do remote_state_handler.find_with_lock do |state| no_content! unless state.latest_file && state.latest_file.exists? @@ -92,6 +93,7 @@ def remote_state_handler tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_terraform_state post do authorize! :admin_terraform_state, user_project @@ -120,6 +122,7 @@ def remote_state_handler tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_terraform_state delete do authorize! :admin_terraform_state, user_project @@ -143,6 +146,7 @@ def remote_state_handler tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_terraform_state params do requires :ID, type: String, limit: 255, desc: 'Terraform state lock ID' requires :Operation, type: String, desc: 'Terraform operation' @@ -192,6 +196,7 @@ def remote_state_handler tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_terraform_state params do optional :ID, type: String, limit: 255, desc: 'Terraform state lock ID' end diff --git a/lib/api/terraform/state_version.rb b/lib/api/terraform/state_version.rb index f98aeb5860ef1..c83899b6a5c89 100644 --- a/lib/api/terraform/state_version.rb +++ b/lib/api/terraform/state_version.rb @@ -52,6 +52,7 @@ def find_version(serial) tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :read_terraform_state get do find_version(params[:serial]) do |version| env['api.format'] = :binary # Bypass json serialization @@ -70,6 +71,7 @@ def find_version(serial) tags %w[terraform_state] end route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth + route_setting :authorization, job_token_policies: :admin_terraform_state delete do authorize! :admin_terraform_state, user_project diff --git a/lib/tasks/ci/job_tokens.rake b/lib/tasks/ci/job_tokens.rake new file mode 100644 index 0000000000000..94db6dea4226c --- /dev/null +++ b/lib/tasks/ci/job_tokens.rake @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +return if Rails.env.production? + +namespace :ci do + namespace :job_tokens do + require_relative './job_tokens_task' + + desc 'CI | Job Tokens | Check if all CI/CD job token allowed endpoints are correctly tagged and documented' + task check_policies: :environment do + task_class = Tasks::Ci::JobTokensTask.new + task_class.check_policies_completeness + task_class.check_policies_correctness + task_class.check_docs + end + + desc 'CI | Job Tokens | Check if all CI/CD job token allowed endpoints are tagged with job_token_policies' + task check_policies_completeness: :environment do + Tasks::Ci::JobTokensTask.new.check_policies_completeness + end + + desc 'CI | Job Tokens | Check if all defined policies for CI/CD job token allowed endpoints are correct' + task check_policies_correctness: :environment do + Tasks::Ci::JobTokensTask.new.check_policies_correctness + end + + desc 'CI | Job Tokens | Check if CI/CD job token allowed endpoints documentation is up to date' + task check_docs: :environment do + Tasks::Ci::JobTokensTask.new.check_docs + end + + desc 'CI | Job Tokens | Compile CI/CD job token allowed endpoints documentation' + task compile_docs: :environment do + Tasks::Ci::JobTokensTask.new.compile_docs + end + end +end diff --git a/lib/tasks/ci/job_tokens_task.rb b/lib/tasks/ci/job_tokens_task.rb new file mode 100644 index 0000000000000..34c0fb07d9911 --- /dev/null +++ b/lib/tasks/ci/job_tokens_task.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module Tasks + module Ci + class JobTokensTask + def initialize + @routes = API::API.endpoints.flat_map(&:routes) + @doc_path = Rails.root.join('doc/ci/jobs/fine_grained_permissions.md') + @template_path = Rails.root.join('tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb') + end + + def check_policies_completeness + allowed_routes_without_policies = find_allowed_routes_without_policies + if allowed_routes_without_policies.empty? + puts 'All allowed endpoints for CI/CD job tokens have policies defined.' + else + puts '##########' + puts '#' + puts '# The following endpoints allowed for CI/CD job tokens should define job token policies:' + puts '#' + puts table_for_routes(allowed_routes_without_policies) + puts '#' + puts '##########' + + abort + end + end + + def check_policies_correctness + routes_with_invalid_policies = find_routes_with_invalid_policies + if routes_with_invalid_policies.empty? + puts 'All defined CI/CD job token policies are valid.' + else + puts '##########' + puts '#' + puts '# The following endpoints have invalid CI/CD job token policies:' + puts '#' + puts table_for_routes(routes_with_invalid_policies, include_policies: true) + puts '#' + puts '##########' + + abort + end + end + + def check_docs + doc = File.read(doc_path) + + template = ERB.new(File.read(template_path)) + if doc == template.result(binding) + puts 'CI/CD job token allowed endpoints documentation is up to date.' + else + puts '##########' + puts '#' + puts '# CI/CD job token allowed endpoints documentation is outdated! Please update it by running ' \ + '`bundle exec rake ci:job_tokens:compile_docs`.' + puts '#' + puts '##########' + + abort + end + end + + def compile_docs + template = ERB.new(File.read(template_path)) + File.write(doc_path, template.result(binding)) + + puts 'CI/CD job token allowed endpoints documentation compiled.' + end + + def allowed_endpoints + routes_for_table = routes.select { |route| allowed_route?(route) } + table_for_routes(routes_for_table, user_docs: true) + end + + private + + attr_reader :routes, :doc_path, :template_path + + def find_allowed_routes_without_policies + routes.select { |route| allowed_route?(route) && policies_for(route).empty? && !skip_route?(route) } + end + + def find_routes_with_invalid_policies + routes.select { |route| (policies_for(route) - valid_policies).present? } + end + + def valid_policies + @valid_policies ||= ::Ci::JobToken::Policies::POLICIES + end + + def table_for_routes(routes, include_policies: false, user_docs: false) + header = [] + header << 'Policies' if include_policies + + if user_docs + header << 'Permissions' + header << 'Permission Names' + end + + header += %w[Path Description] + + table = [] + table << markdown_row(header) + table << markdown_row(header.map { |item| '-' * item.length }) + + formatted_routes = routes.map { |route| format_route(route, include_policies, user_docs) } + table += formatted_routes.uniq.sort + table.join("\n") + end + + def format_route(route, include_policies, user_docs) + row = [] + row << policies_for(route).join(', ') if include_policies + + if user_docs + row << resource_and_permissions_for(route) + row << permission_names(route) + end + + row << [ + "`#{route_path(route)}`", + route.description + ] + + markdown_row(row) + end + + def markdown_row(row) + "| #{row.join(' | ')} |" + end + + def resource_and_permissions_for(route) + policies = policies_for(route) + return 'None' unless policies.present? + + policies.map do |policy| + _, permission, category = policy.match(/^(read|admin)_(.+)/).to_a + next policy unless permission && category + + permission = 'read_and_write' if permission == 'admin' + "#{category.humanize}: #{permission.humanize}" + end.join(', ') + end + + def permission_names(route) + policies = policies_for(route) + return unless policies.present? + + policies.map { |policy| "`#{policy.upcase}`" }.join(', ') + end + + def route_path(route) + [route.request_method, route.origin.delete_prefix('/api/:version')].join(' ') + end + + def allowed_route?(route) + route.settings.dig(:authentication, :job_token_allowed) + end + + def skip_route?(route) + route.settings.dig(:authorization, :skip_job_token_policies) + end + + def policies_for(route) + Array(route.settings.dig(:authorization, :job_token_policies)) + end + end + end +end diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index db66559deeea4..311ae9c6142c3 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -319,6 +319,14 @@ def app end end + context 'when job token policies are skipped' do + before do + allow(helper).to receive(:route_setting).with(:authorization).and_return(skip_job_token_policies: true) + end + + it { is_expected.to eq project } + end + context 'when the `enforce_job_token_policies` feature flag is disabled' do before do stub_feature_flags(enforce_job_token_policies: false) diff --git a/spec/requests/api/ci/catalog_spec.rb b/spec/requests/api/ci/catalog_spec.rb index 6953231520fbf..41c001bb4bdc2 100644 --- a/spec/requests/api/ci/catalog_spec.rb +++ b/spec/requests/api/ci/catalog_spec.rb @@ -28,6 +28,16 @@ post api(url, user), params: { version: release.tag, metadata: metadata } end + it_behaves_like 'enforcing job token policies', :admin_releases do + before_all do + project.add_developer(user) + end + + let(:request) do + post api(url), params: { version: release.tag, metadata: metadata, job_token: target_job.token } + end + end + context 'when the project does not exist' do let(:url) { "/projects/invalid-path/catalog/publish" } diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb index 2109d33cac9f4..90b0540dd3790 100644 --- a/spec/requests/api/ci/job_artifacts_spec.rb +++ b/spec/requests/api/ci/job_artifacts_spec.rb @@ -176,6 +176,21 @@ 'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif' end + it_behaves_like 'enforcing job token policies', :read_jobs do + before do + stub_licensed_features(cross_project_pipelines: true) + end + + around do |example| + Sidekiq::Testing.inline! { example.run } + end + + let(:request) do + get api("/projects/#{source_project.id}/jobs/#{job.id}/artifacts/#{artifact}"), + params: { job_token: target_job.token } + end + end + context 'when user is anonymous' do let(:api_user) { nil } @@ -305,12 +320,16 @@ def get_artifact_file(artifact_path) context 'when job token is used' do let(:other_job) { create(:ci_build, :running, user: user) } - subject { get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", job_token: other_job.token) } + subject(:request) { get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", job_token: other_job.token) } before do stub_licensed_features(cross_project_pipelines: true) end + it_behaves_like 'enforcing job token policies', :read_jobs do + let(:other_job) { target_job } + end + context 'when job token scope is enabled' do before do other_job.project.ci_cd_settings.update!( @@ -484,6 +503,21 @@ def get_for_ref(ref = pipeline.ref, job_name = job.name) get api("/projects/#{project.id}/jobs/artifacts/#{ref}/download", api_user), params: { job: job_name } end + it_behaves_like 'enforcing job token policies', :read_jobs do + before do + stub_licensed_features(cross_project_pipelines: true) + end + + around do |example| + Sidekiq::Testing.inline! { example.run } + end + + let(:request) do + get api("/projects/#{source_project.id}/jobs/artifacts/#{pipeline.ref}/download"), + params: { job: job.name, job_token: target_job.token } + end + end + context 'when not logged in' do let(:api_user) { nil } diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb index 1952ac91c7491..540b6e7d9cf13 100644 --- a/spec/requests/api/ci/pipelines_spec.rb +++ b/spec/requests/api/ci/pipelines_spec.rb @@ -1164,6 +1164,13 @@ def expect_variables(variables, expected_variables) put api("/projects/#{project.id}/pipelines/#{pipeline.id}/metadata", current_user), params: { name: name } end + it_behaves_like 'enforcing job token policies', :admin_jobs do + let(:request) do + put api("/projects/#{source_project.id}/pipelines/#{pipeline.id}/metadata"), + params: { name: name, job_token: target_job.token } + end + end + context 'authorized user' do let(:current_user) { create(:user) } diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb index 20bc233220d31..499d52d6f6b42 100644 --- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb +++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb @@ -1075,6 +1075,11 @@ def expect_use_primary expect(response).to have_gitlab_http_status(:forbidden) end + + it_behaves_like 'enforcing job token policies', :read_jobs do + let(:token) { target_job.token } + let(:request) { download_artifact } + end end context 'when using runnners token' do diff --git a/spec/requests/api/ci/secure_files_spec.rb b/spec/requests/api/ci/secure_files_spec.rb index 46e45eaf4ac0f..971e2bd9fd185 100644 --- a/spec/requests/api/ci/secure_files_spec.rb +++ b/spec/requests/api/ci/secure_files_spec.rb @@ -23,6 +23,13 @@ end describe 'GET /projects/:id/secure_files' do + it_behaves_like 'enforcing job token policies', :read_secure_files do + let_it_be(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/secure_files"), params: { job_token: target_job.token } + end + end + context 'authenticated user with admin permissions' do it 'returns project secure files' do get api("/projects/#{project.id}/secure_files", maintainer) @@ -75,6 +82,14 @@ end describe 'GET /projects/:id/secure_files/:secure_file_id' do + it_behaves_like 'enforcing job token policies', :read_secure_files do + let_it_be(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/secure_files/#{secure_file.id}"), + params: { job_token: target_job.token } + end + end + context 'authenticated user with admin permissions' do it 'returns project secure file details' do get api("/projects/#{project.id}/secure_files/#{secure_file.id}", maintainer) @@ -139,6 +154,14 @@ end describe 'GET /projects/:id/secure_files/:secure_file_id/download' do + it_behaves_like 'enforcing job token policies', :read_secure_files do + let_it_be(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/secure_files/#{secure_file.id}/download"), + params: { job_token: target_job.token } + end + end + context 'authenticated user with admin permissions' do it 'returns a secure file' do sample_file = fixture_file('ci_secure_files/upload-keystore.jks') @@ -197,6 +220,14 @@ end describe 'POST /projects/:id/secure_files' do + it_behaves_like 'enforcing job token policies', :admin_secure_files do + let_it_be(:user) { maintainer } + let(:request) do + post api("/projects/#{source_project.id}/secure_files"), + params: file_params.merge(job_token: target_job.token) + end + end + context 'authenticated user with admin permissions' do it 'creates a secure file' do expect do @@ -347,6 +378,14 @@ end describe 'DELETE /projects/:id/secure_files/:secure_file_id' do + it_behaves_like 'enforcing job token policies', :admin_secure_files do + let_it_be(:user) { maintainer } + let(:request) do + delete api("/projects/#{source_project.id}/secure_files/#{secure_file.id}"), + params: { job_token: target_job.token } + end + end + context 'authenticated user with admin permissions' do it 'deletes the secure file' do expect do diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb index 37488e81fe3be..250cf18bf1038 100644 --- a/spec/requests/api/composer_packages_spec.rb +++ b/spec/requests/api/composer_packages_spec.rb @@ -411,7 +411,15 @@ project.repository.add_tag(user, 'v1.2.99', 'master') end - subject { post api(url), headers: headers, params: params } + subject(:request) { post api(url), headers: headers, params: params } + + it_behaves_like 'enforcing job token policies', :admin_packages do + before_all do + project.add_developer(user) + end + + let(:params) { { tag: 'v1.2.99', job_token: target_job.token } } + end shared_examples 'composer package publish' do where(:project_visibility_level, :member_role, :token_type, :valid_token, :shared_examples_name, :expected_status) do @@ -546,7 +554,7 @@ let(:url) { "/projects/#{project.id}/packages/composer/archives/#{package_name}.zip" } let(:params) { { sha: sha } } - subject { get api(url), headers: headers, params: params } + subject(:request) { get api(url), headers: headers, params: params } context 'with valid project' do let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) } @@ -587,6 +595,14 @@ let(:branch) { project.repository.find_branch('master') } let(:sha) { branch.target } + it_behaves_like 'enforcing job token policies', :read_packages do + before_all do + project.add_developer(user) + end + + let(:headers) { job_basic_auth_header(target_job) } + end + context 'with basic auth' do where(:project_visibility_level, :member_role, :token_type, :valid_token, :expected_status) do 'PUBLIC' | :developer | :user | true | :success diff --git a/spec/requests/api/conan/v1/instance_packages_spec.rb b/spec/requests/api/conan/v1/instance_packages_spec.rb index db55fa523ee0c..955bedbed681d 100644 --- a/spec/requests/api/conan/v1/instance_packages_spec.rb +++ b/spec/requests/api/conan/v1/instance_packages_spec.rb @@ -64,14 +64,14 @@ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel' \ '/digest' do - subject { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers } + subject(:request) { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers } it_behaves_like 'recipe download_urls endpoint' end describe 'GET /api/v4/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel' \ '/packages/:conan_package_reference/download_urls' do - subject do + subject(:request) do get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers end @@ -81,14 +81,14 @@ describe 'GET /api/v4/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel' \ '/download_urls' do - subject { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers } + subject(:request) { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers } it_behaves_like 'recipe download_urls endpoint' end describe 'GET /api/v4/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel' \ '/packages/:conan_package_reference/digest' do - subject do + subject(:request) do get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers end @@ -97,7 +97,7 @@ describe 'POST /api/v4/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel' \ '/upload_urls' do - subject do + subject(:request) do post api("/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers end @@ -106,7 +106,7 @@ describe 'POST /api/v4/packages/conan/v1/conans/:package_name/:package_version/:package_username/:package_channel' \ '/packages/:conan_package_reference/upload_urls' do - subject do + subject(:request) do post api("/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers end @@ -118,7 +118,7 @@ '/:package_channel' do let_it_be_with_reload(:package) { create(:conan_package, project: project) } - subject { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers } + subject(:request) { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers } it_behaves_like 'delete package endpoint' end @@ -129,7 +129,7 @@ describe 'GET /api/v4/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel' \ '/:recipe_revision/export/:file_name' do - subject do + subject(:request) do get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision_value}/export/" \ "#{recipe_file.file_name}"), headers: headers @@ -141,7 +141,7 @@ describe 'GET /api/v4/packages/conan/v1/files/:package_name/:package_version/:package_username/:package_channel' \ '/:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do - subject do + subject(:request) do get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision_value}/package" \ "/#{metadata.conan_package_reference}/#{metadata.package_revision_value}/#{package_file.file_name}"), headers: headers @@ -159,7 +159,7 @@ '/:recipe_revision/export/:file_name/authorize' do let(:file_name) { 'conanfile.py' } - subject do + subject(:request) do put api("/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token end @@ -170,7 +170,7 @@ '/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do let(:file_name) { 'conaninfo.txt' } - subject do + subject(:request) do put api("/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token end diff --git a/spec/requests/api/conan/v1/project_packages_spec.rb b/spec/requests/api/conan/v1/project_packages_spec.rb index 7883066b58f75..42fb639f91004 100644 --- a/spec/requests/api/conan/v1/project_packages_spec.rb +++ b/spec/requests/api/conan/v1/project_packages_spec.rb @@ -70,7 +70,7 @@ let(:url_prefix) { "#{Settings.gitlab.base_url}/api/v4/projects/#{project_id}" } let(:recipe_path) { package.conan_recipe_path } - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username' \ '/:package_channel' do @@ -129,7 +129,7 @@ describe 'POST /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username' \ '/:package_channel/upload_urls' do - subject do + subject(:request) do post api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers end @@ -139,7 +139,7 @@ describe 'POST /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username' \ '/:package_channel/packages/:conan_package_reference/upload_urls' do - subject do + subject(:request) do post api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers end @@ -151,7 +151,9 @@ '/:package_channel' do let_it_be_with_reload(:package) { create(:conan_package, project: project) } - subject { delete api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}"), headers: headers } + subject(:request) do + delete api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}"), headers: headers + end it_behaves_like 'delete package endpoint' end @@ -160,7 +162,7 @@ context 'with file download endpoints' do include_context 'conan file download endpoints' - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } describe 'GET /api/v4/projects/:id/packages/conan/v1/files/:package_name/:package_version/:package_username' \ '/:package_channel/:recipe_revision/export/:file_name' do @@ -194,7 +196,7 @@ '/:package_channel/:recipe_revision/export/:file_name/authorize' do let(:file_name) { 'conanfile.py' } - subject do + subject(:request) do put api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token end @@ -206,7 +208,7 @@ '/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do let(:file_name) { 'conaninfo.txt' } - subject do + subject(:request) do put api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}" \ "/authorize"), headers: headers_with_token diff --git a/spec/requests/api/conan/v2/project_packages_spec.rb b/spec/requests/api/conan/v2/project_packages_spec.rb index 94aca17b032f4..06d5f08e53ac6 100644 --- a/spec/requests/api/conan/v2/project_packages_spec.rb +++ b/spec/requests/api/conan/v2/project_packages_spec.rb @@ -42,6 +42,12 @@ it_behaves_like 'project not found by project id' + # TODO remove expected_success_status: :not_found when endpoint is implemented + it_behaves_like 'enforcing job token policies', :read_packages, expected_success_status: :not_found do + let(:request) { get_request } + let(:headers) { job_basic_auth_header(target_job) } + end + context 'when feature flag is disabled' do before do stub_feature_flags(conan_package_revisions_support: false) diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb index fd14fb5773bc2..51f979e3853a6 100644 --- a/spec/requests/api/deployments_spec.rb +++ b/spec/requests/api/deployments_spec.rb @@ -23,6 +23,12 @@ def perform_request(params = {}) get api("/projects/#{project.id}/deployments", user), params: params end + it_behaves_like 'enforcing job token policies', :read_deployments do + let(:request) do + get api("/projects/#{source_project.id}/deployments"), params: { job_token: target_job.token } + end + end + context 'as member of the project' do it 'returns projects deployments sorted by id asc' do perform_request @@ -168,6 +174,13 @@ def expect_deployments(ordered_deployments) shared_examples "returns project deployments" do let(:project) { deployment.environment.project } + it_behaves_like 'enforcing job token policies', :read_deployments do + let(:request) do + get api("/projects/#{source_project.id}/deployments/#{deployment.id}"), + params: { job_token: target_job.token } + end + end + it 'returns the expected response' do get api("/projects/#{project.id}/deployments/#{deployment.id}", user) @@ -242,12 +255,11 @@ def expect_deployments(ordered_deployments) end it_behaves_like 'enforcing job token policies', [:admin_deployments, :admin_environments] do - # let(:accessed_project) { project } let(:request) do post( - api("/projects/#{project.id}/deployments"), + api("/projects/#{source_project.id}/deployments"), params: { - job_token: job.token, + job_token: target_job.token, environment: 'production', sha: sha, ref: 'master', @@ -460,6 +472,13 @@ def expect_deployments(ordered_deployments) ) end + it_behaves_like 'enforcing job token policies', :admin_deployments do + let(:request) do + put api("/projects/#{source_project.id}/deployments/#{deploy.id}"), + params: { status: 'success', job_token: target_job.token } + end + end + context 'as a maintainer' do it 'returns a 403 when updating a deployment with a build' do deploy.update!(deployable: build) @@ -592,6 +611,13 @@ def expect_deployments(ordered_deployments) ) end + it_behaves_like 'enforcing job token policies', :admin_deployments do + let(:request) do + delete api("/projects/#{source_project.id}/deployments/#{old_deploy.id}"), + params: { job_token: target_job.token } + end + end + context 'as an maintainer' do it 'deletes a deployment' do delete api("/projects/#{project.id}/deployments/#{old_deploy.id}", user) @@ -644,6 +670,12 @@ def expect_deployments(ordered_deployments) subject { get api("/projects/#{project.id}/deployments/#{deployment.id}/merge_requests", user) } + it_behaves_like 'enforcing job token policies', :read_deployments do + let(:request) do + get api("/projects/#{source_project.id}/deployments/#{deployment.id}/merge_requests"), params: { job_token: target_job.token } + end + end + context 'when a user is not a member of the deployment project' do let(:user) { build(:user) } diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index 246a46d48c986..43f4e282013c4 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -126,12 +126,19 @@ it_behaves_like 'enforcing job token policies', :read_environments do let(:request) do - get api("/projects/#{project.id}/environments"), params: { job_token: job.token } + get api("/projects/#{source_project.id}/environments"), params: { job_token: target_job.token } end end end describe 'POST /projects/:id/environments' do + it_behaves_like 'enforcing job token policies', :admin_environments do + let(:request) do + post api("/projects/#{source_project.id}/environments"), + params: { name: "mepmep", tier: 'staging', description: 'description', job_token: target_job.token } + end + end + context 'as a member' do it 'creates an environment with valid params' do post api("/projects/#{project.id}/environments", user), params: { name: "mepmep", tier: 'staging', description: 'description' } @@ -262,6 +269,13 @@ end describe 'POST /projects/:id/environments/stop_stale' do + it_behaves_like 'enforcing job token policies', :admin_environments do + let(:request) do + post api("/projects/#{source_project.id}/environments/stop_stale"), + params: { before: 1.week.ago.to_date.to_s, job_token: target_job.token } + end + end + context 'as a maintainer' do it 'returns a 200' do post api("/projects/#{project.id}/environments/stop_stale", user), params: { before: 1.week.ago.to_date.to_s } @@ -317,6 +331,13 @@ describe 'PUT /projects/:id/environments/:environment_id' do let_it_be(:url) { 'https://mepmep.whatever.ninja' } + it_behaves_like 'enforcing job token policies', :admin_environments do + let(:request) do + put api("/projects/#{source_project.id}/environments/#{environment.id}"), + params: { tier: 'production', job_token: target_job.token } + end + end + it 'returns a 200 if external_url is changed' do put api("/projects/#{project.id}/environments/#{environment.id}", user), params: { external_url: url } @@ -492,6 +513,17 @@ end describe 'DELETE /projects/:id/environments/:environment_id' do + it_behaves_like 'enforcing job token policies', :admin_environments do + before do + environment.stop + end + + let(:request) do + delete api("/projects/#{source_project.id}/environments/#{environment.id}"), + params: { job_token: target_job.token } + end + end + context 'as a maintainer' do it "rejects the requests in environment isn't stopped" do delete api("/projects/#{project.id}/environments/#{environment.id}", user) @@ -544,6 +576,13 @@ end describe 'POST /projects/:id/environments/:environment_id/stop' do + it_behaves_like 'enforcing job token policies', :admin_environments do + let(:request) do + post api("/projects/#{source_project.id}/environments/#{environment.id}/stop"), + params: { job_token: target_job.token } + end + end + context 'as a maintainer' do context 'with a stoppable environment' do before do @@ -589,6 +628,13 @@ let_it_be(:bridge_job) { create(:ci_bridge, :running, project: project, user: user) } let_it_be(:build_job) { create(:ci_build, :running, project: project, user: user) } + it_behaves_like 'enforcing job token policies', :read_environments do + let(:request) do + get api("/projects/#{source_project.id}/environments/#{environment.id}"), + params: { job_token: target_job.token } + end + end + context 'as member of the project' do shared_examples "returns project environments" do it 'returns expected response' do @@ -745,6 +791,16 @@ end end + it_behaves_like 'enforcing job token policies', :admin_environments do + before_all do + create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) + end + + let(:request) do + delete api("/projects/#{source_project.id}/environments/review_apps"), params: { job_token: target_job.token } + end + end + context "as a maintainer" do it_behaves_like "delete stopped review environments" do let(:current_user) { user } diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb index 6c8ae0b053f2d..8d805d2e6b984 100644 --- a/spec/requests/api/generic_packages_spec.rb +++ b/spec/requests/api/generic_packages_spec.rb @@ -86,6 +86,14 @@ def deploy_token_header(value) end describe 'PUT /api/v4/projects/:id/packages/generic/:package_name/:package_version/(*path)/:file_name/authorize' do + it_behaves_like 'enforcing job token policies', :admin_packages do + before do + source_project.add_developer(user) + end + + let(:request) { authorize_upload_file(workhorse_headers.merge(job_token_header(target_job.token))) } + end + context 'with valid project' do where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do 'PUBLIC' | :developer | true | :personal_access_token | :success @@ -207,6 +215,14 @@ def authorize_upload_file(request_headers, package_name: 'mypackage', file_name: let(:file_upload) { fixture_file_upload('spec/fixtures/packages/generic/myfile.tar.gz') } let(:params) { { file: file_upload } } + it_behaves_like 'enforcing job token policies', :admin_packages do + before do + source_project.add_developer(user) + end + + let(:request) { upload_file(params, workhorse_headers.merge(job_token_header(target_job.token))) } + end + context 'authentication' do where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do 'PUBLIC' | :guest | true | :personal_access_token | :forbidden @@ -751,6 +767,16 @@ def upload_file( let_it_be(:package) { create(:generic_package, project: project) } let_it_be(:package_file) { create(:package_file, :generic, package: package) } + it_behaves_like 'enforcing job token policies', :read_packages do + before do + source_project.add_developer(user) + end + + let(:request) do + download_file(job_token_header(target_job.token)) + end + end + context 'authentication' do where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do 'PUBLIC' | :developer | true | :personal_access_token | :success diff --git a/spec/requests/api/go_proxy_spec.rb b/spec/requests/api/go_proxy_spec.rb index 5dac887f0776c..6277647a7498a 100644 --- a/spec/requests/api/go_proxy_spec.rb +++ b/spec/requests/api/go_proxy_spec.rb @@ -189,6 +189,12 @@ it_behaves_like 'a missing module version list resource' end + + it_behaves_like 'enforcing job token policies', :read_packages do + let(:module_name) { base } + let(:resource) { 'list' } + let(:request) { get_resource(job_token: target_job.token) } + end end describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.info' do @@ -243,6 +249,12 @@ let(:version) { "v1.0.4-0.#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{modules[:sha][0][0..10]}" } end end + + it_behaves_like 'enforcing job token policies', :read_packages do + let(:module_name) { base } + let(:resource) { 'v1.0.1.info' } + let(:request) { get_resource(job_token: target_job.token) } + end end describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.mod' do @@ -265,6 +277,12 @@ context 'with an invalid version' do it_behaves_like 'a missing module file resource', 'v1.0.1', path: '/mod' end + + it_behaves_like 'enforcing job token policies', :read_packages do + let(:module_name) { base } + let(:resource) { 'v1.0.1.mod' } + let(:request) { get_resource(job_token: target_job.token) } + end end describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.zip' do @@ -287,6 +305,12 @@ context 'with the root module v2.0.0' do it_behaves_like 'a module archive resource', 'v2.0.0', ['go.mod', 'a.go', 'x.go'], path: '/v2' end + + it_behaves_like 'enforcing job token policies', :read_packages do + let(:module_name) { base } + let(:resource) { 'v1.0.1.zip' } + let(:request) { get_resource(job_token: target_job.token) } + end end context 'with an invalid module directive' do diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 7bca93f3c19b2..e88fd60c29b34 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -473,6 +473,14 @@ end end + it_behaves_like 'enforcing job token policies', :read_packages do + before_all do + project.add_maintainer(user) + end + + let(:request) { download_file(file_name: package_file.file_name, params: { job_token: target_job.token }) } + end + it_behaves_like 'rejecting request with invalid params' it_behaves_like 'handling groups, subgroups and user namespaces for', 'getting a file', visibilities: { public: :redirect, internal: :not_found, private: :not_found } @@ -633,6 +641,12 @@ def download_file_with_token(file_name:, params: {}, request_headers: headers_wi end end + it_behaves_like 'enforcing job token policies', :read_packages do + let(:request) do + download_file(file_name: package_file.file_name, params: { job_token: target_job.token }) + end + end + context 'with the duplicate packages in the two projects' do let_it_be(:recent_project) { create(:project, :private, namespace: group) } @@ -814,6 +828,12 @@ def download_file_with_token(file_name:, params: {}, request_headers: headers_wi subject { download_file_with_token(file_name: package_file.file_name) } + it_behaves_like 'enforcing job token policies', :read_packages do + let(:request) do + download_file(file_name: package_file.file_name, params: { job_token: target_job.token }) + end + end + it_behaves_like 'tracking the file download event' it_behaves_like 'bumping the package last downloaded at field' it_behaves_like 'successfully returning the file' @@ -866,6 +886,10 @@ def download_file_with_token(file_name:, params: {}, request_headers: headers_wi end describe 'PUT /api/v4/projects/:id/packages/maven/*path/:file_name/authorize' do + it_behaves_like 'enforcing job token policies', :admin_packages do + let(:request) { authorize_upload(job_token: target_job.token) } + end + it 'rejects a malicious request' do put api("/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/%2e%2e%2F.ssh%2Fauthorized_keys/authorize"), headers: headers_with_token @@ -964,6 +988,10 @@ def authorize_upload_with_token(params = {}, request_headers = headers_with_toke allow(Packages::PackageFileUploader).to receive(:workhorse_upload_path).and_return('/') end + it_behaves_like 'enforcing job token policies', :admin_packages do + let(:request) { upload_file(params: { file: file_upload, job_token: target_job.token }) } + end + it 'rejects requests without a file from workhorse' do upload_file_with_token diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb index 64977f2f80508..fb95af2d855ba 100644 --- a/spec/requests/api/npm_project_packages_spec.rb +++ b/spec/requests/api/npm_project_packages_spec.rb @@ -142,7 +142,7 @@ let(:headers) { {} } let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") } - subject { get(url, headers: headers) } + subject(:request) { get(url, headers: headers) } before do project.add_developer(user) @@ -203,6 +203,10 @@ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) end + it_behaves_like 'enforcing job token policies', :read_packages do + let(:headers) { build_token_auth_header(target_job.token) } + end + it_behaves_like 'a package file that requires auth' context 'with guest' do @@ -360,6 +364,10 @@ context 'with a scoped name' do let(:package_name) { "@#{group.path}/my_package_name" } + it_behaves_like 'enforcing job token policies', :admin_packages do + let(:request) { upload_package(package_name, params.merge(job_token: target_job.token)) } + end + it_behaves_like 'handling upload with different authentications' end diff --git a/spec/requests/api/package_files_spec.rb b/spec/requests/api/package_files_spec.rb index a70fd129f83fb..910e82a5d2fec 100644 --- a/spec/requests/api/package_files_spec.rb +++ b/spec/requests/api/package_files_spec.rb @@ -23,6 +23,10 @@ project.add_developer(user) end + it_behaves_like 'enforcing job token policies', :read_packages do + let(:request) { get api(url), params: { job_token: target_job.token } } + end + context 'without the need for a license' do context 'project is public' do it 'returns 200' do @@ -147,6 +151,14 @@ end end + it_behaves_like 'enforcing job token policies', :admin_packages do + before do + source_project.add_maintainer(user) + end + + let(:request) { delete api(url), params: { job_token: target_job.token } } + end + context 'project is public' do context 'without user' do let(:user) { nil } diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb index b6b143c9f7181..6a20f5a999728 100644 --- a/spec/requests/api/project_packages_spec.rb +++ b/spec/requests/api/project_packages_spec.rb @@ -20,7 +20,11 @@ let(:url) { "/projects/#{project.id}/packages" } let(:package_schema) { 'public_api/v4/packages/packages' } - subject { get api(url), params: params } + subject(:request) { get api(url), params: params } + + it_behaves_like 'enforcing job token policies', :read_packages do + let(:params) { { job_token: target_job.token } } + end context 'without the need for a license' do context 'project is public' do @@ -227,6 +231,10 @@ subject { get api(package_url, user) } + it_behaves_like 'enforcing job token policies', :read_packages do + let(:request) { get api(package_url), params: { job_token: target_job.token } } + end + shared_examples 'no destroy url' do it 'returns no destroy url' do subject @@ -413,6 +421,10 @@ end end + it_behaves_like 'enforcing job token policies', :read_packages do + let(:request) { get api(package_pipelines_url), params: { job_token: target_job.token } } + end + context 'without the need for a license' do context 'when the package does not exist' do let(:package_pipelines_url) { "/projects/#{project.id}/packages/0/pipelines" } @@ -621,6 +633,14 @@ end describe 'DELETE /projects/:id/packages/:package_id' do + it_behaves_like 'enforcing job token policies', :admin_packages do + before_all do + project.add_maintainer(user) + end + + let(:request) { delete api(package_url), params: { job_token: target_job.token } } + end + context 'without the need for a license' do context 'project is public' do it 'returns 403 for non authenticated user' do diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb index 657e736109f82..a415ef3ae4e77 100644 --- a/spec/requests/api/pypi_packages_spec.rb +++ b/spec/requests/api/pypi_packages_spec.rb @@ -43,7 +43,7 @@ def snowplow_context(user_role: :developer) let_it_be(:package) { create(:pypi_package, project: project) } let_it_be(:package2) { create(:pypi_package, project: project) } - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } describe 'GET /api/v4/groups/:id/-/packages/pypi/simple' do let(:url) { "/groups/#{group.id}/-/packages/pypi/simple" } @@ -86,6 +86,7 @@ def snowplow_context(user_role: :developer) let(:url) { "/projects/#{project.id}/packages/pypi/simple" } let(:snowplow_gitlab_standard_context) { { project: nil, namespace: group, property: 'i_package_pypi_user' } } + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'pypi simple index API endpoint' it_behaves_like 'rejects PyPI access with unknown project id' it_behaves_like 'deploy token for package GET requests' @@ -103,7 +104,7 @@ def snowplow_context(user_role: :developer) context 'simple package API endpoint' do let_it_be(:package) { create(:pypi_package, project: project) } - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } describe 'GET /api/v4/groups/:id/-/packages/pypi/simple/:package_name' do let(:package_name) { package.name } @@ -148,6 +149,7 @@ def snowplow_context(user_role: :developer) let(:url) { "/projects/#{project.id}/packages/pypi/simple/#{package_name}" } let(:snowplow_context) { { project: project, namespace: project.namespace, property: 'i_package_pypi_user' } } + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'pypi simple API endpoint' it_behaves_like 'rejects PyPI access with unknown project id' it_behaves_like 'deploy token for package GET requests' @@ -168,7 +170,15 @@ def snowplow_context(user_role: :developer) let(:url) { "/projects/#{project.id}/packages/pypi/authorize" } let(:headers) { {} } - subject { post api(url), headers: headers } + subject(:request) { post api(url), headers: headers } + + it_behaves_like 'enforcing job token policies', :admin_packages do + before_all do + project.add_developer(user) + end + + let(:headers) { build_token_auth_header(target_job.token).merge(workhorse_headers) } + end context 'with valid project' do where(:visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do @@ -246,7 +256,7 @@ def snowplow_context(user_role: :developer) let(:send_rewritten_field) { true } let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user, property: 'i_package_pypi_user' } } - subject do + subject(:request) do workhorse_finalize( api(url), method: :post, @@ -257,6 +267,14 @@ def snowplow_context(user_role: :developer) ) end + it_behaves_like 'enforcing job token policies', :admin_packages do + before_all do + project.add_developer(user) + end + + let(:headers) { build_token_auth_header(target_job.token).merge(workhorse_headers) } + end + context 'with valid project' do where(:visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do :public | :developer | true | true | 'PyPI package creation' | :created @@ -514,11 +532,12 @@ def snowplow_context(user_role: :developer) end end - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } describe 'GET /api/v4/groups/:id/-/packages/pypi/files/:sha256/*file_identifier' do let(:url) { "/groups/#{group.id}/-/packages/pypi/files/#{package.package_files.first.file_sha256}/#{package_name}-1.0.0.tar.gz" } + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'pypi file download endpoint' it_behaves_like 'rejects PyPI access with unknown group id' it_behaves_like 'a pypi user namespace endpoint' @@ -527,6 +546,7 @@ def snowplow_context(user_role: :developer) describe 'GET /api/v4/projects/:id/packages/pypi/files/:sha256/*file_identifier' do let(:url) { "/projects/#{project.id}/packages/pypi/files/#{package.package_files.first.file_sha256}/#{package_name}-1.0.0.tar.gz" } + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'pypi file download endpoint' it_behaves_like 'rejects PyPI access with unknown project id' it_behaves_like 'allow access for everyone with public package_registry_access_level' diff --git a/spec/requests/api/release/links_spec.rb b/spec/requests/api/release/links_spec.rb index caa24f42df380..d6f37ca3dc3e6 100644 --- a/spec/requests/api/release/links_spec.rb +++ b/spec/requests/api/release/links_spec.rb @@ -25,6 +25,14 @@ end describe 'GET /projects/:id/releases/:tag_name/assets/links' do + it_behaves_like 'enforcing job token policies', :read_releases do + let_it_be(:user) { maintainer } + let(:request) do + get api("/projects/#{source_project.id}/releases/v0.1/assets/links"), + params: { job_token: target_job.token } + end + end + context 'when there are two release links' do let!(:release_link_1) { create(:release_link, release: release, created_at: 2.days.ago) } let!(:release_link_2) { create(:release_link, release: release, created_at: 1.day.ago) } @@ -106,6 +114,14 @@ describe 'GET /projects/:id/releases/:tag_name/assets/links/:link_id' do let!(:release_link) { create(:release_link, release: release) } + it_behaves_like 'enforcing job token policies', :read_releases do + let_it_be(:user) { maintainer } + let(:request) do + get api("/projects/#{source_project.id}/releases/v0.1/assets/links/#{release_link.id}"), + params: { job_token: target_job.token } + end + end + it 'returns 200 HTTP status' do get api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer) @@ -198,6 +214,14 @@ let(:last_release_link) { release.links.last } + it_behaves_like 'enforcing job token policies', :admin_releases do + let_it_be(:user) { maintainer } + let(:request) do + post api("/projects/#{source_project.id}/releases/v0.1/assets/links"), + params: params.merge(job_token: target_job.token) + end + end + it 'accepts the request' do post api("/projects/#{project.id}/releases/v0.1/assets/links", maintainer), params: params @@ -343,6 +367,14 @@ let(:params) { { name: 'awesome-app.msi' } } let!(:release_link) { create(:release_link, release: release) } + it_behaves_like 'enforcing job token policies', :admin_releases do + let_it_be(:user) { maintainer } + let(:request) do + put api("/projects/#{source_project.id}/releases/v0.1/assets/links/#{release_link.id}"), + params: params.merge(job_token: target_job.token) + end + end + it 'accepts the request' do put api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer), params: params @@ -483,6 +515,14 @@ create(:release_link, release: release) end + it_behaves_like 'enforcing job token policies', :admin_releases do + let_it_be(:user) { maintainer } + let(:request) do + delete api("/projects/#{source_project.id}/releases/v0.1/assets/links/#{release_link.id}"), + params: { job_token: target_job.token } + end + end + it 'accepts the request' do delete api("/projects/#{project.id}/releases/v0.1/assets/links/#{release_link.id}", maintainer) diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 86460f0613d20..3836d4462e3e6 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -19,6 +19,13 @@ end describe 'GET /projects/:id/releases', :use_clean_rails_redis_caching do + it_behaves_like 'enforcing job token policies', :read_releases do + let(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/releases"), params: { job_token: target_job.token } + end + end + context 'when there are two releases' do let!(:release_1) do create(:release, project: project, tag: 'v0.1', author: maintainer, released_at: 2.days.ago) @@ -319,6 +326,13 @@ ) end + it_behaves_like 'enforcing job token policies', :read_releases do + let(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/releases/v0.1"), params: { job_token: target_job.token } + end + end + it 'returns 200 HTTP status' do get api("/projects/#{project.id}/releases/v0.1", maintainer) @@ -581,6 +595,14 @@ context 'with a valid release tag' do context 'when filepath is provided' do context 'when filepath exists' do + it_behaves_like 'enforcing job token policies', :read_releases, expected_success_status: :redirect do + let(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/releases/v0.1/downloads#{filepath}"), + params: { job_token: target_job.token } + end + end + it 'redirects to the file download URL' do get api("/projects/#{project.id}/releases/v0.1/downloads#{filepath}", maintainer) @@ -709,6 +731,13 @@ ) end + it_behaves_like 'enforcing job token policies', :read_releases, expected_success_status: :redirect do + let(:user) { developer } + let(:request) do + get api("/projects/#{source_project.id}/releases/permalink/latest"), params: { job_token: target_job.token } + end + end + it 'redirects to the latest release tag' do get api("/projects/#{project.id}/releases/permalink/latest", maintainer) @@ -809,6 +838,13 @@ initialize_tags end + it_behaves_like 'enforcing job token policies', :admin_releases do + let(:user) { developer } + let(:request) do + post api("/projects/#{source_project.id}/releases"), params: params.merge(job_token: target_job.token) + end + end + it 'accepts the request' do post api("/projects/#{project.id}/releases", maintainer), params: params @@ -1363,6 +1399,13 @@ initialize_tags end + it_behaves_like 'enforcing job token policies', :admin_releases do + let(:user) { developer } + let(:request) do + put api("/projects/#{source_project.id}/releases/v0.1"), params: params.merge(job_token: target_job.token) + end + end + it 'accepts the request' do put api("/projects/#{project.id}/releases/v0.1", maintainer), params: params @@ -1604,6 +1647,13 @@ ) end + it_behaves_like 'enforcing job token policies', :admin_releases do + let(:user) { developer } + let(:request) do + delete api("/projects/#{source_project.id}/releases/v0.1"), params: { job_token: target_job.token } + end + end + it 'accepts the request' do delete api("/projects/#{project.id}/releases/v0.1", maintainer) diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 26f31aa2a310f..29a671e97971d 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -807,6 +807,18 @@ def commit_messages(response) end describe 'GET /projects/:id/repository/changelog' do + it_behaves_like 'enforcing job token policies', :read_releases do + before do + allow(Repositories::ChangelogService).to receive(:new) + .and_return(instance_spy(Repositories::ChangelogService)) + end + + let(:request) do + get api("/projects/#{source_project.id}/repository/changelog"), + params: { version: '1.0.0', job_token: target_job.token } + end + end + it 'generates the changelog for a version' do spy = instance_spy(Repositories::ChangelogService) release_notes = 'Release notes' diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb index bae6b5306ea61..4f7a0457c3250 100644 --- a/spec/requests/api/terraform/state_spec.rb +++ b/spec/requests/api/terraform/state_spec.rb @@ -149,6 +149,11 @@ context 'job token authentication' do let(:auth_header) { job_basic_auth_header(job) } + it_behaves_like 'enforcing job token policies', :read_terraform_state do + let_it_be(:user) { maintainer } + let(:job) { target_job } + end + context 'with maintainer permissions' do let(:job) { create(:ci_build, status: :running, project: project, user: maintainer) } @@ -295,6 +300,11 @@ let(:job) { create(:ci_build, status: :running, project: project, user: maintainer) } let(:auth_header) { job_basic_auth_header(job) } + it_behaves_like 'enforcing job token policies', :admin_terraform_state do + let_it_be(:user) { maintainer } + let(:job) { target_job } + end + it 'associates the job with the newly created state version' do expect { request }.to change { state.versions.count }.by(1) @@ -349,6 +359,11 @@ describe 'DELETE /projects/:id/terraform/state/:name' do subject(:request) { delete api(state_path), headers: auth_header } + it_behaves_like 'enforcing job token policies', :admin_terraform_state do + let_it_be(:user) { maintainer } + let(:auth_header) { job_basic_auth_header(target_job) } + end + it_behaves_like 'endpoint with unique user tracking' it_behaves_like 'it depends on value of the `terraform_state.enabled` config' @@ -414,6 +429,11 @@ subject(:request) { post api("#{state_path}/lock"), headers: auth_header, params: params } + it_behaves_like 'enforcing job token policies', :admin_terraform_state do + let_it_be(:user) { maintainer } + let(:auth_header) { job_basic_auth_header(target_job) } + end + it_behaves_like 'endpoint with unique user tracking' it_behaves_like 'cannot access a state that is scheduled for deletion' @@ -492,6 +512,12 @@ subject(:request) { delete api("#{state_path}/lock"), headers: auth_header, params: params } + it_behaves_like 'enforcing job token policies', :admin_terraform_state do + let_it_be(:user) { maintainer } + let(:auth_header) { job_basic_auth_header(target_job) } + let(:lock_id) { '123.456' } + end + it_behaves_like 'endpoint with unique user tracking' do let(:lock_id) { 'irrelevant to this test, just needs to be present' } end diff --git a/spec/requests/api/terraform/state_version_spec.rb b/spec/requests/api/terraform/state_version_spec.rb index 541432586c108..6af39a72b0dd2 100644 --- a/spec/requests/api/terraform/state_version_spec.rb +++ b/spec/requests/api/terraform/state_version_spec.rb @@ -98,6 +98,11 @@ context 'job token authentication' do let(:auth_header) { job_basic_auth_header(job) } + it_behaves_like 'enforcing job token policies', :read_terraform_state do + let_it_be(:user) { maintainer } + let(:auth_header) { job_basic_auth_header(target_job) } + end + context 'with maintainer permissions' do let(:job) { create(:ci_build, status: :running, project: project, user: maintainer) } @@ -153,6 +158,11 @@ describe 'DELETE /projects/:id/terraform/state/:name/versions/:serial' do subject(:request) { delete api(state_version_path), headers: auth_header } + it_behaves_like 'enforcing job token policies', :admin_terraform_state do + let_it_be(:user) { maintainer } + let(:auth_header) { job_basic_auth_header(target_job) } + end + it_behaves_like 'it depends on value of the `terraform_state.enabled` config', { success_status: :no_content } context 'with invalid authentication' do diff --git a/spec/support/shared_examples/ci/job_token_policies_shared_examples.rb b/spec/support/shared_examples/ci/job_token_policies_shared_examples.rb index 9d93df845611e..73f7bef274493 100644 --- a/spec/support/shared_examples/ci/job_token_policies_shared_examples.rb +++ b/spec/support/shared_examples/ci/job_token_policies_shared_examples.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true -RSpec.shared_examples 'enforcing job token policies' do |policies| - context 'when authenticating with a CI Job Token from another project' do - let_it_be(:job) { create(:ci_build, :running, user: user) } - let_it_be(:allowed_policies) { Array(policies) } - let_it_be(:default_permissions) { false } +RSpec.shared_examples 'enforcing job token policies' do |policies, expected_success_status: :success| + context 'when authenticating with a CI job token from another project' do + let(:source_project) { project } + let(:target_job) { create(:ci_build, :running, user: user) } + let(:allowed_policies) { Array(policies) } + let(:default_permissions) { false } before do create(:ci_job_token_project_scope_link, - source_project: project, - target_project: job.project, + source_project: source_project, + target_project: target_job.project, direction: :inbound, job_token_policies: allowed_policies, default_permissions: default_permissions @@ -21,7 +22,7 @@ response end - it { is_expected.to have_gitlab_http_status(:success) } + it { is_expected.to have_gitlab_http_status(expected_success_status) } context 'when the policies are not allowed' do let(:allowed_policies) { [] } @@ -32,7 +33,7 @@ do_request expected_message = '403 Forbidden - Insufficient permissions to access this resource ' \ - "in project #{project.path}. " + "in project #{source_project.path}. " expected_message << if Array(policies).size == 1 "The following token permission is required: #{policies}." @@ -44,9 +45,9 @@ end context 'when fine grained permissions are disabled' do - let_it_be(:default_permissions) { true } + let(:default_permissions) { true } - it { is_expected.to have_gitlab_http_status(:success) } + it { is_expected.to have_gitlab_http_status(expected_success_status) } end context 'when the `enforce_job_token_policies` feature flag is disabled' do @@ -54,7 +55,7 @@ stub_feature_flags(enforce_job_token_policies: false) end - it { is_expected.to have_gitlab_http_status(:success) } + it { is_expected.to have_gitlab_http_status(expected_success_status) } end end end diff --git a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb index c33ed76a2344d..7a856edb7abe0 100644 --- a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb @@ -391,6 +391,8 @@ RSpec.shared_examples 'recipe download_urls' do let(:recipe_path) { package.conan_recipe_path } + it_behaves_like 'enforcing read_packages job token policy' + it 'returns the download_urls for the recipe files' do expected_response = { 'conanfile.py' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py", @@ -408,6 +410,8 @@ RSpec.shared_examples 'package download_urls' do let(:recipe_path) { package.conan_recipe_path } + it_behaves_like 'enforcing read_packages job token policy' + it 'returns the download_urls for the package files' do expected_response = { 'conaninfo.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conaninfo.txt", @@ -436,8 +440,9 @@ end RSpec.shared_examples 'recipe snapshot endpoint' do - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'conan FIPS mode' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects recipe for invalid project' @@ -462,8 +467,9 @@ end RSpec.shared_examples 'package snapshot endpoint' do - subject { get api(url), headers: headers } + subject(:request) { get api(url), headers: headers } + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'conan FIPS mode' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects recipe for invalid project' @@ -515,6 +521,7 @@ 'conanmanifest.txt': 123 } end + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'conan FIPS mode' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects invalid upload_url params' @@ -578,6 +585,7 @@ 'conan_package.tgz': 523 } end + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'conan FIPS mode' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects invalid upload_url params' @@ -631,6 +639,10 @@ project.add_maintainer(user) end + it_behaves_like 'enforcing job token policies', :admin_packages do + let(:headers) { job_basic_auth_header(target_job) } + end + it 'triggers an internal event' do expect { subject } .to trigger_internal_events('delete_package_from_registry') @@ -705,6 +717,7 @@ project.update_column(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) end + it_behaves_like 'enforcing read_packages job token policy' it_behaves_like 'denies download with no token' it_behaves_like 'bumping the package last downloaded at field' @@ -767,6 +780,7 @@ end RSpec.shared_examples 'workhorse authorize endpoint' do + it_behaves_like 'enforcing admin_packages job token policy' it_behaves_like 'conan FIPS mode' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack' @@ -848,7 +862,7 @@ let(:file_name) { 'conanfile.py' } let(:params) { { file: temp_file(file_name) } } - subject do + subject(:request) do workhorse_finalize( url, method: :put, @@ -859,6 +873,7 @@ ) end + it_behaves_like 'enforcing admin_packages job token policy' it_behaves_like 'conan FIPS mode' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack' @@ -873,7 +888,7 @@ let(:file_name) { 'conaninfo.txt' } let(:params) { { file: temp_file(file_name) } } - subject do + subject(:request) do workhorse_finalize( url, method: :put, @@ -884,6 +899,7 @@ ) end + it_behaves_like 'enforcing admin_packages job token policy' it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest' it_behaves_like 'uploads a package file' @@ -1121,3 +1137,15 @@ it_behaves_like 'returning response status', :not_found end end + +RSpec.shared_examples 'enforcing read_packages job token policy' do + it_behaves_like 'enforcing job token policies', :read_packages do + let(:headers) { job_basic_auth_header(target_job) } + end +end + +RSpec.shared_examples 'enforcing admin_packages job token policy' do + it_behaves_like 'enforcing job token policies', :admin_packages do + let(:headers_with_token) { job_basic_auth_header(target_job).merge(workhorse_headers) } + end +end diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb index b6bb3601aa182..d3d547f05bd76 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb @@ -14,7 +14,7 @@ let(:headers) { {} } - subject { get(url, headers: headers) } + subject(:request) { get(url, headers: headers) } shared_examples 'accept metadata request' do |status:| it 'accepts the metadata request' do @@ -131,6 +131,10 @@ end end + it_behaves_like 'enforcing job token policies', :read_packages do + let(:headers) { build_token_auth_header(target_job.token) } + end + context 'with a group namespace' do it_behaves_like 'handles authentication' end @@ -270,7 +274,7 @@ def set_visibility(visibility, scope) { 'HTTP_CONTENT_ENCODING' => 'gzip', 'CONTENT_TYPE' => 'application/json' } end - subject { post(url, headers: headers.merge(default_headers), params: params) } + subject(:request) { post(url, headers: headers.merge(default_headers), params: params) } shared_examples 'accept audit request' do |status:| it 'accepts the audit request' do @@ -342,6 +346,14 @@ def set_visibility(visibility, scope) it_behaves_like 'reject audit request', status: :forbidden end + it_behaves_like 'enforcing job token policies', :read_packages do + before_all do + project.add_reporter(user) + end + + let(:headers) { build_token_auth_header(target_job.token) } + end + %i[oauth personal_access_token job_token deploy_token].each do |auth| context "with #{auth}" do let(:auth) { auth } @@ -399,7 +411,7 @@ def set_visibility(visibility, scope) let(:headers) { {} } - subject { get(url, headers: headers) } + subject(:request) { get(url, headers: headers) } shared_examples 'reject package tags request' do |status:| before do @@ -479,6 +491,10 @@ def set_visibility(visibility, scope) end end + it_behaves_like 'enforcing job token policies', :read_packages do + let(:headers) { build_token_auth_header(target_job.token) } + end + context 'with a group namespace' do it_behaves_like 'handles authentication' end @@ -578,7 +594,7 @@ def set_visibility(visibility, scope) end shared_examples 'handling all conditions' do - subject { put(url, env: env, headers: headers) } + subject(:request) { put(url, env: env, headers: headers) } context 'with unauthenticated requests' do let(:package_name) { 'unscoped-package' } @@ -622,7 +638,7 @@ def set_visibility(visibility, scope) end shared_examples 'handling all conditions' do - subject { delete(url, headers: headers) } + subject(:request) { delete(url, headers: headers) } context 'with unauthenticated requests' do let(:package_name) { 'unscoped-package' } @@ -715,6 +731,10 @@ def set_visibility(visibility, scope) project.update!(visibility: 'private') end + it_behaves_like 'enforcing job token policies', :admin_packages do + let(:headers) { build_token_auth_header(target_job.token) } + end + context 'with authentication methods' do %i[oauth personal_access_token job_token deploy_token].each do |auth| context "with #{auth}" do diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb index 283ab565dc4d0..cadf04a1ccc53 100644 --- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb @@ -100,6 +100,12 @@ end end +RSpec.shared_examples 'enforcing read_packages job token policy' do + it_behaves_like 'enforcing job token policies', :read_packages do + let(:headers) { build_token_auth_header(target_job.token) } + end +end + RSpec.shared_examples 'job token for package uploads' do |authorize_endpoint: false, accept_invalid_username: false| context 'with job token headers' do let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token).merge(workhorse_headers) } diff --git a/spec/tasks/ci/job_tokens_rake_spec.rb b/spec/tasks/ci/job_tokens_rake_spec.rb new file mode 100644 index 0000000000000..a41d7dc7adddd --- /dev/null +++ b/spec/tasks/ci/job_tokens_rake_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ci:job_tokens rake tasks', feature_category: :permissions do + let(:task_class) { Tasks::Ci::JobTokensTask } + let(:task) { instance_double(task_class) } + + before do + Rake.application.rake_require('tasks/ci/job_tokens') + end + + describe 'check_policies' do + it 'invokes the check methods of Ci::JobTokensTask' do + expect(task_class).to receive(:new).and_return(task) + + expect(task).to receive(:check_policies_completeness) + expect(task).to receive(:check_policies_correctness) + expect(task).to receive(:check_docs) + + run_rake_task('ci:job_tokens:check_policies') + end + end + + describe 'check_policies_completeness' do + it 'invokes the check_policies_completeness method of Ci::JobTokensTask' do + expect(task_class).to receive(:new).and_return(task) + + expect(task).to receive(:check_policies_completeness) + + run_rake_task('ci:job_tokens:check_policies_completeness') + end + end + + describe 'check_policies_correctness' do + it 'invokes the check_policies_correctness method of Ci::JobTokensTask' do + expect(task_class).to receive(:new).and_return(task) + + expect(task).to receive(:check_policies_correctness) + + run_rake_task('ci:job_tokens:check_policies_correctness') + end + end + + describe 'check_docs' do + it 'invokes the check_docs method of Ci::JobTokensTask' do + expect(task_class).to receive(:new).and_return(task) + + expect(task).to receive(:check_docs) + + run_rake_task('ci:job_tokens:check_docs') + end + end + + describe 'compile_docs' do + it 'invokes the compile_docs method of Ci::JobTokensTask' do + expect(task_class).to receive(:new).and_return(task) + + expect(task).to receive(:compile_docs) + + run_rake_task('ci:job_tokens:compile_docs') + end + end +end diff --git a/spec/tasks/ci/job_tokens_task_spec.rb b/spec/tasks/ci/job_tokens_task_spec.rb new file mode 100644 index 0000000000000..e5c7d33f01297 --- /dev/null +++ b/spec/tasks/ci/job_tokens_task_spec.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../lib/tasks/ci/job_tokens_task' + +RSpec.describe Tasks::Ci::JobTokensTask, :silence_stdout, feature_category: :permissions do + let(:task) { described_class.new } + let(:path) { Rails.root.join('tmp/tests/doc/ci/jobs') } + let(:doc) { 'fine_grained_permissions.md' } + let(:doc_path) { Rails.root.join(path, doc) } + let(:routes) do + [ + allowed_route_without_policies, + allowed_route_with_invalid_policies, + allowed_route_with_valid_policies, + allowed_route_with_skipped_policies, + not_allowed_route + ] + end + + before do + allow(task).to receive_messages(routes: routes, doc_path: doc_path) + end + + describe '#check_policies_completeness' do + subject(:check_policies_completeness) { task.check_policies_completeness } + + context 'when no allowed routes without policies are found' do + let(:routes) { [allowed_route_with_valid_policies] } + + it 'outputs a success message' do + expect { check_policies_completeness } + .to output("All allowed endpoints for CI/CD job tokens have policies defined.\n") + .to_stdout + end + end + + context 'when allowed routes without policies are found' do + let(:error_message) do + <<~OUTPUT + ########## + # + # The following endpoints allowed for CI/CD job tokens should define job token policies: + # + | Path | Description | + | ---- | ----------- | + | `GET path/to/allowed_route_without_policies` | route description | + # + ########## + OUTPUT + end + + it 'raises an error and outputs a routes table' do + expect { check_policies_completeness }.to raise_error(SystemExit).and output(error_message).to_stdout + end + end + end + + describe '#check_policies_correctness' do + subject(:check_policies_correctness) { task.check_policies_correctness } + + context 'when no routes with invalid policies are found' do + let(:routes) { [allowed_route_with_valid_policies] } + + it 'outputs a success message' do + expect { check_policies_correctness } + .to output("All defined CI/CD job token policies are valid.\n") + .to_stdout + end + end + + context 'when routes with invalid policies are found' do + let(:error_message) do + <<~OUTPUT + ########## + # + # The following endpoints have invalid CI/CD job token policies: + # + | Policies | Path | Description | + | -------- | ---- | ----------- | + | invalid_policy | `GET path/to/allowed_route_with_invalid_policies` | route description | + # + ########## + OUTPUT + end + + it 'raises an error and outputs a routes table' do + expect { check_policies_correctness }.to raise_error(SystemExit).and output(error_message).to_stdout + end + end + end + + describe '#check_docs' do + subject(:check_docs) { task.check_docs } + + before do + FileUtils.mkdir_p(path) + task.compile_docs + end + + context 'when the docs are up to date' do + it 'outputs a success message' do + expect { check_docs } + .to output("CI/CD job token allowed endpoints documentation is up to date.\n") + .to_stdout + end + end + + context 'when the doc is updated manually' do + before do + File.write(doc_path, 'Manually adding this line at the end of the the doc', mode: 'a+') + end + + let(:error_message) do + <<~OUTPUT + ########## + # + # CI/CD job token allowed endpoints documentation is outdated! Please update it by running `bundle exec rake ci:job_tokens:compile_docs`. + # + ########## + OUTPUT + end + + it 'raises an error' do + expect { check_docs }.to raise_error(SystemExit).and output(error_message).to_stdout + end + end + + context 'when the doc is not up to date' do + before do + allow(task).to receive(:routes).and_return([]) + end + + let(:error_message) do + <<~OUTPUT + ########## + # + # CI/CD job token allowed endpoints documentation is outdated! Please update it by running `bundle exec rake ci:job_tokens:compile_docs`. + # + ########## + OUTPUT + end + + it 'raises an error' do + expect { check_docs }.to raise_error(SystemExit).and output(error_message).to_stdout + end + end + end + + describe '#compile_docs' do + subject(:compile_docs) { task.compile_docs } + + before do + FileUtils.mkdir_p(path) + end + + it 'outputs a success message' do + expect { compile_docs } + .to output("CI/CD job token allowed endpoints documentation compiled.\n") + .to_stdout + end + + it 'creates fine_grained_permissions.md', :aggregate_failures do + FileUtils.rm_f(doc_path) + expect { File.read(doc_path) }.to raise_error(Errno::ENOENT) + expect(task).to receive(:allowed_endpoints) + + compile_docs + + expect(File.read(doc_path)).to match(/This documentation is auto generated by a Rake task/) + end + end + + describe '#allowed_endpoints' do + let(:table) do + <<~TABLE.chomp + | Permissions | Permission Names | Path | Description | + | ----------- | ---------------- | ---- | ----------- | + | None | | `GET path/to/allowed_route_with_skipped_policies` | route description | + | None | | `GET path/to/allowed_route_without_policies` | route description | + | Packages: Read | `READ_PACKAGES` | `GET path/to/allowed_route_with_valid_policies` | route description | + | invalid_policy | `INVALID_POLICY` | `GET path/to/allowed_route_with_invalid_policies` | route description | + TABLE + end + + it 'returns a sorted table for the docs that includes allowed routes only' do + expect(task.allowed_endpoints).to eq(table) + end + end + + def allowed_route_without_policies + instance_double(Grape::Router::Route, + settings: { + authentication: { job_token_allowed: true } + }, + request_method: 'GET', + description: 'route description', + origin: 'path/to/allowed_route_without_policies' + ) + end + + def allowed_route_with_invalid_policies + instance_double(Grape::Router::Route, + settings: { + authentication: { job_token_allowed: true }, + authorization: { job_token_policies: :invalid_policy } + }, + request_method: 'GET', + description: 'route description', + origin: 'path/to/allowed_route_with_invalid_policies' + ) + end + + def allowed_route_with_valid_policies + instance_double(Grape::Router::Route, + settings: { + authentication: { job_token_allowed: true }, + authorization: { job_token_policies: :read_packages } + }, + request_method: 'GET', + description: 'route description', + origin: 'path/to/allowed_route_with_valid_policies' + ) + end + + def allowed_route_with_skipped_policies + instance_double(Grape::Router::Route, + settings: { + authentication: { job_token_allowed: true }, + authorization: { skip_job_token_policies: true } + }, + request_method: 'GET', + description: 'route description', + origin: 'path/to/allowed_route_with_skipped_policies' + ) + end + + def not_allowed_route + instance_double(Grape::Router::Route, + settings: { + authentication: { job_token_allowed: false }, + authorization: { job_token_policies: :read_packages } + }, + request_method: 'GET', + description: 'route description', + origin: 'path/to/route' + ) + end +end diff --git a/tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb b/tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb new file mode 100644 index 0000000000000..3e7ffa964e648 --- /dev/null +++ b/tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb @@ -0,0 +1,30 @@ +--- +stage: Software Supply Chain Security +group: Pipeline Security +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +<!-- + This documentation is auto generated by a Rake task. + + Please do not edit this file directly. To update this file, run: + `bundle exec rake ci:job_tokens:compile_docs`. + + To make changes to the output of the Rake task, + edit `tooling/ci/job_tokens/docs/templates/fine_grained_permissions.md.erb`. +--> + +# Fine-grained permissions for CI/CD job tokens + +DETAILS: +**Tier:** Free, Premium, Ultimate +**Offering:** GitLab.com, GitLab Self-Managed, GitLab Dedicated + +## Available API endpoints + +The following endpoints are available for CI job tokens. +You can use fine-grained permissions to explicitly allow access to a limited set of the following API endpoints. + +`None` means fine-grained permissions cannot control access to this endpoint. + +<%= allowed_endpoints %> -- GitLab