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, mirroring iam_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 raises Gitlab::Configs::MissingConfig on an instance that never set the key.
  • ArtifactRegistry::ServiceCredential#token: reads that file, chomped and memoized per instance; nil when 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 both authed_request and perform_request trip Metrics/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_file the process cannot read raises Errno::ENOENT or Errno::EACCES rather than resolving to nil, matching the iam_data_access_service mirror. Swallowing it would turn a broken mount into a silent permanent outage that reads as an unconfigured deployment. Covered by tests.

References

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.rb

599 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.

  1. Build a tiny harness that wraps the real AR guard around a handler that echoes a namespace body. From an artifact-registry checkout, drop this at cmd/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))
    }
  2. Create the mounted-secret files and start the harness with the same token (the file carries a trailing newline, so chomp is 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 &
  3. 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'
    end
    bundle 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 PASS

Harness 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.

Edited by Narendran

Merge request reports

Loading
Loading