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: selectsuserPermissions { updateRepository deleteArtifact }on each upstream. The issue namesget_repository_detail.query.graphql, but the rows come from the upstream-list query, so the selection goes there.get_repository_detail.query.graphql: addsdeleteRepositoryUpstreamto the repository'suserPermissions.graphql/utils/permissions.js:upstreamActionsandupstreamHasActionshold the per-row rule, so the row menu and the table read the same checks.upstream_repository_actions.vue: gates each item throughupstreamActions, 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: truecaproni up
caproni runArtifact 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 removalStep 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-3406bb9bf5d8Step 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
endOutput (✅ )
== 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 runStep 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.jsOutput (✅ )
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 totalEvidence
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)