Gate Artifact Registry upstream row actions on their permission verdicts

What this does

The kebab menu on each upstream row of a virtual repository detail page showed Edit repository, Clear cache and Remove repository to every user. This MR gates each item on its permission verdict. Edit repository reads the upstream's own updateRepository and Clear cache reads the upstream's own deleteArtifact, following the bindings in the monolith/S05 spec. Remove repository reads deleteRepositoryUpstream on the page's own repository, because it dissociates the upstream from that repository. When every item is denied the row renders no kebab, the same rule row_actions_menu.vue and repository_actions.vue follow. When no row has an action, the table drops the Actions column.

The client and GraphQL half landed in !258050 (merged), and Artifact Registry serves the upstream verdicts since gitlab-org/ops/artifact-registry#1174 (closed) closed.

Closes #629674 (closed)

Changes

  • get_repository_upstream_repositories.query.graphql: selects userPermissions { updateRepository deleteArtifact } on each upstream. The issue names get_repository_detail.query.graphql, but the rows come from the upstream-list query, so the selection goes there.
  • get_repository_detail.query.graphql: adds deleteRepositoryUpstream to the repository's userPermissions.
  • graphql/utils/permissions.js: upstreamActions and upstreamHasActions hold the per-row rule, so the row menu and the table read the same checks.
  • upstream_repository_actions.vue: gates each item through upstreamActions, and renders no toggle when none is allowed.
  • upstream_repositories_table.vue: drops the Actions column when no row has an action.
  • Specs: permission gating cases for the actions component, including checks that each item reads the right repository's verdicts, plus Actions column cases for the table and unit specs for the helpers. The virtual repository feature spec stubs real upstream verdicts and opens each row kebab. Mock data and the two GraphQL fixture generators now carry the new fields.

How to set up and validate locally

Run against the caproni rig with the Artifact Registry and IAM Data Access fragments, so every verdict comes from a real role grant in IAM Data Access, evaluated by the GLAZ sidecar as on GitLab.com. Nothing is stubbed.

Step 1. Bring up the rig with real authorization and this branch in edit mode

In caproni.local.yaml, extend both fragments (IAM last) and enable monolith edit mode, then put repos/gitlab on this branch:

extends:
  - fragments/caproni.artifact-registry.yaml
  - fragments/caproni.iam-data-access.yaml
repositories:
  gitlab:
    edit_mode: true
reloaders:
  gitlab:
    edit_mode: true
caproni up
caproni run

