Add artifact registry service credential seam and #namespace (S02 Step 1)

What does this MR do and why?

Step 1 of the monolith/S02 GitLab API namespace client: it lets ArtifactRegistry::Client authenticate as the monolith itself, and adds the first method that needs it, #namespace(uuid:).

Until now the client only spoke for a user: every request went through TokenExchange#token_for(current_user, slug). The GitLab API endpoints have neither a user nor a slug, so this MR adds a second credential path over the same transport. Both paths are private and each public method binds to exactly one of them, so no caller can select or supply a credential - the service credential is more powerful than a per-user token and must not be reachable that way.

The rest of the change is what that path needs to be safe and diagnosable:

  • ArtifactRegistry::ServiceCredential, the argument-free provider. Its default returns nil so an unwired client fails closed rather than issuing an unauthenticated call.
  • ArtifactRegistry::Namespace, the value object for the resolution response. status is passed through unvalidated so an unrecognized value reaches the frontend fallback instead of raising; created_at is the only coerced field.
  • ArtifactRegistry::TimeCoercion, the ISO8601-rescue-to-nil coercion Repository already used privately, extracted so both value objects share one copy.
  • ArtifactRegistry::ErrorReporter, which owns every surface an error reaches error tracking through: the allowlisted context, credential redaction, and the cause-free exception copy the log receives. The client no longer keeps a second redaction implementation.
  • Base URL validation. The base URL must be http or https and must have a host. Userinfo, query and fragment are refused, because the base URL is logged verbatim in error contexts. A base URL carrying a path is also refused, because each method builds its own absolute path, so a base path would be duplicated into the request. Plain HTTP is accepted in every environment at construction time; HTTPS is enforced only when a service-authenticated request is issued, and only in production. The per-user methods predate this client and run against a plaintext in-cluster URL, so a global HTTPS rule would break them. The narrower rule protects the service credential, which is more powerful than a per-user token and must not travel in the clear.

#namespace returns nil on 404 and logs it. The caller resolves a UUID it persisted itself, so a 404 means Rails and AR have drifted rather than a bad input: worth an operator signal, but an expected outcome for the caller rather than a raised error.

Steps 2 and 3 add #provision_namespace and the disable/enable pair on top of this seam.

References

Screenshots or screen recordings

N/A. Library code with no user-facing surface; the methods have no caller until S10.

How to set up and validate locally

The script drives the client directly with the AR HTTP responses stubbed, because the client has no wired caller yet. The AR route shapes and error envelope were checked against the artifact-registry repo's internal/gitlabapi/handler.go and api/openapi/gitlab-v1.yaml.

  1. Save the script below as /tmp/validate_ar_step1.rb.
  2. Run it: bundle exec rails runner /tmp/validate_ar_step1.rb
  3. Expect 32/32 passed.
# Validation for S02 Step 1: service credential seam, Namespace value object, #namespace
require 'webmock'
include WebMock::API
WebMock.enable!

BASE = 'https://artifact-registry.example.test'
UUID = 'a1b2c3d4-0000-0000-0000-000000000000'
SERVICE_TOKEN = 'ar-service-token'
JSON_HEADERS = { 'Content-Type' => 'application/json' }

service_credential = Class.new do
  def token = 'ar-service-token'
end.new

results = []
check = ->(name, got, want) { results << [got == want, name, got, want] }

# 1. #namespace on 200 returns a Namespace with the service credential
WebMock.reset!
req = stub_request(:get, "#{BASE}/api/gitlab/v1/namespaces/#{UUID}")
  .with(headers: { 'Authorization' => "Bearer #{SERVICE_TOKEN}" })
  .to_return(status: 200, headers: JSON_HEADERS, body: {
    id: UUID, slug: 'my-group', platform: 'gitlab', entity_type: 'group',
    entity_id: '42', status: 'active', created_at: '2026-07-01T10:00:00Z'
  }.to_json)

client = ArtifactRegistry::Client.new(base_url: BASE, service_credential: service_credential)
ns = client.namespace(uuid: UUID)
check.call('200 returns Namespace', ns.class.name, 'ArtifactRegistry::Namespace')
check.call('  slug', ns.slug, 'my-group')
check.call('  status passed through raw', ns.status, 'active')
check.call('  created_at coerced', ns.created_at.class.name, 'DateTime')
check.call('  service credential used', WebMock::RequestRegistry.instance.times_executed(req.request_pattern), 1)

# 2. 404 returns nil and logs the uuid with request identifiers, no credential
WebMock.reset!
logged = []
Gitlab::ErrorTracking.singleton_class.prepend(Module.new do
  define_method(:log_exception) { |e, c = {}| logged << [e.class.name, c] }
end)
stub_request(:get, "#{BASE}/api/gitlab/v1/namespaces/#{UUID}")
  .to_return(status: 404, headers: JSON_HEADERS,
    body: { error: { code: 'not_found', message: 'namespace not found', request_id: 'req-404' } }.to_json)
Labkit::Correlation::CorrelationId.use_id('corr-validate') do
  check.call('404 returns nil', client.namespace(uuid: UUID), nil)
end
check.call('404 logs ApiError', logged.last&.first, 'ArtifactRegistry::Client::ApiError')
check.call('404 context keys', logged.last&.last&.keys&.sort, [:correlation_id, :request_id, :status, :url, :uuid])
check.call('404 context has no token', logged.to_s.include?(SERVICE_TOKEN), false)

# 3. Missing service credential fails closed with no request issued
WebMock.reset!
no_cred_req = stub_request(:get, %r{/api/gitlab/v1/namespaces})
begin
  ArtifactRegistry::Client.new(base_url: BASE).namespace(uuid: UUID)
  check.call('null credential fails closed', 'no error raised', 'AuthorizationError')
