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