Artifact Registry UI: gate controls on permission verdicts (monolith/S15 steps 6-11)
Summary
Gate every Artifact Registry (AR) UI control the spec names on the userPermissions blocks the GraphQL layer exposes: the edit route, the list's create dropdown and row kebab, the detail's Edit button, connection Test control and kebab, and the create route. This is the frontend track (Steps 6 to 11) of the monolith/S15 plan, implementing monolith/S15: Permission-gated UI controls.
Why
monolith/S07 shipped the create, edit, delete, connection-test, and clear-cache affordances ungated, relying on AR's denial after the click. With the verdicts on the reads the pages already perform, the UI removes a control the user cannot use before they try it. The verdicts are advisory; AR keeps authorizing every request.
Surfaces the spec marks "gates on arrival" and the virtual upstream row's items are out of scope, per the spec's Out of scope. The virtual upstream row's verdicts are tracked in artifact-registry#1174.
Scope
| Step | Name | Depends on |
|---|---|---|
| 6 | Gate the edit route and add the gating seam | GraphQL track Step 3 |
| 7 | Gate the create dropdown on the list | GraphQL track Step 4, Step 6 |
| 8 | Gate the row kebab on the list | Step 7 |
| 9 | Gate the detail header and connection test | Step 6 |
| 10 | Gate the detail kebab | Steps 8 and 9 |
| 11 | Gate the create route | GraphQL track Step 5, Steps 6 and 7 |
The edit route (Step 6) lands first and carries the gating seam: the action-name constants for both sets in constants.js, the permissionAllows(block, action) helper in graphql/utils/permissions.js, and the allow-all and deny-all mock block factories in mock_data.js. Every later step imports those three without modifying them. The list and the detail are each split in two so that no step exceeds the ~10-file review ceiling.
Rules the components enforce
- No optimistic render, and no render from the cache alone. A gated component renders nothing until its block has resolved, and renders the control only on an explicit
true.permissionAllowsanswersfalsefor an unresolved block and for any value buttrue, so no component reads a block field directly. Every document carrying a block usesCACHE_AND_NETWORK, so every mount re-asks the network: the list and detail queries already do, the edit form's query moves to it, and the create route's new query starts with it. Each page hands its components the block only once the query has stopped loading and passesundefinedbefore that. Page content still renders from the cache; the gated controls wait for the network answer (criterion 15). - Empty menu, no toggle.
row_actions_menu.vuerenders nothing when its only item is denied.repository_actions.vuereaches the same state on an instance with no client base URL, where Copy repository URL is not rendered (v-if="clientUrl"), once its gated items are denied; it renders no toggle when no item remains. - A denied route renders the not-found view. The edit route on a denied
updateRepositoryand the create route on a deniedcreateRepositoryrenderNotFoundinside the shell, the same outcome as a null read. - Fragments condition on the permission block types.
ArtifactRegistryRepositoryandArtifactRegistryRepositoryDetailsare unrelated schema types, so a fragment conditioned on either parent is invalid at the other site.namespace_verdicts.fragment.graphql(Step 7) conditions onArtifactRegistryNamespacePermissionsand is spread by the list connection and the create route;repository_verdicts.fragment.graphql(Step 8) conditions onArtifactRegistryRepositoryPermissionsand is spread by the list rows only. The edit and detail documents select the repository set inline so neither takes an edge onto the list track. - Criterion 5's per-component clause. Every gated component's Jest spec asserts that it reads its verdict through
permissionAllowswith the declared constant fromconstants.js. graphql-eslint validates every block selection against the schema, so no document can name an action the declaration lacks.
Acceptance
-
permissionAllowsisfalsefor an unresolved block and for any field value buttrue, andtrueon an explicittrue. - Edit route: a denying
updateRepositoryrendersNotFoundinside the shell; an allowing one renders the form; nothing renders before the read resolves. - List: a deny-all connection block leaves the header and the empty state with no create dropdown; one denied row among allowed rows loses only its kebab; all rows denied render no kebab; the copy-URL button on a denied row is unaffected.
- Detail: the Edit button and the Test control are each absent on a denied
updateRepository; Delete repository is absent on a denieddeleteRepositoryand Clear cache on a denieddeleteArtifact; the kebab still renders with Copy repository URL when both are denied and a client base URL is configured, and renders no toggle when both are denied and none is. - A mutation dispatched with an allowing verdict and an AR denial renders the existing error (criterion 17).
- Create route: the form renders after one namespace details read resolves allowing; a denying one renders
NotFound; nothing renders before the read resolves. - Primed-cache re-fetch (criterion 15) for the create dropdown and the detail's Edit button: with the cache primed by a denying response, a second mount issues a network request, renders no control until it answers, and renders it when the answer allows.
- Nothing gated renders before its read resolves, on every page (criterion 14).
- The list and the detail with every verdict denying are each
be_axe_cleanwithinrepositories-shell(criterion 19).
Every step is user-visible when artifact_registry_ui is enabled and dark by default. No new strings, no changelog trailer: the monolith's feature-flag guide gives the entry to the MR that flips the flag's default or removes it.
Implementation details (from the plan)
All paths below ee/app/assets/javascripts/packages_and_registries/artifact_registry/ are abbreviated as <app>/, and paths below ee/spec/frontend/packages_and_registries/artifact_registry/ as <spec>/.
Step 6: Gate the edit route and add the gating seam
- Files:
<app>/graphql/utils/permissions.js(Create):permissionAllows(block, action).<app>/constants.js(Modify): both sets' action names,REPOSITORY_PERMISSION_*andNAMESPACE_PERMISSION_*, the camelCase action names components pass topermissionAllows.<spec>/graphql/utils/permissions_spec.js(Create).<spec>/mock_data.js(Modify): allow-all and deny-all block factories for both scopes.<app>/graphql/queries/get_repository.query.graphql(Modify):userPermissions { updateRepository deleteRepository deleteArtifact }, written inline. The repository fragment's owner, Step 8, sits on the list track; an inline selection keeps this step free of a Step 8 edge, and graphql-eslint validates an inline selection against the schema as it does a fragment.<app>/repositories/edit/repositories_edit_form.vue(Modify): renderNotFoundwhenupdateRepositorydenies, hold the form until the read resolves, move the query toCACHE_AND_NETWORK.<spec>/repositories/edit/repositories_edit_form_spec.js(Modify): criterion 14's edit case, the no-optimistic-render case, and criterion 5's per-component clause.
- Shared seams: none. Steps 7 to 11 import
constants.js,permissions.js, andmock_data.jswithout modifying them.
Step 7: Gate the create dropdown on the list
- Files:
<app>/graphql/fragments/namespace_verdicts.fragment.graphql(Create):fragment ArtifactRegistryNamespaceVerdicts on ArtifactRegistryNamespacePermissions. It lands here rather than in Step 6 because this document is its first consumer and Step 4, which declares the type, is this step's predecessor and not Step 6's.<app>/graphql/queries/get_repositories.query.graphql(Modify): the connection's block through...ArtifactRegistryNamespaceVerdicts.<app>/repositories/list/repositories_list.vue,<app>/repositories/list/repositories_header.vue(Modify).<app>/repositories/components/create_button.vue(Modify): the gate, read throughpermissionAllowswith the declared constant.<spec>/repositories/list/repositories_list_spec.js,<spec>/repositories/list/repositories_header_spec.js,<spec>/repositories/components/create_button_spec.js(Modify): criterion 11, criterion 14's no-optimistic-render case for the dropdown, criterion 15's primed-cache re-fetch case for the dropdown, and criterion 5's per-component clause.ee/spec/frontend/fixtures/artifact_registry/repositories.rb(Modify): the fixture carries the connection's block.
- Shared seams:
repositories_list.vue,get_repositories.query.graphql, and the list fixture, with Step 8, which depends on this step and adds the rows' block to each. Step 11 spreads this step's namespace fragment without modifying it.
Step 8: Gate the row kebab on the list
- Files:
<app>/graphql/fragments/repository_verdicts.fragment.graphql(Create):fragment ArtifactRegistryRepositoryVerdicts on ArtifactRegistryRepositoryPermissions, beside the existinglisted_version.fragment.graphql, whose name sets the prefix. This document is its first and only consumer; the detail and edit documents select the set inline.<app>/graphql/queries/get_repositories.query.graphql(Modify): the rows' block through...ArtifactRegistryRepositoryVerdicts.<app>/repositories/list/repositories_list.vue,<app>/repositories/list/repositories_table.vue(Modify).<app>/repositories/components/row_actions_menu.vue(Modify): renders nothing when its only item is denied.<spec>/repositories/list/repositories_table_spec.js,<spec>/repositories/components/row_actions_menu_spec.js(Modify): criterion 12's cases and criterion 5's per-component clause.ee/spec/frontend/fixtures/artifact_registry/repositories.rb(Modify): the fixture gains the rows' block.
- Shared seams: as Step 7. This step does not touch the feature spec; Step 10 owns it and depends on this step.
- Acceptance (step-level): the rows' block is selected through the repository fragment; one denied row among allowed rows loses only its kebab; all rows denied render no kebab; the copy-URL button on a denied row is unaffected; no kebab renders before the read resolves.
Step 9: Gate the detail header and connection test
- Files:
<app>/graphql/queries/get_repository_detail.query.graphql(Modify):userPermissions { updateRepository deleteRepository deleteArtifact }, written inline, for the reason Step 6's document entry gives.<app>/repositories/detail/repository_detail.vue,repository_header.vue,repository_sidebar.vue,connection_section.vue(Modify).<spec>/repositories/detail/repository_detail_spec.js,repository_header_spec.js,repository_sidebar_spec.js,connection_section_spec.js(Modify): criterion 13's Edit and Test cases, criterion 14's no-optimistic-render case for the detail, criterion 15's primed-cache re-fetch case for the Edit button, and criterion 5's per-component clause.ee/spec/frontend/fixtures/artifact_registry/repository.rb(Modify): the detail fixture carries the block.
- Shared seams:
repository_header.vue, with Step 10, which depends on this step and hands the block to the kebab the header renders. - Acceptance (step-level): the Edit button and the Test control are each absent on a denied
updateRepositoryand present on an allowing one; nothing gated renders before the read resolves; the primed-cache case for the Edit button; the kebab is unchanged until Step 10.
Step 10: Gate the detail kebab
- Files:
<app>/repositories/detail/repository_header.vue,<app>/repositories/detail/repository_actions.vue(Modify).<spec>/repositories/detail/repository_actions_spec.js(Modify): criterion 13's Delete, Clear cache, and kebab-still-renders cases; the no-toggle case (no client base URL, hosted,deleteRepositorydenied); criterion 17 (verdict allows, AR denies the write, the existing error rendering runs); criterion 5's per-component clause.ee/spec/features/artifact_registry/repositories_spec.rb(Modify): onedescribewith two examples under a client faked to deny every verdict, the list and the detail eachbe_axe_clean.within_testid('repositories-shell')(criterion 19). That testid is the shellapp.vuerenders around every route, and the one the file's existing accessibility example already scopes to; no detail component carries a root testid.
- Shared seams:
repository_header.vue, with Step 9. The feature spec has this step as its only writer, which is what the dependency edge to Step 8 buys: the two examples land in one MR instead of as adjacent hunks from two branches.
Step 11: Gate the create route
- Files:
<app>/graphql/queries/get_registry_permissions.query.graphql(Create):getArtifactRegistryPermissions, selectinguserPermissions { ...ArtifactRegistryNamespaceVerdicts }onorganization.artifactRegistry.<app>/repositories/create/repositories_create_form.vue(Modify): oneCACHE_AND_NETWORKquery, renderNotFoundwhencreateRepositorydenies, hold the form until the read resolves.<spec>/repositories/create/repositories_create_form_spec.js(Modify): criterion 14's create case, the no-optimistic-render case, and criterion 5's per-component clause.ee/spec/frontend/fixtures/artifact_registry/registry_permissions.rb(Create): the fixture for the new document, per the tree's generated-fixture convention.
- Shared seams: none.
- Acceptance (step-level): the form renders after one namespace details read resolves allowing; a denying one renders
NotFound; nothing renders before the read resolves; a second mount issues a network request and renders nothing until it answers.
Testing
Jest and Vue Test Utils cover every gated component and the permissionAllows predicate. Every step that adds a block selection to a document and reads it (6, 7, 8, 9, 11) asserts that nothing gated renders before the read resolves. The two steps whose pages the spec names (7 and 9) also assert criterion 15's primed-cache re-fetch, because removing either the CACHE_AND_NETWORK policy or the loading gate would pass every other test. The accessibility criterion runs as be_axe_clean feature-spec examples with the client faked to deny everything, one per view, both in Step 10.
E2E scenario impact: none, because every step is behind artifact_registry_ui, which no step enables. Enablement is tracked at #605113, where the e2e question belongs.
The plan names monolith paths and symbols against origin/master at 011c9082eab8.
Related
- Spec: monolith/S15 Permission-gated UI controls
- Plan: 2026-09-07-permission-gated-controls.md
- Tracking work item: #627317
- Flag rollout (enabling
artifact_registry_uineeds ARv2.18.0or later answeringpermissionson all three reads): #605113 - Blocked by the GraphQL track (Steps 3 to 5).