rescue ArtifactRegistry::Client::AuthorizationError
  check.call('null credential fails closed', 'AuthorizationError', 'AuthorizationError')
end
check.call('null credential issues no request',
  WebMock::RequestRegistry.instance.times_executed(no_cred_req.request_pattern), 0)

# 4. Per-user path is unreachable without a user, before the token exchange runs
WebMock.reset!
exchange_calls = 0
permissive_exchange = Class.new do
  define_method(:token_for) { |*| 'per-user-token' }
end.new
permissive_exchange.define_singleton_method(:token_for) { |*| exchange_calls += 1; 'per-user-token' }
service_only = ArtifactRegistry::Client.new(base_url: BASE, service_credential: service_credential,
  token_exchange: permissive_exchange)
begin
  service_only.repository(slug: 'my-group', name: 'my-repo')
  check.call('per-user path rejects nil user', 'no error raised', 'ArgumentError')
rescue ArgumentError
  check.call('per-user path rejects nil user', 'ArgumentError', 'ArgumentError')
end
check.call('rejects BEFORE token_for', exchange_calls, 0)

# 5. Base URL validation: HTTPS except loopback, no credential-bearing URL
{
  'https://ar.test' => :ok, 'http://localhost:8080' => :ok, 'http://127.0.0.2:8080' => :ok,
  'http://[::1]:8080' => :ok, 'http://gdk.test:8080' => :ok,
  'https://user:secret@ar.test' => :rejected, 'https://ar.test?token=abc' => :rejected,
  'http://exa mple.test' => :rejected, 'https://ar.test/api/v1' => :rejected
}.each do |url, want|
  got = begin
    ArtifactRegistry::Client.new(base_url: url)
    :ok
  rescue ArgumentError
    :rejected
  end
  check.call("base_url #{url}", got, want)
end

# 6. uuid that would rewrite the request path is refused before any request
WebMock.reset!
guard_req = stub_request(:get, %r{/api/gitlab/v1/namespaces})
[nil, '', '.', '..'].each do |bad|
  got = begin
    client.namespace(uuid: bad)
    :accepted
  rescue ArgumentError
    :rejected
  end
  check.call("uuid #{bad.inspect} refused", got, :rejected)
end
check.call('guarded uuids issue no request',
  WebMock::RequestRegistry.instance.times_executed(guard_req.request_pattern), 0)

# 7. Terminal 5xx reports once, attributable, and raises with the transport cause absent
WebMock.reset!
logged.clear
stub_request(:get, "#{BASE}/api/v1/my-group/repositories")
  .to_return(status: 503, headers: JSON_HEADERS,
    body: { error: { code: 'unavailable', message: 'boom', request_id: 'req-5xx' } }.to_json)
user_client = ArtifactRegistry::Client.new(base_url: BASE, current_user: Object.new,
  token_exchange: Class.new { def token_for(*) = 'user-token' }.new)
begin
  Labkit::Correlation::CorrelationId.use_id('corr-validate') { user_client.repositories(slug: 'my-group') }
rescue ArtifactRegistry::Client::UnavailableError => e
  check.call('5xx raises UnavailableError', e.class.name, 'ArtifactRegistry::Client::UnavailableError')
  check.call('5xx preserves request_id', e.request_id, 'req-5xx')
end
check.call('5xx reported once', logged.size, 1)
check.call('5xx context is attributable (slug present)', logged.last&.last&.[](:slug), 'my-group')
check.call('5xx context keys', logged.last&.last&.keys&.sort,
  [:code, :correlation_id, :method, :request_id, :slug, :status, :url])

puts "\n=== RESULTS ==="
results.each { |ok, name, got, want| puts "#{ok ? 'PASS' : 'FAIL'}  #{name}#{ok ? '' : "  (got #{got.inspect}, want #{want.inspect})"}" }
puts "\n#{results.count { |r| r[0] }}/#{results.size} passed"

Output from this branch:

=== RESULTS ===
PASS  200 returns Namespace
PASS    slug
PASS    status passed through raw
PASS    created_at coerced
PASS    service credential used
PASS  404 returns nil
PASS  404 logs ApiError
PASS  404 context keys
PASS  404 context has no token
PASS  null credential fails closed
PASS  null credential issues no request
PASS  per-user path rejects nil user
PASS  rejects BEFORE token_for
PASS  base_url https://ar.test
PASS  base_url http://localhost:8080
PASS  base_url http://127.0.0.2:8080
PASS  base_url http://[::1]:8080
PASS  base_url http://gdk.test:8080
PASS  base_url https://user:secret@ar.test
PASS  base_url https://ar.test?token=abc
PASS  base_url http://exa mple.test
PASS  base_url https://ar.test/api/v1
PASS  uuid nil refused
PASS  uuid "" refused
PASS  uuid "." refused
PASS  uuid ".." refused
PASS  guarded uuids issue no request
PASS  5xx raises UnavailableError
PASS  5xx preserves request_id
PASS  5xx reported once
PASS  5xx context is attributable (slug present)
PASS  5xx context keys

32/32 passed

Specs: bundle exec rspec ee/spec/lib/artifact_registry/ - 281 examples, 0 failures.

Database changes

None. No migrations, models, or queries; this MR is library code under ee/lib.

MR acceptance checklist

  • Tests added for the new methods and their failure paths
  • RuboCop clean on the changed files
  • No feature flag: the methods have no caller until S10, and library code carries no gate
  • No changelog entry: no user-visible change ships in this MR
Edited by Narendran

Merge request reports

Loading
Loading