Artifact Registry must be v2.111.0 or later, the first release that serves verdicts on upstream rows (gitlab-org/ops/artifact-registry#1174 (closed)).

Output (✅)
$ caproni kubectl -n artifact-registry get pods -o 'custom-columns=IMG:.spec.containers[*].image,INIT:.spec.initContainers[*].image'
registry.gitlab.com/gitlab-org/ops/artifact-registry:v2.119.0   registry.gitlab.com/gitlab-org/auth/glaz:v10.1.0

$ caproni kubectl -n artifact-registry get configmap/appconfig-artifact-registry -o yaml
    glaz:
      address: 127.0.0.1:8082
      timeout: 1s
    iam:
      address: iam-data-access.iam-data-access.svc.cluster.local:5005

$ caproni kubectl -n artifact-registry get configmap/appconfig-artifact-registry -o yaml | grep -c unenforced
0

$ git -C repos/gitlab log --oneline -1
a0ac6ca1f3c Wait for fresh permissions before showing upstream removal

Step 2. Enable the feature flags

Edit mode runs against its own gitlabhq_development database, so use .gitlab/caproni/exec.sh rails runner from repos/gitlab rather than the toolbox pod.

%i[org_stage_experimental org_stage_ga artifact_registry_ui].each { |flag| Feature.enable(flag) }
Output (✅)
{"org_stage_experimental":true,"org_stage_ga":true,"artifact_registry_ui":true}

Step 3. Create five non-admin users and grant their roles

Create ar-e2e-admin, ar-e2e-viewer, ar-e2e-virtual-mgr, ar-e2e-upstream-mgr and ar-e2e-remote-mgr as organization members, each with a personal access token. Then grant the roles below as root, using the artifactRegistryRoleGrant mutation. The default namespace is the one mapped to the organization. The repositories come from Step 4, so grant the repository-level roles after it.

mutation($id: UserID!, $r: String!, $role: ArtifactRegistryRole!) {
  artifactRegistryRoleGrant(input: { assigneeId: $id, resourceId: $r, role: $role }) { errors }
}
User Namespace default Repository grant
ar-e2e-admin Artifact Admin none
ar-e2e-viewer Artifact Viewer none
ar-e2e-virtual-mgr Artifact Viewer Artifact Manager on npm-virtual-demo
ar-e2e-upstream-mgr Artifact Viewer Artifact Manager on npmjs-proxy and team-internal-npm
ar-e2e-remote-mgr Artifact Viewer Artifact Manager on npmjs-proxy

Artifact Admin and Artifact Manager hold update_repository, delete_artifact and delete_repository_upstream. Artifact Viewer holds none of them.

Output (✅)
ar-e2e-admin ARTIFACT_ADMIN on namespace: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
ar-e2e-viewer ARTIFACT_VIEWER on namespace: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
ar-e2e-virtual-mgr ARTIFACT_VIEWER on namespace: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
ar-e2e-upstream-mgr ARTIFACT_VIEWER on namespace: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
ar-e2e-remote-mgr ARTIFACT_VIEWER on namespace: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
virtual-mgr Manager on npm-virtual-demo: {"artifactRegistryRoleGrant":{"errors":[]}}
upstream-mgr Manager on npmjs-proxy: {"artifactRegistryRoleGrant":{"errors":[]}}
upstream-mgr Manager on team-internal-npm: {"artifactRegistryRoleGrant":{"errors":[]}}
remote-mgr Manager on npmjs-proxy: {"artifactRegistryRoleGrant":{"errors":[]}}

Step 4. Create the virtual repository and its upstreams as ar-e2e-admin

Only Artifact Admin holds create_repository. Mint a JWT from the user's token, then create a remote, a hosted and a virtual npm repository:

JWT="$(curl -sk -X POST https://gitlab.caproni.test/api/v4/token_exchange \
  -H "PRIVATE-TOKEN: $ADMIN_PAT" --data-urlencode audience=gitlab-artifact-registry | jq -r .token)"
AR=https://artifact-registry.caproni.test/api/v1/default/repositories

curl -sk -X POST $AR -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"name":"npmjs-proxy","format":"npm","kind":"remote","settings":{"url":"https://registry.npmjs.org"}}'
curl -sk -X POST $AR -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"name":"team-internal-npm","format":"npm","kind":"hosted"}'
curl -sk -X POST $AR -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"name":"npm-virtual-demo","format":"npm","kind":"virtual","upstream_repository_ids":["<npmjs-proxy id>","<team-internal-npm id>"]}'
Output (✅)
npmjs-proxy        HTTP 201  01a0ebda-e888-7571-90e6-d9435e16a1c8
team-internal-npm  HTTP 201  01a0ebda-e8fb-7b65-ad69-a4dd6d30b97b
npm-virtual-demo   HTTP 201  01a0ebda-e95b-72b8-b087-3406bb9bf5d8

Step 5. Confirm the client returns each user's real verdicts

org = Organizations::Organization.first
pick = ->(verdicts, keys) { keys.to_h { |key| [key, verdicts.allowed?(key)] } }

%w[ar-e2e-admin ar-e2e-viewer ar-e2e-virtual-mgr ar-e2e-upstream-mgr ar-e2e-remote-mgr].each do |username|
  client = ArtifactRegistry::Client.new(current_user: User.find_by!(username: username), organization: org)
  puts "== #{username}"

  repo = client.repository(slug: 'default', name: 'npm-virtual-demo', include_permissions: true)
  puts "repository #{repo.name} (#{repo.kind}): #{pick.(repo.permissions, %w[delete_repository_upstream])}"

  client.upstream_repositories(slug: 'default', repository_name: 'npm-virtual-demo',
    format: 'npm', include_permissions: true).each do |association|
    upstream = association.upstream_repository
    puts "upstream #{upstream.name} (#{upstream.kind}) absent=#{upstream.permissions.absent?}: " \
      "#{pick.(upstream.permissions, %w[update_repository delete_artifact])}"
  end
