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. permissionAllows answers false for an unresolved block and for any value but true, so no component reads a block field directly. Every document carrying a block uses CACHE_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 passes undefined before 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.vue renders nothing when its only item is denied. repository_actions.vue reaches 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 updateRepository and the create route on a denied createRepository render NotFound inside the shell, the same outcome as a null read.
  • Fragments condition on the permission block types. ArtifactRegistryRepository and ArtifactRegistryRepositoryDetails are unrelated schema types, so a fragment conditioned on either parent is invalid at the other site. namespace_verdicts.fragment.graphql (Step 7) conditions on ArtifactRegistryNamespacePermissions and is spread by the list connection and the create route; repository_verdicts.fragment.graphql (Step 8) conditions on ArtifactRegistryRepositoryPermissions and 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 permissionAllows with the declared constant from constants.js. graphql-eslint validates every block selection against the schema, so no document can name an action the declaration lacks.

Acceptance

  • permissionAllows is false for an unresolved block and for any field value but true, and true on an explicit true.
  • Edit route: a denying updateRepository renders NotFound inside 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 denied deleteRepository and Clear cache on a denied deleteArtifact; 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_clean within repositories-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_* and NAMESPACE_PERMISSION_*, the camelCase action names components pass to permissionAllows.
    • <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): render NotFound when updateRepository denies, hold the form until the read resolves, move the query to CACHE_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, and mock_data.js without 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 through permissionAllows with 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 existing listed_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 updateRepository and 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, deleteRepository denied); 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): one describe with two examples under a client faked to deny every verdict, the list and the detail each be_axe_clean.within_testid('repositories-shell') (criterion 19). That testid is the shell app.vue renders 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, selecting userPermissions { ...ArtifactRegistryNamespaceVerdicts } on organization.artifactRegistry.
    • <app>/repositories/create/repositories_create_form.vue (Modify): one CACHE_AND_NETWORK query, render NotFound when createRepository denies, 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.

Edited by 🤖 GitLab Bot 🤖