docs(plans): plan the ADR-020 service-token credential for the AR client
Summary
Plans the Rails counterpart to AR's service-token guard: the monolith stops
sending the interim bootstrap bearer on /api/gitlab/v1 and presents the
ADR-020 per-edge service token in the Gitlab-Artifact-Registry-Token header
instead.
Adds docs/plans/monolith/2026-08-20-s02-service-token-credential.md and amends
the two specs the change falsifies.
The AR side is already done. ops/artifact-registry#356 closed on 2026-08-21: the guard shipped and the rollout reached staging (1.343.1), production (1.350.0) and the caproni rig (gitlab-caproni!84), so every deployed environment runs in configured mode. That work item's closing note names gitlab#617724, which this plan implements, as one of the two things left. So this is the last step on the edge rather than a blocked one.
Plan shape
One step, one MR, in the monolith:
artifact_registry.service_token.secret_filesettings key plus itsgitlab.yml.examplestanza, mirroringiam_data_access_service.ArtifactRegistry::ServiceCredential#tokenreads that secret instead of returningnil.ArtifactRegistry::Clientthreads the credential's transport form from the two entry points,user_requestandservice_request, through the sharedauthed_requestintoperform_request.authed_requestcarries whichever header it is handed and composes neither itself, so it cannot re-attach a bearer on the service path.ArtifactRegistry::ProvisionNamespaceService, the secondClient.newsite, which injects its own credential and so does not inherit the default provider.
Not split, because splitting at the credential/transport seam would ship an intermediate that returns the real token while still sending it as a bearer, and AR rejects that shape in both of its modes.
Spec amendments
monolith/S02: Credential acquisition, Rails configuration stanza, Acceptance criteria, Security considerations, Resolutions, and the dependency table. The spec said the client attaches every credential as a bearer token.monolith/S10: its dependency table, the Ruby client Credential and Injected inputs paragraphs, and the credential acceptance criterion. S10 claimed a slice wires a bootstrap service provider; no slice does.
Diff size
696 insertions across four files, past the 500 reviewable-LOC line in
docs/dev/development-model.md, and not worth splitting:
| Lines | File group |
|---|---|
| +558 | the new plan, which is one document and cannot be usefully halved |
| +115 | monolith/S02 |
| +22 | monolith/S10 |
| +1 | the docs/plans/README.md index row |
The two spec amendments are the plan's own gate, so landing them separately would leave a plan whose Spec Reference points at text that contradicts it.
Two findings worth a reviewer's attention
- The header constant is settled, not an open question as the issue implies.
AR declares
HeaderName = "Gitlab-Artifact-Registry-Token"ininternal/auth/servicetoken/servicetoken.go, asserted in its test and published ascomponents.securitySchemes.serviceTokeninapi/openapi/gitlab-v1.yaml. ADR-020 leaves the name to each service, so the client's constant matches AR's rather than declaring the contract. - AR's two modes are exclusive, not additive. With a token configured it rejects a bearer-only request; with none configured its fallback rejects a header-only request. The ordering that follows is already satisfied wherever AR is deployed, so mounting the Rails secret closes the edge rather than opening a window. It still binds a new environment, which is why the plan and the S02 amendment both record it.
Governing ADRs
-
ADR-020 Authentication Flow is the decision source. Conforms: a static symmetric per-edge secret, no JWT, no audience and no principal, because the call acts for no end user; the header name is the service's to choose; the validator accepts a current and a next value so rotation needs no cutover.
Read against the handbook copy, not the local mirror.
scripts/adr-freshness.shreports the mirror 10 commits stale with020_authentication_flow.mdamong the drifted files, and the unsynced commit is the one that added the service-to-service section, which the mirror therefore does not contain.One thing the spec now records rather than glosses: ADR-020 calls this token interim for this edge, with mutual TLS and workload identity as the direction and neither decided. So the seam stays injectable, and "settled" means settled for this iteration.
-
ADR-014 (
docs/adr/014_frontend_to_artifact_registry.md) governs the client pattern and the server-held credential. Conforms: the credential is acquired and attached server-side and never reaches the browser. Not drifted in the mirror. -
ADR-009 (
docs/adr/009_api_design.md) governs the GitLab API surface. Conforms: the service seam takes no slug, because that surface is slug-exempt.
Testing
Documentation only, so verification is a consistency read rather than a suite. What was checked, and against what:
- Every monolith path and symbol against
origin/masterof$GITLAB_MONOLITH_PATH, by symbol rather than by line number. - Every AR path against
origin/mainof this repository. The servicetoken implementation is not in this branch's working tree, which forks from before it, so a reviewer grepping the checkout finds nothing and should usegit show origin/main:.... - The header name, the exclusive-modes behaviour and both rejection log lines
against
internal/auth/servicetoken/servicetoken.goand its test. - The mirror this plan copies (
Authn::IamDataAccessService) against its own source and spec, including where this plan departs from it: the error behaviour deliberately, and the memoization scope, the credential's name and nesting, and the header constant's home as consequences of the surrounding code. markdownlint-cli2,valeandlycheeclean on the three changed files.
No e2e scenario applies: docs/testing/e2e/ covers the artifact formats, and
none drives the GitLab API namespace methods, whose two callers are fail-closed
until an operator mounts the secret.
Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/617724