Expose upstream repository verdicts on the Artifact Registry upstream list

What this does

The upstream table on the virtual repository detail page has a kebab per row with "Edit repository" and "Clear cache". Both act on the upstream repository, not the virtual one the page reads, so the page has no verdict to gate them on. Artifact Registry now embeds those verdicts in the upstream list when asked with include_permissions (gitlab-org/ops/artifact-registry#1174 (closed), served for every format on AR main).

This MR is the client and GraphQL half: the AR client requests the verdicts, and each upstreamRepository row exposes them as userPermissions, typed ArtifactRegistryRepositoryPermissions like the repository's own block. The frontend gating in upstream_repository_actions.vue follows in a separate MR, so the backend ships first.

Related to #629674 (closed)

Changes

  • ArtifactRegistry::Client#upstream_repositories takes include_permissions: (default false), sends include_permissions=true only when asked, and parses each row's upstream_repository.permissions into Permissions::Verdicts (read :upstream_repositories). Not requested gives nil, requested but missing gives an absent verdict set, so the existing VerdictReport tracking applies.
  • ArtifactRegistry::UpstreamRepositorySummary carries the verdicts, handed through UpstreamRepositoryAssociation.
  • ArtifactRegistryUpstreamRepositorySummary.userPermissions (experiment, 19.5), resolved through the same Base::Block as ArtifactRegistryRepository.userPermissions. Every permission is false when AR returns no verdicts.
  • UpstreamRepositoriesResolver only asks for verdicts when userPermissions is selected under upstreamRepository, so the page's existing query keeps its plain AR request.
  • Regenerated GraphQL reference docs and introspection result.

How to set up and validate locally

Setup for steps 5 and 6: Artifact Registry at or after 9ccd4fccb on main (serves include_permissions on the upstream list for every format), the artifact_registry: block in config/gitlab.yml pointing at it, the artifact_registry_ui flag on, and a virtual repository with upstreams in your organization's namespace. Steps 1 to 4 need none of that.

Step 1: Lint the changed Ruby files

git diff --name-only origin/master...HEAD -- '*.rb' | xargs bundle exec rubocop
Output (✅)
Inspecting 11 files
...........

11 files inspected, no offenses detected

Step 2: Run the specs

(a) Value-object unit specs (fast_spec_helper):

bundle exec rspec ee/spec/lib/artifact_registry/upstream_repository_summary_spec.rb \
  ee/spec/lib/artifact_registry/upstream_repository_association_spec.rb
Output (✅)
Finished in 0.0189 seconds (files took 2.87 seconds to load)
16 examples, 0 failures

(b) Client, type, resolver and request specs (spec_helper):

CI=true bundle exec rspec ee/spec/lib/artifact_registry/client_spec.rb:1959 \
  ee/spec/graphql/types/artifact_registry/upstream_repository_summary_type_spec.rb \
  ee/spec/graphql/resolvers/artifact_registry/upstream_repositories_resolver_spec.rb \
  ee/spec/requests/api/graphql/organizations/artifact_registry_repository_upstream_repositories_spec.rb
Output (✅)
Finished in 36.79 seconds (files took 1 minute 35.04 seconds to load)
78 examples, 0 failures

Step 3: Run the graphql-verify checks

One invocation each, as the graphql-verify CI job does:

bundle exec rake gitlab:graphql:validate
bundle exec rake gitlab:graphql:check_docs
bundle exec rake gitlab:graphql:check_introspection_sync
bundle exec rake gitlab:graphql:schema:dump
node scripts/frontend/graphql_possible_types_extraction.js --check
Output (✅)
# gitlab:graphql:validate (exit 0)
1873 query files reported OK, 0 invalid

# gitlab:graphql:check_docs (exit 0)
GraphQL documentation is up to date

# gitlab:graphql:check_introspection_sync (exit 0)
✅ All GraphQL introspection schemas are up to date

# gitlab:graphql:schema:dump (exit 0)
Schema IDL dumped into .../tmp/tests/graphql/gitlab_schema.graphql
Schema JSON dumped into .../tmp/tests/graphql/gitlab_schema.json

# graphql_possible_types_extraction.js --check (exit 0, no output)

git status --short was empty afterwards, so none of these rewrote a tracked file.

Step 4: Eager load the application

bundle exec rails runner 'Rails.application.eager_load!; puts "eager load OK"'
Output (✅)
eager load OK

Step 5: Client requests verdicts only on opt-in

client = ArtifactRegistry::Client.new(current_user: User.find_by_username('<org owner>'), organization: Organizations::Organization.first)
args = { slug: '<namespace slug>', repository_name: '<virtual repository>', format: 'npm' }

client.upstream_repositories(**args).map { |a| [a.upstream_repository.name, a.upstream_repository.permissions] }
client.upstream_repositories(**args, include_permissions: true).map { |a| [a.upstream_repository.name, a.upstream_repository.permissions.allowed?('update_repository')] }

✅ The first call gives nil permissions on every row and sends no query parameter; the second sends include_permissions=true and each row carries complete verdicts. Transcript and AR access log under Rails console.

Step 6: userPermissions on each upstream row in GraphiQL

{
  organization(id: "gid://gitlab/Organizations::Organization/1") {
    artifactRegistryRepository(name: "<virtual repository>") {
      upstreamRepositories {
        upstreamRepository { name userPermissions { updateRepository deleteArtifact } }
      }
    }
  }
}

✅ Each row resolves its own upstream's verdicts; dropping userPermissions from the selection sends the plain AR request. Recordings under GraphiQL.

Evidence

Local GDK plus an isolated Artifact Registry built from origin/main at 9ccd4fccb, with a stubbed Artifact Registry for the deny and missing-block states, since local AR runs unenforced and only ever answers all-allow. UI: N/A, no frontend changes.

Rails console

Live Artifact Registry. AR built from origin/main at 9ccd4fccb (includes gitlab-org/ops/artifact-registry#1174 (closed) for npm and Maven), run locally against a copy of the GDK's AR database, namespace gdk-local-test, virtual npm repository npm-virtual-demo with one remote and one hosted upstream. The local AR runs with authorization.unenforced: true (no IAM deployment locally), so every live verdict is true. The deny and missing-block states are shown against a stub further down.

Transcript (live AR)
>> client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'npm-virtual-demo', format: 'npm').map { |a| [a.upstream_repository.name, a.upstream_repository.permissions] }
[["npmjs-proxy", nil], ["team-internal-npm", nil]]

>> client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'npm-virtual-demo', format: 'npm', include_permissions: true)
npmjs-proxy (remote) complete?=true {"read_repository"=>true, "update_repository"=>true, "delete_repository"=>true, "create_repository_upstream"=>true, "update_repository_upstream"=>true, "delete_repository_upstream"=>true, "read_artifact"=>true, "create_artifact"=>true, "delete_artifact"=>true}
team-internal-npm (hosted) complete?=true {"read_repository"=>true, "update_repository"=>true, "delete_repository"=>true, "create_repository_upstream"=>true, "update_repository_upstream"=>true, "delete_repository_upstream"=>true, "read_artifact"=>true, "create_artifact"=>true, "delete_artifact"=>true}

