Present the ADR-020 service token to Artifact Registry
What does this MR do and why?
Switches the Artifact Registry client's service credential from the interim
bootstrap bearer to the ADR-020 per-edge service token, presented in AR's
dedicated Gitlab-Artifact-Registry-Token header. The per-user path is
untouched and keeps Authorization: Bearer.
AR shipped the guard on /api/gitlab/v1 and completed its rollout on
2026-08-21: staging (1.343.1), production (1.350.0) and the caproni rig all run
it in configured mode. AR's two modes are exclusive: with a token configured it
rejects a bearer-only request, so this MR is what closes the edge rather than
opening a window. With no secret_file configured the client fails closed
exactly as it does today.
Implements the merged plan ops/artifact-registry!1827, Step 1 of 1.
What changed
config/initializers/1_settings.rb:artifact_registry.service_token.secret_file, no production default, mirroringiam_data_access_service.secret_file.config/gitlab.yml.example: the key documented, commented out, at the path the chart mounts.ArtifactRegistry::Configuration.service_token_secret_file: the settings read, by hash key rather than the method reader, which raisesGitlab::Configs::MissingConfigon an instance that never set the key.ArtifactRegistry::ServiceCredential#token: reads that file,chomped and memoized per instance;nilwhen unconfigured, empty or whitespace-only.ArtifactRegistry::Client:SERVICE_TOKEN_HEADER, and each entry point now binds its own transport form so the shared transport composes what it is handed. The blank guard still tests the bare credential, not the composed header: a"Bearer "prefix is never blank and a guard on it would stop failing closed.
Two notes for the reviewer
- The transport form travels as a frozen
{header:, prefix:}pair, not as two bare string parameters. The plan's accepted-smell bullet describes bare strings; two extra keyword parameters on bothauthed_requestandperform_requesttripMetrics/ParameterLists. One parameter each keeps the strings bare and the entry point still supplies both halves, which is the property the plan asks for. - A configured
secret_filethe process cannot read raisesErrno::ENOENTorErrno::EACCESrather than resolving tonil, matching theiam_data_access_servicemirror. Swallowing it would turn a broken mount into a silent permanent outage that reads as an unconfigured deployment. Covered by tests.
References
- Work item: https://gitlab.com/gitlab-org/gitlab/-/work_items/617724
- Merged plan: ops/artifact-registry!1827
- AR-side guard (the receiver this MR sends to): the
servicetokenpackage ininternal/auth/servicetoken, headerGitlab-Artifact-Registry-Token.
Screenshots or screen recordings
N/A. No user-facing change: this changes which header the monolith sends to an
internal service, on a surface with no reachable caller (artifact_registry_ui
is off by default).
How to set up and validate locally
Unit and request specs
bundle exec rspec \
ee/spec/lib/artifact_registry/service_credential_spec.rb \
ee/spec/lib/artifact_registry/configuration_spec.rb \
ee/spec/lib/artifact_registry/client_spec.rb \
ee/spec/models/concerns/artifact_registry/caches_client_spec.rb \
ee/spec/services/artifact_registry/provision_namespace_service_spec.rb \
ee/spec/requests/api/graphql/organizations/artifact_registry_spec.rb \
spec/initializers/1_settings_spec.rb599 examples, 0 failures. RuboCop clean on the touched files.
End-to-end wire test against the real AR guard
The specs stub the wire, so they cannot prove the header the monolith emits is
the one the real AR guard accepts. This drives the real Rails service client
over HTTP against the actual merged AR servicetoken.Middleware (built from
gitlab-org/ops/artifact-registry main), running in configured mode.
-
Build a tiny harness that wraps the real AR guard around a handler that echoes a namespace body. From an
artifact-registrycheckout, drop this atcmd/ar-e2e-harness/main.go,go build, then remove the source (it is not part of the service):package main import ( "context" "encoding/json" "fmt" "log" "net/http" "os" "strings" "gitlab.com/gitlab-org/labkit/v2/secret" "gitlab.com/gitlab-org/ops/artifact-registry/internal/auth" "gitlab.com/gitlab-org/ops/artifact-registry/internal/auth/servicetoken" ) type denyValidator struct{} func (denyValidator) Validate(context.Context, secret.Secret) (*auth.Identity, error) { return nil, auth.ErrTokenInvalid } func main() { token := os.Getenv("AR_E2E_SERVICE_TOKEN") next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("HANDLER reached: path=%s %s=%q Authorization=%q", r.URL.Path, servicetoken.HeaderName, r.Header.Get(servicetoken.HeaderName), r.Header.Get("Authorization")) uuid := strings.TrimPrefix(r.URL.Path, servicetoken.GitlabAPIPrefix+"namespaces/") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"id": uuid, "slug": "e2e"}) }) guarded := servicetoken.Middleware(next, denyValidator{}, secret.NewSecret(token)) mux := http.NewServeMux() mux.Handle(servicetoken.GitlabAPIPrefix, guarded) log.Printf("listening, guarding %s with header %s", servicetoken.GitlabAPIPrefix, servicetoken.HeaderName) log.Fatal(http.ListenAndServe("127.0.0.1:8099", mux)) } -
Create the mounted-secret files and start the harness with the same token (the file carries a trailing newline, so
chompis exercised):printf 'ar-svc-token\n' > /tmp/ar-e2e/secret_file printf 'wrong-token\n' > /tmp/ar-e2e/wrong_file AR_E2E_SERVICE_TOKEN=ar-svc-token ./ar-e2e-harness & -
From the monolith on this branch, run the driver with
rails runner:# /tmp/ar-e2e/monolith_e2e.rb base_url = 'http://127.0.0.1:8099' uuid = '0192abcd-0000-7000-8000-000000000042' Settings.artifact_registry ||= {} Settings.artifact_registry['api_url'] = base_url Settings.artifact_registry['service_token'] = { 'secret_file' => '/tmp/ar-e2e/secret_file' } token = File.read('/tmp/ar-e2e/secret_file').chomp # 1. ServiceCredential reads the mounted secret. raise 'token mismatch' unless ArtifactRegistry::ServiceCredential.new.token == token # 2. Real service client (no injected credential -> default file-reading provider), # reaches the real AR guard only if the service-token header is accepted. ns = ArtifactRegistry::Client.new(base_url: base_url).namespace(uuid: uuid) raise 'not a Namespace' unless ns.is_a?(ArtifactRegistry::Namespace) && ns.id == uuid # 3. Wire-form contract: correct header, no Authorization on the service path. require 'webmock' WebMock.enable! captured = {} WebMock.stub_request(:get, "#{base_url}/api/gitlab/v1/namespaces/#{uuid}") .with { |req| captured = req.headers; true } .to_return(status: 200, body: { 'id' => uuid }.to_json, headers: { 'Content-Type' => 'application/json' }) ArtifactRegistry::Client.new(base_url: base_url).namespace(uuid: uuid) raise 'wrong header' unless captured['Gitlab-Artifact-Registry-Token'] == token raise 'leaked Authorization' if captured.key?('Authorization') WebMock.reset!; WebMock.disable! # 4. Wrong token -> real guard 401 -> fail-closed AuthorizationError. Settings.artifact_registry['service_token'] = { 'secret_file' => '/tmp/ar-e2e/wrong_file' } begin ArtifactRegistry::Client.new(base_url: base_url).namespace(uuid: uuid) raise 'expected rejection' rescue ArtifactRegistry::Client::AuthorizationError puts 'ALL PASS' endbundle exec rails runner /tmp/ar-e2e/monolith_e2e.rb
Output
Driver (real Rails client on this branch, over HTTP to the real AR guard):
== AR service-token e2e ==
PASS ServiceCredential#token reads the mounted secret
PASS service client namespace(uuid:) succeeds through the real AR guard
PASS returned Namespace carries the echoed uuid (id)
PASS sends Gitlab-Artifact-Registry-Token header with the bare token
PASS sends NO Authorization header on the service path
PASS wrong token is rejected by the real AR guard -> AuthorizationError
ALL PASSHarness log (what actually reached the real AR handler): the accepted request
arrives with the service-token header set and Authorization empty, and the
wrong-token request is rejected by AR's constant-time validator before the
handler:
HANDLER reached: path=/api/gitlab/v1/namespaces/0192abcd-... Gitlab-Artifact-Registry-Token="<present>" Authorization=""
{"level":"WARN","msg":"servicetoken: service token rejected","error_message":"unauthorized"}This confirms end to end: the header name, the bare-token form (no prefix), no
Authorization on the service path, and the fail-closed behavior on a
non-matching token all match the merged AR contract.
Changelog
No Changelog: trailer: this is not user-facing. It changes which header the
monolith sends to an internal service, on a surface with no reachable caller
(artifact_registry_ui is off by default). No migration, no REST or GraphQL
change.
Suggested labels
~"type::feature" ~backend ~"group::container registry" ~"Category:Artifact Registry" ~"devops::package"
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.