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