本文环境
| 组件 | 版本 / 说明 |
|---|---|
| 极狐GitLab | 19.x(私有化部署,作业令牌默认 JWT 格式,19.0 起) |
| GitLab Runner | 17.x,Docker 执行器 |
| 客户端工具 | curl 8.x、git 2.4x、jq 1.6 |
| 涉及项目 | group-a/ci-orchestrator(触发方)、group-b/shared-libs(被访问方) |
| 角色要求 | 两个项目均需维护者及以上 |
一、问题现象:三种 404,长得一模一样
跨项目调用是流水线里最自然的动作:一个编排项目去触发公共库项目的流水线,或者从制品仓库里拉一个共享包。配置看起来没错,令牌也是现成的 CI_JOB_TOKEN,跑起来却是 404。
我这次遇到的是三个场景,报错长得几乎一样,但排查路径完全不同。
场景一:跨项目克隆代码仓库
bash
$ git clone https://gitlab-ci-token:$CI_JOB_TOKEN@gitlab.example.com/group-b/shared-libs.git
Cloning into 'shared-libs'...
remote: The project you were looking for could not be found or you don't have permission to view it.
fatal: repository 'https://gitlab-ci-token:[MASKED]@gitlab.example.com/group-b/shared-libs.git/' not found
场景二:跨项目下载通用包
bash
$ wget --header="JOB-TOKEN: $CI_JOB_TOKEN" \
${CI_API_V4_URL}/projects/1234/packages/generic/my_package/0.0.1/file.txt
--2026-10-22 06:51:13-- https://gitlab.example.com/api/v4/projects/1234/packages/generic/my_package/0.0.1/file.txt
HTTP request sent, awaiting response... 404 Not Found
2026-10-22 06:51:13 ERROR 404: Not Found.
场景三:跨项目触发下游流水线
bash
$ curl --verbose --request POST --form "token=$CI_JOB_TOKEN" --form ref=master \
"https://gitlab.example.com/api/v4/projects/1234/trigger/pipeline"
< HTTP/2 404
< content-type: application/json
{"message":"404 Not Found"}
这三个场景的共同点是:都用了 CI_JOB_TOKEN,都返回 404,但代码路径完全不同。如果你的第一反应是"项目 ID 填错了",大概率会浪费半天------项目 ID 是对的,只是令牌没有访问那个项目的权限。
二、为什么是 404 而不是 403
这是本次排查最反直觉的一点。权限不足,为什么返回"找不到"而不是"不允许"?
原因是安全设计上的主动选择:作业令牌鉴权失败时,极狐GitLab 有意返回 404 Not Found 或类似响应,目的是不向调用方泄露"这个项目存在但你没权限"的信息。对外部攻击者来说,404 和 403 的差别就是"目标是否存在"这条情报。
所以排查的第一步不是查 URL,而是确认令牌的作用域 。默认情况下,作业令牌的访问范围仅限于在当前项目自身的流水线中运行的作业。想让 A 项目的作业用令牌访问 B 项目,必须显式把 A 加入 B 的作业令牌允许名单,同时还得满足另外两个条件:
- 触发作业的用户必须是 B 项目的成员;
- 该用户必须具有执行该操作的权限。
注意第 2 条------加允许名单不等于授予权限。把项目 A 加进 B 的名单,只是允许 A 的作业令牌"拿着 A 成员的身份去敲门",能不能进还是看那位成员在 B 里本来有什么角色。这条理解错了,后面每一步都会白做。
三、9 步根治:从确认身份到验证生效
步骤 1:先确认"是谁在敲门"
加允许名单之前必须搞清楚一件事:作业令牌携带的是触发流水线那个用户的身份。流水线不是以项目身份运行的,是以人身份运行的。
bash
# 在编排项目的作业里打印触发者
echo "triggered by: $GITLAB_USER_LOGIN"
echo "job token exists: ${#CI_JOB_TOKEN} chars"
拿到这个用户名之后,去目标项目 group-b/shared-libs 的成员列表里查他的角色。如果他是 Guest 或根本不是成员,后面怎么配都是 404。
踩坑 1:我第一版排查直接去加允许名单,加完还是 404。原因就是触发者是 CI 机器人账号,只在编排项目里有权限,在目标项目里没有成员身份。允许名单不授予权限,它只是开门的资格。
步骤 2:确认目标项目的作业令牌范围是"受限"
默认情况下,作业令牌访问仅限于项目自身流水线的作业。如果你的实例是私有化部署,管理员还可以强制启用并锁定这个限制------锁定后所有项目的作业令牌都严格受允许名单约束,项目层面无法关掉。
确认路径:目标项目 → 设置 → CI/CD → 展开「作业令牌权限」。
步骤 3:把编排项目加进允许名单
在目标项目 group-b/shared-libs 里操作:设置 → CI/CD → 作业令牌权限 → 「CI/CD 作业令牌允许名单」右侧点「添加」→ 选择「项目」→ 输入 group-a/ci-orchestrator → 添加。
批量配置可以用 GraphQL(字段名以你实例的 GraphiQL 校验为准):
bash
curl --request POST \
--header "Authorization: Bearer $ADMIN_TOKEN" \
--header "Content-Type: application/json" \
--data '{"query":"mutation { ciJobTokenScopeAddProject(input:{projectPath:\"group-b/shared-libs\", targetProjectPath:\"group-a/ci-orchestrator\", direction:INBOUND}) { errors } }"}' \
"$GITLAB_HOST/api/graphql"
步骤 4:群组条目还是项目条目,想清楚再选
把群组 加进允许名单,等于把该群组下所有项目以及任意层级的子群组项目都放行,而且极狐GitLab 在每次使用令牌时才评估匹配------之后新建的项目自动生效,不用再改名单。
代价是粒度粗。并且有硬上限:群组最多 200 个,项目最多 200 个,两个限制分别计算。
踩坑 2:我把 12 个群组一次性加进去之后,后面再加项目条目时静默失败,页面没有任何红色提示。排查发现是项目条目数已经顶到 200 上限。条目数是分别算的,别以为群组没满就没事。
步骤 5:公开和内部项目,光配允许名单不够
这一条最容易漏。如果目标项目是公开或内部项目,不在允许名单里的项目依然可以用作业令牌访问它的:产物、容器镜像仓库、软件包仓库、发布/部署/环境、代码仓库。允许名单管不到这里。
要收紧,得去:设置 → 通用 → 展开「可见性、项目功能、权限」,把对应功能的可见性改成「仅项目成员」。
踩坑 3 :我们有个公共组件库是内部项目,配完允许名单以为万事大吉,结果外部流水线照样能拉包。就是因为包仓库的可见性没改。其中「获取产物」的访问权限由 CI/CD 可见性设置控制,不在包仓库那一栏里。
步骤 6:Git 推送权限单独开关,默认关闭
如果作业需要往目标项目推代码,还有一道独立开关:设置 → CI/CD → 作业令牌权限 → 权限 → 「允许向代码仓库发起 Git 推送请求」。
这个开关默认关闭,开启后有三个必须知道的行为:
- 只有在目标项目自身流水线中运行的作业生成的令牌,才能推到该项目;
- 用作业令牌推送不会触发任何 CI/CD 流水线;
- 使用 semantic-release 这类工具时,此设置可能会阻止流水线创建。
官方文档还专门提醒:不要在配置为拉取镜像的项目上启用它 ,尤其当镜像更新会跑流水线的时候------上游仓库所有者可能尝试用 CI_JOB_TOKEN 往镜像项目推提交。
步骤 7:细粒度权限,只放行需要的端点
如果目标只是"能下载某个包的某个文件",没必要放开整个项目。细粒度权限允许你明确放行一组有限的 REST API 端点,把爆炸半径压到最小。
踩坑 4 :作业令牌不能用于对 GraphQL 请求做身份认证。我一开始想用 GraphQL 批量查询目标项目的包列表,鉴权直接失败。GraphQL 必须换 Personal Access Token 或项目访问令牌。
步骤 8:改造流水线,用推荐方式传递令牌
跨项目下载制品,官方推荐用请求头而不是 URL 参数(?job_token= 不推荐,会进日志):
yaml
fetch_shared_pkg:
stage: prepare
script:
- |
curl --fail --silent --show-error \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
--output shared.tar.gz \
"${CI_API_V4_URL}/projects/${TARGET_PROJECT_ID}/packages/generic/shared_lib/1.2.0/shared.tar.gz"
- tar -xzf shared.tar.gz
跨项目触发下游流水线:
yaml
trigger_downstream:
stage: deploy
trigger:
project: group-b/shared-libs
branch: main
或者用 API 触发并带上变量:
bash
curl --request POST \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
--form "ref=main" \
--form "variables[UPSTREAM_SHA]=$CI_COMMIT_SHA" \
"${CI_API_V4_URL}/projects/${TARGET_PROJECT_ID}/trigger/pipeline"
跨项目克隆代码仓库(即使实例禁用了 HTTPS 协议,作业令牌克隆依然可用):
bash
git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.example.com/group-b/shared-libs.git
步骤 9:用身份认证日志确认生效
配置完别靠"跑一次没报错"下结论。目标项目的作业令牌权限区域里有「身份认证日志」,会列出哪些项目用作业令牌访问过你的项目:
- 最多显示 100 条事件,超出需下载 CSV;
- 新的身份认证事件最多需要 5 分钟才会出现,立刻查会误判为没生效;
- 可以下载 CSV 做审计。
顺手写个批量自检脚本,把三个场景一次验完:
bash
#!/usr/bin/env bash
set -u
TARGET_ID="$1"
check() {
local name="$1"; local code="$2"
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
echo "PASS $name (HTTP $code)"
else
echo "FAIL $name (HTTP $code)"
fi
}
code=$(curl -s -o /dev/null -w "%{http_code}" \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
"${CI_API_V4_URL}/projects/${TARGET_ID}/packages/generic/shared_lib/1.2.0/shared.tar.gz")
check "package-download" "$code"
code=$(curl -s -o /dev/null -w "%{http_code}" \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
"${CI_API_V4_URL}/projects/${TARGET_ID}/repository/branches")
check "repo-read" "$code"
四、验证结果对比
| 场景 | 修复前 | 修复后 | 关键动作 |
|---|---|---|---|
| 跨项目克隆代码仓库 | 404 not found | 克隆成功 | 加入允许名单 + 触发者为目标项目成员 |
| 跨项目下载通用包 | 404 Not Found | HTTP 200 | 允许名单 + 包仓库可见性设为仅项目成员 |
| 跨项目触发流水线 | {"message":"404 Not Found"} |
下游流水线创建 | 允许名单 + 改用 JOB-TOKEN 请求头 |
| 外部项目拉内部项目包(未授权) | 可拉取 | 404 | 包仓库可见性收紧 |
| 长任务(>6 分钟无输出) | 403 Forbidden | 正常 | 升级到 18.9 及以上 |
五、另一个 403:跟权限一点关系都没有
排查过程中还撞到一类报错,日志里是 403 而不是 404,很容易被当成权限问题继续折腾允许名单。实际上它跟权限无关:
在极狐GitLab 18.8 及更早版本中使用 JWT 格式作业令牌时,作业可能失败并报 403 Forbidden,常见于三种情况:
- 使用了
needs的作业; - 子流水线中的作业;
- 运行时间超过约 6 分钟且没有任何控制台输出的作业。
Runner 侧日志长这样:
bash
WARNING: Submitting job to coordinator... job failed
code=403 job=<job_id> status=PUT https://gitlab.example.com/api/v4/jobs/<job_id>: 403 Forbidden
踩坑 5:我们有个编译作业要跑 20 分钟,中间只在结束时才输出日志,前 6 分钟静默。它在 18.8 上稳定 403,加什么允许名单都没用。解决办法是升级到 18.9;在升级之前,临时规避方式是让脚本持续输出进度(比如每分钟打一行时间戳),避免长时间零输出。
顺带记一条版本事实:从 19.0 开始,CI/CD 作业令牌默认使用 JWT 标准格式。如果某些下游系统依赖旧版格式,可以通过为项目配置顶级群组后关闭「为 CI/CD 作业令牌启用 JWT 格式」来沿用旧格式------这个开关只在极狐GitLab 20.0 发布前可用。
六、什么情况下别这么做
允许名单是个方便的东西,但有几个场景它并不是正确答案:
- 不要把「所有群组和项目」当默认选项。这是私有化部署上一个可关闭允许名单的开关,官方明确提示存在安全风险------恶意用户可以创建一条由你项目维护者触发的流水线,用作业令牌反过来访问你的项目。只应在测试时临时打开,用完立刻还原。
- 不要把令牌放进 URL 查询串 。
?job_token=是不推荐的传递方式,容易落进代理日志和浏览器历史。统一用JOB-TOKEN请求头。 - 不要把长期凭证换成作业令牌到处传。作业令牌只在作业运行期间有权限,作业结束、被清除或项目删除时立即失效------这是它的优点,也意味着它不适合做跨流水线的长期集成。那种场景应该用项目访问令牌或 OIDC。
- 机器人账号要单独治理。如果流水线由 CI 机器人触发,它必须在目标项目里有成员身份且具备最小必要角色,否则允许名单形同虚设。
- 别在镜像拉取项目上开 Git 推送权限,尤其镜像更新会触发流水线时会形成回路。
踩坑 6:我一度想省事,把编排项目做成公开项目,这样"谁都能访问"就不用配名单了。结果是对外暴露了内部组件的下载入口,安全同事第二天就找上门。公开不是简化配置的方式。
七、文末福利
这篇里用到的三样东西我都整理好了:
- 批量自检脚本
check-job-token.sh(覆盖包下载、仓库读取、流水线触发三个场景,输出 PASS/FAIL); - 允许名单配置清单(含 200 上限的计数提醒与公开项目可见性收紧项);
- 18.8 长任务 403 的临时规避脚本(持续输出进度版)。
需要的同学评论区留一句「作业令牌」,或者去我主页置顶动态里自取,脚本和清单都放在一起了。如果你的实例版本与我这篇不同(尤其是 18.8 之前),评论区说一下版本号,我把对应差异补上。