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 returnsnilso an unwired client fails closed rather than issuing an unauthenticated call.ArtifactRegistry::Namespace, the value object for the resolution response.statusis passed through unvalidated so an unrecognized value reaches the frontend fallback instead of raising;created_atis the only coerced field.ArtifactRegistry::TimeCoercion, the ISO8601-rescue-to-nil coercionRepositoryalready 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
- Plan: monolith/S02 GitLab API namespace client, Step 1
- Spec: monolith/S02 AR Ruby client
- AR-side contract:
api/openapi/gitlab-v1.yamlingitlab-org/ops/artifact-registry - Consuming slice: monolith/S10 activation and deactivation, which wires a functional service
credential and calls
#namespacebehind its resolution cache
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.
- Save the script below as
/tmp/validate_ar_step1.rb. - Run it:
bundle exec rails runner /tmp/validate_ar_step1.rb - 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 passedSpecs: 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