>> GitlabSchema.execute(<query selecting userPermissions>)
{
  "data": {
    "organization": {
      "artifactRegistryRepository": {
        "upstreamRepositories": [
          {
            "upstreamRepository": {
              "name": "npmjs-proxy",
              "userPermissions": {
                "updateRepository": true,
                "deleteArtifact": true,
                "deleteRepository": true
              }
            }
          },
          {
            "upstreamRepository": {
              "name": "team-internal-npm",
              "userPermissions": {
                "updateRepository": true,
                "deleteArtifact": true,
                "deleteRepository": true
              }
            }
          }
        ]
      }
    }
  }
}

>> GitlabSchema.execute(<same query without userPermissions>)
{
  "data": {
    "organization": {
      "artifactRegistryRepository": {
        "upstreamRepositories": [
          {
            "upstreamRepository": {
              "name": "npmjs-proxy",
              "kind": "REMOTE"
            }
          },
          {
            "upstreamRepository": {
              "name": "team-internal-npm",
              "kind": "HOSTED"
            }
          }
        ]
      }
    }
  }
}

AR access log for the four calls above, in order. include_permissions=true goes out only on the opt-in client call and on the GraphQL query that selects userPermissions:

2026-09-28T13:53:21.792+10:00 GET /api/v1/gdk-local-test/repositories/npm-virtual-demo/npm/upstream_repositories 200
2026-09-28T13:53:21.797+10:00 GET /api/v1/gdk-local-test/repositories/npm-virtual-demo/npm/upstream_repositories?include_permissions=true 200
2026-09-28T13:53:27.431+10:00 GET /api/v1/gdk-local-test/repositories/npm-virtual-demo/npm/upstream_repositories?include_permissions=true 200
2026-09-28T13:53:27.452+10:00 GET /api/v1/gdk-local-test/repositories/npm-virtual-demo/npm/upstream_repositories 200

