Add disable and enable Artifact Registry mutations

What does this MR do and why?

Adds two GraphQL mutations, Mutations::ArtifactRegistry::Disable and Mutations::ArtifactRegistry::Enable, that toggle an organization's Artifact Registry between active and disabled.

Each mutation takes no arguments beyond clientMutationId. The organization is read from request context, and Artifact Registry derives the namespace from it.

The two mutations are near-identical, differing only by their condition endpoint (disable_namespace / enable_namespace) and target status. The shared flow (the availability gate, the endpoint call, the cache invalidation, and the payload build) lives in a small intermediate base, Mutations::ArtifactRegistry::ConditionMutation, parameterized by a condition :disable_namespace / condition :enable_namespace class-level declaration. disable.rb and enable.rb are thin subclasses that declare only their name, description, and endpoint, so the two cannot drift apart. ConditionMutation itself extends the shared Mutations::ArtifactRegistry::Base (the same base the repository mutations use), which supplies the feature-flag gate, the organization from context, the RendersErrors error mapping, and the standard errors payload array.

ConditionMutation overrides the base availability gate to require update_organization instead of the base's read_artifact_registry floor, because a condition change is an owner-only write. That ability implies the read the base would otherwise check, and it is re-checked server-side on every request (the Artifact Registry service credential carries no user identity, so the Rails check is the only user gate). When the artifact_registry_ui flag is off, the mutation raises a top-level resource-not-available error before any client call.

It calls the declared condition endpoint through the organization's service-authenticated client, builds the registry payload from that response, and invalidates the resolution cache so the render path re-reads the new status rather than a stale one. A subclass that forgets to declare its endpoint fails fast with a clear error rather than a confusing nil dispatch.

Repeating a condition against a registry already in the target state succeeds as a no-op, since Artifact Registry treats the condition endpoints as idempotent.

The request specs are de-duplicated the same way: the shared scenarios live in a shared example group, an Artifact Registry condition mutation, parameterized by the mutation name, endpoint, and target status, and each spec is a thin wrapper that sets those and calls it_behaves_like.

This change sits behind the existing artifact_registry_ui feature flag, which is dark and disabled by default. Because the flag is dark, there is no changelog, and the schema text needs no i18n.

References

Screenshots or screen recordings

N/A: this is a dark, off-by-default backend change with no user-visible behavior yet.

How to set up and validate locally

This is a dark, off-by-default change, validated by the automated checks and the console script below.

bundle exec rubocop \
  ee/app/graphql/mutations/artifact_registry/condition_mutation.rb \
  ee/app/graphql/mutations/artifact_registry/disable.rb \
  ee/app/graphql/mutations/artifact_registry/enable.rb \
  ee/spec/support/shared_examples/requests/api/graphql/mutations/artifact_registry/condition_mutation_shared_examples.rb \
  ee/spec/requests/api/graphql/mutations/artifact_registry/disable_spec.rb \
  ee/spec/requests/api/graphql/mutations/artifact_registry/enable_spec.rb

Confirmed output: 6 files inspected, no offenses detected.

bundle exec rspec \
  ee/spec/requests/api/graphql/mutations/artifact_registry/disable_spec.rb \
  ee/spec/requests/api/graphql/mutations/artifact_registry/enable_spec.rb

Confirmed output: 28 examples, 0 failures.

bundle exec rake gitlab:graphql:check_docs

Confirmed output: GraphQL documentation is up to date.

The script below can be run with bundle exec rails runner <file> or pasted into rails console. It stubs the Artifact Registry client so no live service is needed, and prints ALL CHECKS PASSED on success.

Feature.enable(:artifact_registry_ui)

organization = FactoryBot.create(:organization)
owner = FactoryBot.create(:organization_owner, organization: organization).user
member = FactoryBot.create(:organization_user, organization: organization).user
mapping = FactoryBot.create(:artifact_registry_namespace_mapping, organization: organization)

# A fake client recording the calls the mutations make and returning a namespace
# in the requested state, so we assert the endpoint hit and the resolved payload
# without a real Artifact Registry.
fake_client = Class.new do
  attr_reader :calls

  def initialize
    @calls = []
  end

  def disable_namespace(uuid:)
    @calls << [:disable_namespace, uuid]
    namespace('disabled', uuid)
  end

  def enable_namespace(uuid:)
    @calls << [:enable_namespace, uuid]
    namespace('active', uuid)
  end

  def namespace(status, uuid)
    ArtifactRegistry::Namespace.new(
      'id' => uuid, 'slug' => 'acme', 'status' => status,
      'created_at' => '2026-01-01T00:00:00Z'
    )
  end
end.new

Organizations::Organization.define_method(:artifact_registry_service_client) { fake_client }

def run_mutation(name, user, organization)
  query = "mutation { #{name}(input: {}) { registry { status } errors } }"
  GitlabSchema.execute(query, context: { current_user: user, current_organization: organization }).to_h
end

failures = []
check = ->(label, condition) { failures << label unless condition; puts "#{condition ? 'PASS' : 'FAIL'}: #{label}" }

result = run_mutation('artifactRegistryDisable', owner, organization)
data = result.dig('data', 'artifactRegistryDisable')
check.call('disable: no top-level errors', result['errors'].nil?)
check.call('disable: payload errors empty', data && data['errors'] == [])
check.call('disable: status is disabled', data && data.dig('registry', 'status') == 'disabled')
check.call('disable: called disable_namespace with the mapping uuid',
  fake_client.calls.include?([:disable_namespace, mapping.ar_namespace_id]))

result = run_mutation('artifactRegistryEnable', owner, organization)
data = result.dig('data', 'artifactRegistryEnable')
check.call('enable: status is active', data && data.dig('registry', 'status') == 'active')

before = fake_client.calls.size
result = run_mutation('artifactRegistryDisable', member, organization)
check.call('member: top-level access error', result['errors']&.any? { |e| e['message'].to_s.match?(/permission/) })
check.call('member: no client call', fake_client.calls.size == before)

Feature.disable(:artifact_registry_ui)
before = fake_client.calls.size
result = run_mutation('artifactRegistryDisable', owner, organization)
check.call('flag off: top-level access error', result['errors']&.any? { |e| e['message'].to_s.match?(/permission/) })
check.call('flag off: no client call', fake_client.calls.size == before)
Feature.enable(:artifact_registry_ui)

org_no_mapping = FactoryBot.create(:organization)
owner_no_mapping = FactoryBot.create(:organization_owner, organization: org_no_mapping).user
result = run_mutation('artifactRegistryDisable', owner_no_mapping, org_no_mapping)
check.call('no mapping row: top-level access error',
  result['errors']&.any? { |e| e['message'].to_s.match?(/permission/) })

puts(failures.empty? ? "\nALL CHECKS PASSED" : "\nFAILED: #{failures.join(', ')}")

Confirmed output: all ten checks print PASS, followed by ALL CHECKS PASSED.

Once the flag is enabled for an activated organization, the two mutations can also be run directly:

mutation { artifactRegistryDisable(input: {}) { registry { status } errors } }
mutation { artifactRegistryEnable(input: {}) { registry { status } errors } }

MR acceptance checklist

This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Narendran

Merge request reports

Loading
Loading