Deploy token support for /api/v4/token_exchange endpoint
## Summary
`POST /api/v4/token_exchange` (introduced in <https://gitlab.com/gitlab-org/gitlab/-/merge_requests/236798>) accepts five of the six token types required by the [Artifact Registry auth interface agreement R1](https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/artifact_registry/agreements/auth/#r1--token-exchange-service):
| token type | status in `!236798` |
|---|---|
| Legacy PAT | accepted, returns a JWT |
| Granular PAT (FGT) | accepted, via `skip_granular_token_authorization` |
| Project/group access token | accepted |
| OAuth bearer | accepted |
| CI job token | accepted, via `route_setting :authentication, job_token_allowed: true` |
| **Deploy token** | **deliberately NOT accepted** (returns `401` at the auth gate) |
This issue tracks the design + implementation conversation needed to add deploy-token support.
## Why deploy tokens are not a simple add
`DeployToken` ([`app/models/deploy_token.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/deploy_token.rb)) is **not a `User`**. It's a separate ActiveRecord model:
- Belongs to a `Project` OR a `Group` (`deploy_token_type` enum + `num_nonnulls(group_id, project_id) = 1` check constraint).
- Has its own permission flags (`read_repository`, `read_registry`, `read_package_registry`, `read_virtual_registry`, and the matching `write_*` ones) -- none for token issuance.
- **Has no `organization_id` column.**
- **Has no `flipper_id`** (not a `FeatureGate`).
If we set `route_setting :authentication, deploy_token_allowed: true` on the endpoint, `current_user` after auth becomes a `DeployToken` instance (via `user_from_namespace_inheritable` at [`lib/api/helpers/authentication.rb#L56-61`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/api/helpers/authentication.rb#L56-61)). Two immediate consequences in the current code:
1. The FF check `Feature.enabled?(:gate_token_exchange_endpoint, current_user, type: :gitlab_com_derisk)` would raise `NoMethodError: undefined method 'flipper_id' for #<DeployToken ...>`.
2. Even if we got past that, the JWT payload (`sub: current_user.id.to_s`, `gitlab_organization_id: current_user.organization_id`) would:
- put the **deploy-token's id** in `sub` -- not a "GitLab user id" as R3 expects;
- crash on `current_user.organization_id` (`NoMethodError`).
So naive opt-in turns the current clean `401` into a `500`, with no useful JWT shape.
## Why "use the deploy token's creator" is not a workaround
The model itself documents this. From [`app/models/deploy_token.rb` (current master, lines 25-28)](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/deploy_token.rb#L25-28):
```ruby
# Do NOT use this `user` for the authentication/authorization of the deploy tokens.
# It's for the auditing purpose on Credential Inventory, only.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/353467#note_859774246 for more information.
belongs_to :user, foreign_key: :creator_id, optional: true
```
The linked rationale (commented on <https://gitlab.com/gitlab-org/gitlab/-/issues/353467#note_859774246>):
> `deploy_tokens` is a token acts as an individual system user, thus it doesn't have an association with a particular user.
>
> - The creator of the token can be removed from the project membership or banned from the GitLab. Do we remove the associated token in those cases?
> - **We'd not want this creator (= an individual user) to be used for subsequent authorization processes.** This became a huge head-ache in [Deploy Keys](https://docs.gitlab.com/user/project/deploy_keys/), such as <https://gitlab.com/gitlab-org/gitlab/-/issues/30769>.
> - By the above reason, we would shift to **create a service account by default** for Deploy Keys, meaning **deploy keys/tokens should be kept as a service account that is treated as a project-level resource, instead of individual user representation**.
So the "use creator" route is actively counter to a multi-year team direction, with concrete prior pain (Deploy Keys). A solution should treat the deploy token as **a project/group-level service-account principal**, not as a thin wrapper over an individual user.
## What R1 / R3 say
- **R1** ([handbook](https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/artifact_registry/agreements/auth/#r1--token-exchange-service)): the endpoint MUST accept PATs, OAuth tokens, CI job tokens, deploy tokens, and project/group access tokens.
- **R3** ([handbook](https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/artifact_registry/agreements/auth/#r3--token-payload)): the token MUST contain the principal's GitLab ID. For a User principal that's `user.id`. For a deploy token, "the principal" is undefined.
R1 requires the call to succeed. R3 doesn't currently define what success looks like for a deploy token.
## Three plausible directions
### Option 1 -- Stay as-is
Don't enable `deploy_token_allowed`. Deploy-token callers get `401` at the auth gate. Documented in the endpoint with a comment, asserted by a spec. R1 partially unmet.
- Pros: honest, no semantic guesswork, no crash.
- Cons: doesn't satisfy R1.
### Option 2 -- Use the deploy token's creator as the principal
`sub = deploy_token.creator_id`, then proceed as if the creator User authenticated.
- Pros: minimal code change.
- Cons:
- Counter to the in-code guidance and the 2022 reasoning above.
- The creator can be removed from the project or banned -- the deploy token outlives that. The resulting JWT would carry a stale / banned user as principal.
- The Deploy Keys parallel (gitlab#30769) is a direct counter-example.
Likely a non-starter unless the auth-team direction has materially changed since 2022.
### Option 3 -- Polymorphic principal claim shape
Treat the deploy token as a first-class non-User principal. Concretely:
- Add a `principal_type` claim. `User`-authenticated callers get `principal_type: "user"`, deploy-token callers get `principal_type: "deploy_token"`.
- For deploy-token callers, `sub` is the deploy token's id. Possibly also include `gitlab_owner_project_id` / `gitlab_owner_group_id` as additional context.
- For `gitlab_organization_id`, derive from the owner's project / group's organization (`deploy_token.owner_project&.organization_id || deploy_token.owner_group&.organization_id`).
- AR's verifier dispatches on `principal_type` and applies the appropriate per-repo authz path.
Matches the 2022 "service account / project-level resource" framing, and gives AR an explicit signal to route differently.
A worked example of the payload for a `principal_type: "deploy_token"` flow:
```json
{
"jti": "...",
"iss": "http://gdk.test:3443",
"aud": ["gitlab-artifact-registry"],
"sub": "42",
"principal_type": "deploy_token",
"iat": 1779870540,
"nbf": 1779870540,
"exp": 1779870840,
"gitlab_realm": "saas",
"gitlab_organization_id": 1,
"gitlab_owner_project_id": 7
}
```
The verifier reads `principal_type: "deploy_token"` and uses the token's scopes / owner project for authz, instead of treating `sub` as a user id.
## Acceptance criteria for this follow-up
- [ ] AR team confirms the deploy-token call shape (likely option 3) and what `sub` / extra claims they need.
- [ ] Endpoint enables `route_setting :authentication, deploy_token_allowed: true`.
- [ ] The FF check is restructured so a `DeployToken` `current_user` doesn't raise (e.g. resolve to `:instance` actor when `current_user` is not a `FeatureGate`).
- [ ] `TokenIssuer` dispatches on principal type: builds the User-shaped payload for User callers and the deploy-token-shaped payload otherwise.
- [ ] Request spec covers a deploy-token caller returning `201` with the agreed claim shape (replaces today's `401` assertion).
- [ ] Endpoint comment / MR description updated to reflect the new support.
## Related links
- Source MR: <https://gitlab.com/gitlab-org/gitlab/-/merge_requests/236798>
- Companion CC catalog MR: <https://gitlab.com/gitlab-org/cloud-connector/gitlab-cloud-connector/-/merge_requests/241>
- R1 / R3 handbook: <https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/artifact_registry/agreements/auth/>
- ADR-019 (Identity Federation): <https://gitlab.com/gitlab-org/architecture/auth-architecture/design-doc/-/merge_requests/121>
- `DeployToken` model (current master): <https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/deploy_token.rb>
- "Do not use creator as principal" rationale: <https://gitlab.com/gitlab-org/gitlab/-/issues/353467#note_859774246>
- Parallel pain in Deploy Keys: <https://gitlab.com/gitlab-org/gitlab/-/issues/30769>
- AR-team Ability follow-up: <https://gitlab.com/gitlab-org/gitlab/-/work_items/599081>
- Parent epic: <https://gitlab.com/groups/gitlab-org/-/epics/22052>
issue