end
Output (✅)
== ar-e2e-admin
repository npm-virtual-demo (virtual): {"delete_repository_upstream"=>true}
upstream npmjs-proxy (remote) absent=false: {"update_repository"=>true, "delete_artifact"=>true}
upstream team-internal-npm (hosted) absent=false: {"update_repository"=>true, "delete_artifact"=>true}
== ar-e2e-viewer
repository npm-virtual-demo (virtual): {"delete_repository_upstream"=>false}
upstream npmjs-proxy (remote) absent=false: {"update_repository"=>false, "delete_artifact"=>false}
upstream team-internal-npm (hosted) absent=false: {"update_repository"=>false, "delete_artifact"=>false}
== ar-e2e-virtual-mgr
repository npm-virtual-demo (virtual): {"delete_repository_upstream"=>true}
upstream npmjs-proxy (remote) absent=false: {"update_repository"=>false, "delete_artifact"=>false}
upstream team-internal-npm (hosted) absent=false: {"update_repository"=>false, "delete_artifact"=>false}
== ar-e2e-upstream-mgr
repository npm-virtual-demo (virtual): {"delete_repository_upstream"=>false}
upstream npmjs-proxy (remote) absent=false: {"update_repository"=>true, "delete_artifact"=>true}
upstream team-internal-npm (hosted) absent=false: {"update_repository"=>true, "delete_artifact"=>true}
== ar-e2e-remote-mgr
repository npm-virtual-demo (virtual): {"delete_repository_upstream"=>false}
upstream npmjs-proxy (remote) absent=false: {"update_repository"=>true, "delete_artifact"=>true}
upstream team-internal-npm (hosted) absent=false: {"update_repository"=>false, "delete_artifact"=>false}

Step 6. Query the verdicts through GraphQL as each user

query($organizationId: OrganizationsOrganizationID!, $name: String!) {
  organization(id: $organizationId) {
    artifactRegistryRepository(name: $name) {
      name
      userPermissions { updateRepository deleteRepository deleteArtifact deleteRepositoryUpstream }
      upstreamRepositories {
        upstreamRepository {
          name
          kind
          userPermissions { updateRepository deleteArtifact }
        }
      }
    }
  }
}
curl -sk https://gitlab.caproni.test/api/graphql -H "Authorization: Bearer $USER_PAT" -H "Content-Type: application/json" \
  -d '{"query": "<query above>", "variables": {"organizationId": "gid://gitlab/Organizations::Organization/1", "name": "npm-virtual-demo"}}'
Output (✅)
== ar-e2e-admin
page repository npm-virtual-demo: {"updateRepository":true,"deleteRepository":true,"deleteArtifact":true,"deleteRepositoryUpstream":true}
  upstream npmjs-proxy (REMOTE): {"updateRepository":true,"deleteArtifact":true}
  upstream team-internal-npm (HOSTED): {"updateRepository":true,"deleteArtifact":true}
== ar-e2e-viewer
page repository npm-virtual-demo: {"updateRepository":false,"deleteRepository":false,"deleteArtifact":false,"deleteRepositoryUpstream":false}
  upstream npmjs-proxy (REMOTE): {"updateRepository":false,"deleteArtifact":false}
  upstream team-internal-npm (HOSTED): {"updateRepository":false,"deleteArtifact":false}
== ar-e2e-virtual-mgr
page repository npm-virtual-demo: {"updateRepository":true,"deleteRepository":false,"deleteArtifact":true,"deleteRepositoryUpstream":true}
  upstream npmjs-proxy (REMOTE): {"updateRepository":false,"deleteArtifact":false}
  upstream team-internal-npm (HOSTED): {"updateRepository":false,"deleteArtifact":false}
== ar-e2e-upstream-mgr
page repository npm-virtual-demo: {"updateRepository":false,"deleteRepository":false,"deleteArtifact":false,"deleteRepositoryUpstream":false}
  upstream npmjs-proxy (REMOTE): {"updateRepository":true,"deleteArtifact":true}
  upstream team-internal-npm (HOSTED): {"updateRepository":true,"deleteArtifact":true}