Stubbed Artifact Registry. A small fake AR on 127.0.0.1:8099, everything above the wire is the real branch. mixed-virtual returns a full verdict block per row with different values (row 1 may update but not delete artifacts, row 2 the reverse). absent-virtual returns rows with no permissions object even when asked.

Transcript (stubbed AR)
>> client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'mixed-virtual', format: 'npm').map { |a| [a.upstream_repository.name, a.upstream_repository.permissions] }
[["team-internal-npm", nil], ["npmjs-proxy", nil]]

>> client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'mixed-virtual', format: 'npm', include_permissions: true)
team-internal-npm absent?=false complete?=true {"update_repository"=>true, "delete_artifact"=>false, "delete_repository"=>false}
npmjs-proxy absent?=false complete?=true {"update_repository"=>false, "delete_artifact"=>true, "delete_repository"=>false}

>> GitlabSchema.execute(<query selecting userPermissions on mixed-virtual>)
{
  "data": {
    "organization": {
      "artifactRegistryRepository": {
        "upstreamRepositories": [
          {
            "upstreamRepository": {
              "name": "team-internal-npm",
              "userPermissions": {
                "updateRepository": true,
                "deleteArtifact": false
              }
            }
          },
          {
            "upstreamRepository": {
              "name": "npmjs-proxy",
              "userPermissions": {
                "updateRepository": false,
                "deleteArtifact": true
              }
            }
          }
        ]
      }
    }
  }
}

>> client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'absent-virtual', format: 'npm').map { |a| [a.upstream_repository.name, a.upstream_repository.permissions] }
[["team-internal-npm", nil], ["npmjs-proxy", nil]]

>> client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'absent-virtual', format: 'npm', include_permissions: true)
team-internal-npm absent?=true complete?=false {"update_repository"=>false, "delete_artifact"=>false, "delete_repository"=>false}
npmjs-proxy absent?=true complete?=false {"update_repository"=>false, "delete_artifact"=>false, "delete_repository"=>false}

>> GitlabSchema.execute(<query selecting userPermissions on absent-virtual>)
{
  "data": {
    "organization": {
      "artifactRegistryRepository": {
        "upstreamRepositories": [
          {
            "upstreamRepository": {
              "name": "team-internal-npm",
              "userPermissions": {
                "updateRepository": false,
                "deleteArtifact": false
              }
            }
          },
          {
            "upstreamRepository": {
              "name": "npmjs-proxy",
              "userPermissions": {
                "updateRepository": false,
                "deleteArtifact": false
              }
            }
          }
        ]
      }
    }
  }
}

Stub request log for the transcript above:

STUB "GET /api/v1/gdk-local-test/repositories/mixed-virtual/npm/upstream_repositories HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/mixed-virtual/npm/upstream_repositories?include_permissions=true HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/mixed-virtual HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/mixed-virtual/npm/upstream_repositories?include_permissions=true HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/absent-virtual/npm/upstream_repositories HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/absent-virtual/npm/upstream_repositories?include_permissions=true HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/absent-virtual HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/absent-virtual/npm/upstream_repositories?include_permissions=true HTTP/1.1" 200 -

GraphiQL

Recorded in GraphiQL (/-/graphql-explorer) on a Puma running this branch from its worktree, signed in as a non-admin Owner of the organization.

  1. Live AR, npm-virtual-demo: each upstream row resolves its own userPermissions (all true because local AR is unenforced).

  1. Stubbed AR, mixed-virtual: the two rows resolve different verdicts, team-internal-npm gets updateRepository: true, deleteArtifact: false and npmjs-proxy gets the reverse.

  1. Stubbed AR, absent-virtual: AR answers without a permissions block, every permission resolves false, rows stay populated, and there is no errors key.

Stub request log for recordings 2 and 3:

STUB "GET /api/v1/gdk-local-test/repositories/mixed-virtual HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/mixed-virtual/npm/upstream_repositories?include_permissions=true HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/absent-virtual HTTP/1.1" 200 -
STUB "GET /api/v1/gdk-local-test/repositories/absent-virtual/npm/upstream_repositories?include_permissions=true HTTP/1.1" 200 -

🤖 Generated with Claude Code

Edited by Fiona McCawley

Merge request reports

Loading
Loading