== ar-e2e-remote-mgr
page repository npm-virtual-demo: {"updateRepository":false,"deleteRepository":false,"deleteArtifact":false,"deleteRepositoryUpstream":false}
  upstream npmjs-proxy (REMOTE): {"updateRepository":true,"deleteArtifact":true}
  upstream team-internal-npm (HOSTED): {"updateRepository":false,"deleteArtifact":false}

No response carried errors.

Step 7. Open the row kebab on the virtual repository detail page as each user

Sign in as each user at http://gitlab.caproni.test, visit /o/default/-/artifact_registry/default/repositories/npm-virtual-demo and open the kebab on each upstream row.

Output (✅)
ar-e2e-admin:
  npmjs-proxy: toggle=true items=["Clear cache","Edit repository","Remove repository"]
  team-internal-npm: toggle=true items=["Edit repository","Remove repository"]
ar-e2e-viewer:
  npmjs-proxy: toggle=false
  team-internal-npm: toggle=false
ar-e2e-virtual-mgr:
  npmjs-proxy: toggle=true items=["Remove repository"]
  team-internal-npm: toggle=true items=["Remove repository"]
ar-e2e-upstream-mgr:
  npmjs-proxy: toggle=true items=["Clear cache","Edit repository"]
  team-internal-npm: toggle=true items=["Edit repository"]
ar-e2e-remote-mgr:
  npmjs-proxy: toggle=true items=["Clear cache","Edit repository"]
  team-internal-npm: toggle=false
console errors: 0 in every run

Step 8. Run the component specs

node_modules/.bin/jest ee/spec/frontend/packages_and_registries/artifact_registry/graphql/utils/permissions_spec.js \
  ee/spec/frontend/packages_and_registries/artifact_registry/repositories/detail/upstream_repository_actions_spec.js \
  ee/spec/frontend/packages_and_registries/artifact_registry/repositories/detail/repository_detail_spec.js \
  ee/spec/frontend/packages_and_registries/artifact_registry/repositories/detail/upstream_repositories_table_spec.js
Output (✅)
PASS ee/spec/frontend/packages_and_registries/artifact_registry/graphql/utils/permissions_spec.js
PASS ee/spec/frontend/packages_and_registries/artifact_registry/repositories/detail/upstream_repository_actions_spec.js
PASS ee/spec/frontend/packages_and_registries/artifact_registry/repositories/detail/upstream_repositories_table_spec.js
PASS ee/spec/frontend/packages_and_registries/artifact_registry/repositories/detail/repository_detail_spec.js
Test Suites: 4 passed, 4 total
Tests:       241 passed, 241 total

Evidence

Recorded on the caproni rig at this branch's head (a0ac6ca1f3c), with Artifact Registry v2.119.0, the GLAZ v10.1.0 sidecar and IAM Data Access. Each user is a non-admin organization member whose only Artifact Registry access is the grant shown in the caption.

The recordings predate 7e4a1685, so the ar-e2e-viewer recording still shows an Actions header over empty cells. That column is now dropped, which upstream_repositories_table_spec.js covers.

The transient state from the last commit, where Remove repository stays hidden until the repository detail refresh returns, lasts too short a time to record. repository_detail_spec.js covers it.

Screen recordings

ar-e2e-admin, Artifact Admin on the namespace. The remote row offers Clear cache, Edit repository and Remove repository. The hosted row offers Edit repository and Remove repository.

ar-e2e-viewer, Artifact Viewer on the namespace. Neither row renders a kebab toggle.

ar-e2e-virtual-mgr, Artifact Manager on npm-virtual-demo only. The page repository allows delete_repository_upstream and the upstreams deny update_repository and delete_artifact, so both rows offer Remove repository only.

ar-e2e-upstream-mgr, Artifact Manager on both upstreams only. The page repository denies delete_repository_upstream, so neither row offers Remove repository. The remote row offers Clear cache and Edit repository, and the hosted row offers Edit repository.

ar-e2e-remote-mgr, Artifact Manager on npmjs-proxy only. Each row reads its own upstream's verdicts: the remote row offers Clear cache and Edit repository, and the hosted row renders no kebab.

Rails console

N/A (no client change)

GraphiQL

N/A (no GraphQL schema change)

Edited by Fiona McCawley

Merge request reports

Loading
Loading