diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index abf23b9d2ec93df17a3a86cd9f7a8ad202f16f70..a147cb62649c9df5b60626709200f31cc1ad0938 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -61,11 +61,11 @@ export const getGroupTransferLocations = (groupId, params = {}) => {
   return axios.get(url, { params: { ...defaultParams, ...params } });
 };
 
-export const getGroupMembers = (groupId, inherited = false) => {
+export const getGroupMembers = (groupId, inherited = false, params = {}) => {
   const path = inherited ? GROUP_ALL_MEMBERS_PATH : GROUP_MEMBERS_PATH;
   const url = buildApiUrl(path).replace(':id', groupId);
 
-  return axios.get(url);
+  return axios.get(url, { params });
 };
 
 export const createGroup = (params) => {
diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
index 5b43096325247e244a30ab6d12855378c40b626c..14aeb992f8722450640de416664905b1fae2ee7f 100644
--- a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
+++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
@@ -1,5 +1,6 @@
 import axios from '~/lib/utils/axios_utils';
 import { joinPaths } from '~/lib/utils/url_utility';
+import { getGroupMembers } from '~/rest_api';
 
 const GROUP_SUBGROUPS_PATH = '/-/autocomplete/group_subgroups.json';
 
@@ -25,3 +26,10 @@ export const getSubGroups = (options = defaultOptions) => {
     },
   });
 };
+
+export const getUsers = (query) => {
+  return getGroupMembers(gon.current_group_id, false, {
+    query,
+    per_page: 20,
+  });
+};
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
index e47092b896bfa487b0b2c35f195e86185aa6af8c..56178debee541d80150cfd4495ce5e3224e0a51f 100644
--- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -1,14 +1,22 @@
 <script>
-import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import {
+  GlDropdown,
+  GlDropdownItem,
+  GlDropdownSectionHeader,
+  GlDropdownDivider,
+  GlSearchBoxByType,
+} from '@gitlab/ui';
 import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
 import { createAlert } from '~/alert';
 import { __, s__, n__ } from '~/locale';
-import { getSubGroups } from '../api/access_dropdown_api';
+import { getSubGroups, getUsers } from '../api/access_dropdown_api';
 import { LEVEL_TYPES } from '../constants';
 
 export const i18n = {
   selectUsers: s__('ProtectedEnvironment|Select groups'),
+  rolesSectionHeader: s__('AccessDropdown|Roles'),
   groupsSectionHeader: s__('AccessDropdown|Groups'),
+  usersSectionHeader: s__('AccessDropdown|Users'),
 };
 
 export default {
@@ -17,9 +25,15 @@ export default {
     GlDropdown,
     GlDropdownItem,
     GlDropdownSectionHeader,
+    GlDropdownDivider,
     GlSearchBoxByType,
   },
   props: {
+    accessLevelsData: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
     hasLicense: {
       required: false,
       type: Boolean,
@@ -40,15 +54,29 @@ export default {
       required: false,
       default: () => [],
     },
+    items: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+    showUsers: {
+      required: false,
+      type: Boolean,
+      default: false,
+    },
   },
   data() {
     return {
       loading: false,
       initialLoading: false,
       query: '',
+      roles: [],
       groups: [],
+      users: [],
       selected: {
+        [LEVEL_TYPES.ROLE]: [],
         [LEVEL_TYPES.GROUP]: [],
+        [LEVEL_TYPES.USER]: [],
       },
     };
   },
@@ -61,30 +89,73 @@ export default {
         Object.entries(this.selected).map(([key, value]) => [key, value.length]),
       );
 
+      const isOnlyRoleSelected =
+        counts[LEVEL_TYPES.ROLE] === 1 &&
+        [counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.USER]].every((count) => count === 0);
+
+      if (isOnlyRoleSelected) {
+        return this.selected[LEVEL_TYPES.ROLE][0].text;
+      }
+
       const labelPieces = [];
 
+      if (counts[LEVEL_TYPES.ROLE] > 0) {
+        labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE]));
+      }
+
       if (counts[LEVEL_TYPES.GROUP] > 0) {
         labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
       }
 
+      if (counts[LEVEL_TYPES.USER] > 0) {
+        labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
+      }
+
       return labelPieces.join(', ') || this.label;
     },
     toggleClass() {
       return this.toggleLabel === this.label ? 'gl-text-gray-500!' : '';
     },
     selection() {
-      return [...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id')];
+      return [
+        ...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'),
+        ...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'),
+        ...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'),
+      ];
     },
   },
   watch: {
     query: debounce(function debouncedSearch() {
       return this.getData();
     }, 500),
+    items(items) {
+      this.setDataForSave(items);
+    },
   },
   created() {
     this.getData({ initial: true });
   },
   methods: {
+    setDataForSave(items) {
+      this.selected = items.reduce(
+        (selected, item) => {
+          if (item.group_id) {
+            selected[LEVEL_TYPES.GROUP].push({ id: item.group_id, ...item });
+          } else if (item.user_id) {
+            selected[LEVEL_TYPES.USER].push({ id: item.user_id, ...item });
+          } else if (item.access_level) {
+            const level = this.accessLevelsData.find(({ id }) => item.access_level === id);
+            selected[LEVEL_TYPES.ROLE].push(level);
+          }
+          return selected;
+        },
+        {
+          [LEVEL_TYPES.GROUP]: [],
+          [LEVEL_TYPES.USER]: [],
+          [LEVEL_TYPES.ROLE]: [],
+        },
+      );
+    },
     focusInput() {
       this.$refs.search.focusInput();
     },
@@ -99,25 +170,44 @@ export default {
             includeParentSharedGroups: true,
             search: this.query,
           }),
+          this.showUsers ? getUsers(this.query) : Promise.resolve({ data: this.users }),
         ])
-          .then(([groupsResponse]) => {
-            this.consolidateData(groupsResponse.data);
+          .then(([groupsResponse, usersResponse]) => {
+            this.consolidateData(groupsResponse.data, usersResponse.data);
             this.setSelected({ initial });
           })
-          .catch(() => createAlert({ message: __('Failed to load groups.') }))
+          .catch(() => createAlert({ message: __('Failed to load groups and users.') }))
           .finally(() => {
             this.initialLoading = false;
             this.loading = false;
           });
       }
     },
-    consolidateData(groupsResponse = []) {
+    consolidateData(groupsResponse = [], usersResponse = []) {
+      this.roles = this.accessLevelsData.map((role) => ({ ...role, type: LEVEL_TYPES.ROLE }));
+
       if (this.hasLicense) {
         this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
+        this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({
+          id,
+          name,
+          username,
+          avatar_url,
+          type: LEVEL_TYPES.USER,
+        }));
       }
     },
     setSelected({ initial } = {}) {
       if (initial) {
+        const selectedRoles = intersectionWith(
+          this.roles,
+          this.preselectedItems,
+          (role, selected) => {
+            return selected.type === LEVEL_TYPES.ROLE && role.id === selected.access_level;
+          },
+        );
+        this.selected[LEVEL_TYPES.ROLE] = selectedRoles;
+
         const selectedGroups = intersectionWith(
           this.groups,
           this.preselectedItems,
@@ -126,6 +216,23 @@ export default {
           },
         );
         this.selected[LEVEL_TYPES.GROUP] = selectedGroups;
+
+        const selectedUsers = this.preselectedItems
+          .filter(({ type }) => type === LEVEL_TYPES.USER)
+          .map(({ user_id: id, name, username, avatar_url, type }) => ({
+            id,
+            name,
+            username,
+            avatar_url,
+            type,
+          }));
+
+        this.selected[LEVEL_TYPES.USER] = selectedUsers;
+
+        this.users = this.users.filter(
+          (user) => !this.selected[LEVEL_TYPES.USER].some((selected) => selected.id === user.id),
+        );
+        this.users.unshift(...this.selected[LEVEL_TYPES.USER]);
       }
     },
     getDataForSave(accessType, key) {
@@ -144,14 +251,16 @@ export default {
       return [...added, ...removed, ...preserved];
     },
     onItemClick(item) {
-      this.toggleSelection(this.selected[item.type], item);
+      this.toggleSelection(item);
       this.emitUpdate();
     },
-    toggleSelection(arr, item) {
-      const itemIndex = arr.findIndex(({ id }) => id === item.id);
-      if (itemIndex > -1) {
-        arr.splice(itemIndex, 1);
-      } else arr.push(item);
+    toggleSelection(item) {
+      const itemSelected = this.isSelected(item);
+      if (itemSelected) {
+        this.selected[item.type] = this.selected[item.type].filter(({ id }) => id !== item.id);
+        return;
+      }
+      this.selected[item.type].push(item);
     },
     isSelected(item) {
       return this.selected[item.type].some((selected) => selected.id === item.id);
@@ -180,6 +289,23 @@ export default {
       <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
     </template>
     <div>
+      <template v-if="roles.length">
+        <gl-dropdown-section-header>{{
+          $options.i18n.rolesSectionHeader
+        }}</gl-dropdown-section-header>
+        <gl-dropdown-item
+          v-for="role in roles"
+          :key="`${role.id}${role.text}`"
+          data-testid="role-dropdown-item"
+          is-check-item
+          :is-checked="isSelected(role)"
+          @click.native.capture.stop="onItemClick(role)"
+        >
+          {{ role.text }}
+        </gl-dropdown-item>
+        <gl-dropdown-divider v-if="groups.length || users.length" />
+      </template>
+
       <template v-if="groups.length">
         <gl-dropdown-section-header>{{
           $options.i18n.groupsSectionHeader
@@ -194,6 +320,25 @@ export default {
         >
           {{ group.name }}
         </gl-dropdown-item>
+        <gl-dropdown-divider v-if="users.length" />
+      </template>
+
+      <template v-if="users.length">
+        <gl-dropdown-section-header>{{
+          $options.i18n.usersSectionHeader
+        }}</gl-dropdown-section-header>
+        <gl-dropdown-item
+          v-for="user in users"
+          :key="`${user.id}${user.username}`"
+          data-testid="user-dropdown-item"
+          :avatar-url="user.avatar_url"
+          :secondary-text="user.username"
+          is-check-item
+          :is-checked="isSelected(user)"
+          @click.native.capture.stop="onItemClick(user)"
+        >
+          {{ user.name }}
+        </gl-dropdown-item>
       </template>
     </div>
   </gl-dropdown>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
index 023ddf29b36122fbafc3f05d0c6912c2634c5c5e..7af699c9ca9acf60000c6a3fc92aca11ac7e1e84 100644
--- a/app/assets/javascripts/groups/settings/constants.js
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -1,6 +1,4 @@
-export const LEVEL_TYPES = {
-  GROUP: 'group',
-};
+export const LEVEL_TYPES = { ROLE: 'role', GROUP: 'group', USER: 'user' };
 
 export const README_MODAL_ID = 'add_group_readme_modal';
 export const GITLAB_README_PROJECT = 'gitlab-profile';
diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js
index 3cb29b74424ffb335e4d3afc1eec4ebbbd028cb8..32118246c0aebe6c54a23a4b82dd70d57a9085ea 100644
--- a/ee/app/assets/javascripts/api.js
+++ b/ee/app/assets/javascripts/api.js
@@ -33,10 +33,10 @@ export default {
   issueMetricSingleImagePath:
     '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id',
   environmentApprovalPath: '/api/:version/projects/:id/deployments/:deployment_id/approval',
-  protectedEnvironmentsPath: '/api/:version/projects/:id/protected_environments/',
+  protectedEnvironmentsPath: '/api/:version/:entity_type/:id/protected_environments/',
   mrStatusCheckRetryPath:
     '/api/:version/projects/:id/merge_requests/:merge_request_iid/status_checks/:external_status_check_id/retry',
-  protectedEnvironmentPath: '/api/:version/projects/:id/protected_environments/:name',
+  protectedEnvironmentPath: '/api/:version/:entity_type/:id/protected_environments/:name',
   aiCompletionsPath: '/api/:version/ai/experimentation/openai/completions',
   aiEmbeddingsPath: '/api/:version/ai/experimentation/openai/embeddings',
   aiChatPath: '/api/:version/ai/experimentation/openai/chat/completions',
@@ -272,26 +272,32 @@ export default {
     return this.deploymentApproval({ id, deploymentId, approve: false, representedAs, comment });
   },
 
-  protectedEnvironments(id, params = {}) {
-    const url = Api.buildUrl(this.protectedEnvironmentsPath).replace(':id', encodeURIComponent(id));
+  protectedEnvironments(id, entityType, params = {}) {
+    const url = Api.buildUrl(this.protectedEnvironmentsPath)
+      .replace(':entity_type', encodeURIComponent(entityType))
+      .replace(':id', encodeURIComponent(id));
     return axios.get(url, { params });
   },
 
-  createProtectedEnvironment(id, protectedEnvironment) {
-    const url = Api.buildUrl(this.protectedEnvironmentsPath).replace(':id', encodeURIComponent(id));
+  createProtectedEnvironment(id, entityType, protectedEnvironment) {
+    const url = Api.buildUrl(this.protectedEnvironmentsPath)
+      .replace(':entity_type', encodeURIComponent(entityType))
+      .replace(':id', encodeURIComponent(id));
     return axios.post(url, protectedEnvironment);
   },
 
-  updateProtectedEnvironment(id, protectedEnvironment) {
+  updateProtectedEnvironment(id, entityType, protectedEnvironment) {
     const url = Api.buildUrl(this.protectedEnvironmentPath)
+      .replace(':entity_type', encodeURIComponent(entityType))
       .replace(':id', encodeURIComponent(id))
       .replace(':name', encodeURIComponent(protectedEnvironment.name));
 
     return axios.put(url, protectedEnvironment);
   },
 
-  deleteProtectedEnvironment(id, { name }) {
+  deleteProtectedEnvironment(id, entityType, { name }) {
     const url = Api.buildUrl(this.protectedEnvironmentPath)
+      .replace(':entity_type', encodeURIComponent(entityType))
       .replace(':id', encodeURIComponent(id))
       .replace(':name', encodeURIComponent(name));
 
diff --git a/ee/app/assets/javascripts/protected_environments/add_approvers.vue b/ee/app/assets/javascripts/protected_environments/add_approvers.vue
index bd9703dade28781af5717fa9a86b4c51a0a2636a..18ed94648a3042149881bf38c4151b307182ef45 100644
--- a/ee/app/assets/javascripts/protected_environments/add_approvers.vue
+++ b/ee/app/assets/javascripts/protected_environments/add_approvers.vue
@@ -17,6 +17,7 @@ import Api from 'ee/api';
 import { getUser } from '~/rest_api';
 import { s__ } from '~/locale';
 import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
+import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import { ACCESS_LEVELS, INHERITED_GROUPS, NON_INHERITED_GROUPS } from './constants';
 
 const mapUserToApprover = (user) => ({
@@ -66,12 +67,14 @@ export default {
     GlSprintf,
     GlToggle,
     AccessDropdown,
+    GroupsAccessDropdown,
   },
   directives: { GlTooltip },
   inject: {
     accessLevelsData: { default: [] },
     apiLink: {},
     docsLink: {},
+    entityType: { default: 'projects' },
   },
   props: {
     disabled: {
@@ -96,6 +99,12 @@ export default {
     hasSelectedApprovers() {
       return Boolean(this.approvers.length);
     },
+    approverHelpText() {
+      return this.$options.i18n.approverHelp[this.entityType];
+    },
+    isProjectType() {
+      return this.entityType === 'projects';
+    },
   },
   watch: {
     async approvers() {
@@ -196,9 +205,14 @@ export default {
   },
   i18n: {
     approverLabel: s__('ProtectedEnvironment|Approvers'),
-    approverHelp: s__(
-      'ProtectedEnvironments|Set which groups, access levels, or users are required to approve. Groups and users must be members of the project.',
-    ),
+    approverHelp: {
+      projects: s__(
+        'ProtectedEnvironments|Set which groups, access levels, or users are required to approve. Groups and users must be members of the project.',
+      ),
+      groups: s__(
+        'ProtectedEnvironments|Set which groups, access levels, or users are required to approve in this environment tier.',
+      ),
+    },
     approvalRulesLabel: s__('ProtectedEnvironments|Approval rules'),
     approvalsInvalid: s__('ProtectedEnvironments|Number of approvals must be between 1 and 5'),
     removeApprover: s__('ProtectedEnvironments|Remove approval rule'),
@@ -221,9 +235,10 @@ export default {
       :label="$options.i18n.approverLabel"
     >
       <template #label-description>
-        {{ $options.i18n.approverHelp }}
+        {{ approverHelpText }}
       </template>
       <access-dropdown
+        v-if="isProjectType"
         id="create-approver-dropdown"
         :label="$options.i18n.accessDropdownLabel"
         :access-levels-data="accessLevelsData"
@@ -232,6 +247,16 @@ export default {
         :items="approvers"
         @select="updateApprovers"
       />
+      <groups-access-dropdown
+        v-else
+        id="create-approver-dropdown"
+        :label="$options.i18n.accessDropdownLabel"
+        :access-levels-data="accessLevelsData"
+        :disabled="disabled"
+        :items="approvers"
+        show-users
+        @select="updateApprovers"
+      />
       <template #description>
         <gl-sprintf :message="$options.i18n.unifiedRulesHelpText">
           <template #apiLink="{ content }">
diff --git a/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue b/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue
index f32e8175949f2966fddaf7b49a35e90a74b06d34..730eb8b45a84d201fa5d867750e1acedd552f192 100644
--- a/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue
+++ b/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue
@@ -14,6 +14,7 @@ import axios from '~/lib/utils/axios_utils';
 import { __, s__ } from '~/locale';
 import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
+import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
 import AddApprovers from './add_approvers.vue';
 import { ACCESS_LEVELS } from './constants';
@@ -28,6 +29,7 @@ export default {
     GlFormGroup,
     GlCollapsibleListbox,
     AccessDropdown,
+    GroupsAccessDropdown,
     AddApprovers,
   },
   mixins: [glFeatureFlagsMixin()],
@@ -35,8 +37,10 @@ export default {
     accessLevelsData: { default: [] },
     apiLink: {},
     docsLink: {},
-    projectId: { default: '' },
+    entityId: { default: '' },
+    entityType: { default: 'projects' },
     searchUnprotectedEnvironmentsUrl: { default: '' },
+    tiers: { default: [] },
   },
   data() {
     return {
@@ -49,6 +53,7 @@ export default {
       environmentsLoading: false,
       errorMessage: '',
       loading: false,
+      environmentTier: '',
     };
   },
   computed: {
@@ -59,10 +64,26 @@ export default {
       return this.environment || this.$options.i18n.environmentText;
     },
     hasSelectedEnvironment() {
-      return Boolean(this.environment);
+      return Boolean(this.environment) || Boolean(this.environmentTier);
+    },
+    isProjectType() {
+      return this.entityType === 'projects';
+    },
+    environmentTierText() {
+      return this.environmentTier || this.$options.i18n.environmentTierText;
+    },
+    environmentTiers() {
+      return this.tiers.map((tier) => ({ text: tier, value: tier }));
+    },
+    deployerHelpText() {
+      return this.$options.i18n.deployerHelp[this.entityType];
+    },
+    addText() {
+      return this.$options.i18n.addText[this.entityType];
     },
   },
   mounted() {
+    if (!this.isProjectType) return;
     this.fetchEnvironments();
   },
   unmounted() {
@@ -114,11 +135,12 @@ export default {
       this.loading = true;
 
       const protectedEnvironment = {
-        name: this.environment,
+        name: this.environment || this.environmentTier,
         deploy_access_levels: this.deployers,
         approval_rules: this.approvers,
       };
-      Api.createProtectedEnvironment(this.projectId, protectedEnvironment)
+      const entityType = this.isProjectType ? 'projects' : 'groups';
+      Api.createProtectedEnvironment(this.entityId, entityType, protectedEnvironment)
         .then(() => {
           this.$emit('success');
           this.deployers = [];
@@ -141,14 +163,24 @@ export default {
   },
   i18n: {
     header: s__('ProtectedEnvironment|Protect an environment'),
-    addText: s__('ProtectedEnvironment|Add new protected environment'),
+    addText: {
+      projects: s__('ProtectedEnvironment|Add new protected environment'),
+      groups: s__('ProtectedEnvironment|Protect environment tier'),
+    },
     environmentLabel: s__('ProtectedEnvironment|Select environment'),
     environmentText: s__('ProtectedEnvironment|Select an environment'),
+    environmentTierLabel: s__('ProtectedEnvironment|Select environment tier'),
+    environmentTierText: s__('ProtectedEnvironment|Select environment tier'),
     approvalLabel: s__('ProtectedEnvironment|Required approvals'),
     deployerLabel: s__('ProtectedEnvironments|Allowed to deploy'),
-    deployerHelp: s__(
-      'ProtectedEnvironments|Set which groups, access levels, or users can deploy to this environment. Groups and users must be members of the project.',
-    ),
+    deployerHelp: {
+      projects: s__(
+        'ProtectedEnvironments|Set which groups, access levels, or users can deploy to this environment. Groups and users must be members of the project.',
+      ),
+      groups: s__(
+        'ProtectedEnvironments|Set which groups, access levels, or users can deploy in this environment tier.',
+      ),
+    },
     buttonText: s__('ProtectedEnvironment|Protect'),
     buttonTextCancel: __('Cancel'),
     accessDropdownLabel: s__('ProtectedEnvironments|Select users'),
@@ -162,9 +194,10 @@ export default {
         {{ errorMessage }}
       </gl-alert>
 
-      <h4 class="gl-mt-0">{{ $options.i18n.addText }}</h4>
+      <h4 class="gl-mt-0">{{ addText }}</h4>
 
       <gl-form-group
+        v-if="isProjectType"
         label-for="environment"
         data-testid="create-environment"
         :label="$options.i18n.environmentLabel"
@@ -180,6 +213,20 @@ export default {
         />
       </gl-form-group>
 
+      <gl-form-group
+        v-else
+        label-for="environment-tier"
+        data-testid="create-environment"
+        :label="$options.i18n.environmentTierLabel"
+      >
+        <gl-collapsible-listbox
+          id="create-environment"
+          v-model="environmentTier"
+          :toggle-text="environmentTierText"
+          :items="environmentTiers"
+        />
+      </gl-form-group>
+
       <gl-collapse :visible="hasSelectedEnvironment">
         <gl-form-group
           data-testid="create-deployer-dropdown"
@@ -187,9 +234,10 @@ export default {
           :label="$options.i18n.deployerLabel"
         >
           <template #label-description>
-            {{ $options.i18n.deployerHelp }}
+            {{ deployerHelpText }}
           </template>
           <access-dropdown
+            v-if="isProjectType"
             id="create-deployer-dropdown"
             :label="$options.i18n.accessDropdownLabel"
             :access-levels-data="accessLevelsData"
@@ -199,9 +247,19 @@ export default {
             groups-with-project-access
             @select="updateDeployers"
           />
+          <groups-access-dropdown
+            v-else
+            id="create-deployer-dropdown"
+            :label="$options.i18n.accessDropdownLabel"
+            :access-levels-data="accessLevelsData"
+            :disabled="disabled"
+            :items="deployers"
+            show-users
+            @select="updateDeployers"
+          />
         </gl-form-group>
         <add-approvers
-          :project-id="projectId"
+          :project-id="entityId"
           :approval-rules="approvers"
           @change="updateApprovers"
           @error="errorMessage = $event"
diff --git a/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue b/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue
index b632ff36b2e767a6dec064482138d4d1b3e9354f..37f29d6f8c7c53f05a8a3aef4e4077dc78c6ca2b 100644
--- a/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue
+++ b/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue
@@ -12,6 +12,7 @@ import {
 import { mapState, mapActions, mapGetters } from 'vuex';
 import { s__, __ } from '~/locale';
 import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
+import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import ShowMore from '~/vue_shared/components/show_more.vue';
 import { ACCESS_LEVELS, DEPLOYER_RULE_KEY, APPROVER_RULE_KEY, INHERITED_GROUPS } from './constants';
 import EditProtectedEnvironmentRulesCard from './edit_protected_environment_rules_card.vue';
@@ -28,6 +29,7 @@ export default {
     GlIcon,
     GlToggle,
     AccessDropdown,
+    GroupsAccessDropdown,
     ProtectedEnvironments,
     EditProtectedEnvironmentRulesCard,
     AddRuleModal,
@@ -37,12 +39,12 @@ export default {
   directives: {
     GlTooltip,
   },
-  inject: { accessLevelsData: { default: [] } },
+  inject: { accessLevelsData: { default: [] }, entityType: { default: 'projects' } },
   data() {
     return { isAddingRule: false, addingEnvironment: null, addingRule: '' };
   },
   computed: {
-    ...mapState(['projectId', 'loading', 'protectedEnvironments', 'editingRules']),
+    ...mapState(['entityId', 'loading', 'protectedEnvironments', 'editingRules']),
     ...mapGetters(['getUsersForRule']),
     isAddingDeploymentRule() {
       return this.addingRule === DEPLOYER_RULE_KEY;
@@ -52,6 +54,9 @@ export default {
         ? this.$options.i18n.addDeploymentRuleModalTitle
         : this.$options.i18n.addApprovalRuleModalTitle;
     },
+    isProjectType() {
+      return this.entityType === 'projects';
+    },
   },
   mounted() {
     this.fetchProtectedEnvironments();
@@ -128,6 +133,7 @@ export default {
           data-testid="create-deployer-dropdown"
         >
           <access-dropdown
+            v-if="isProjectType"
             id="update-deployer-dropdown"
             class="gl-w-3/10"
             :label="$options.i18n.accessDropdownLabel"
@@ -136,11 +142,20 @@ export default {
             :access-level="$options.ACCESS_LEVELS.DEPLOY"
             @hidden="setRule({ environment: addingEnvironment, newRules: $event })"
           />
+          <groups-access-dropdown
+            v-else
+            id="update-deployer-dropdown"
+            class="gl-w-3/10"
+            :label="$options.i18n.accessDropdownLabel"
+            :access-levels-data="accessLevelsData"
+            show-users
+            @hidden="setRule({ environment: addingEnvironment, newRules: $event })"
+          />
         </gl-form-group>
       </template>
       <template v-else #add-rule-form>
         <add-approvers
-          :project-id="projectId"
+          :project-id="entityId"
           @change="setRule({ environment: addingEnvironment, newRules: $event })"
         />
       </template>
diff --git a/ee/app/assets/javascripts/protected_environments/protected_environments.js b/ee/app/assets/javascripts/protected_environments/protected_environments.js
index 38ded53e758acc4c9ee41b814775657f6286ba6b..e1141951d5df82e133cee602457cdb65e275df64 100644
--- a/ee/app/assets/javascripts/protected_environments/protected_environments.js
+++ b/ee/app/assets/javascripts/protected_environments/protected_environments.js
@@ -13,14 +13,18 @@ export const initProtectedEnvironments = () => {
     return null;
   }
 
-  const { projectId, apiLink, docsLink } = el.dataset;
+  // entityId is the ID of the project or group.
+  // entityType is either 'projects' or 'groups'.
+  const { entityId, apiLink, docsLink, entityType, tiers } = el.dataset;
   return new Vue({
     el,
     store: createStore({
       ...el.dataset,
     }),
     provide: {
-      projectId,
+      entityId,
+      entityType,
+      tiers: tiers ? JSON.parse(tiers) : [],
       accessLevelsData: gon?.deploy_access_levels?.roles ?? [],
       apiLink,
       docsLink,
diff --git a/ee/app/assets/javascripts/protected_environments/protected_environments.vue b/ee/app/assets/javascripts/protected_environments/protected_environments.vue
index 5d973bba19b82a7e177ad72d9317674697392545..2b794e0001ecc5492acec40a5cbed23cf7f4f8ef 100644
--- a/ee/app/assets/javascripts/protected_environments/protected_environments.vue
+++ b/ee/app/assets/javascripts/protected_environments/protected_environments.vue
@@ -18,6 +18,7 @@ export default {
     Pagination,
     CreateProtectedEnvironment,
   },
+  inject: ['entityType'],
   props: {
     environments: {
       required: true,
@@ -27,7 +28,10 @@ export default {
   i18n: {
     title: s__('ProtectedEnvironments|Protected environments'),
     newProtectedEnvironment: s__('ProtectedEnvironments|Protect an environment'),
-    emptyMessage: s__('ProtectedEnvironment|No environments in this project are protected.'),
+    emptyMessage: {
+      projects: s__('ProtectedEnvironment|No environments in this project are protected.'),
+      groups: s__('ProtectedEnvironment|No environments in this group are protected.'),
+    },
   },
   data() {
     return {
@@ -58,6 +62,9 @@ export default {
     showEmptyMessage() {
       return this.environments.length === 0 && !this.isAddFormVisible;
     },
+    emptyMessage() {
+      return this.$options.i18n.emptyMessage[this.entityType];
+    },
   },
   methods: {
     ...mapActions(['setPage', 'fetchProtectedEnvironments']),
@@ -166,7 +173,7 @@ export default {
       </gl-modal>
 
       <div v-if="showEmptyMessage" class="gl-new-card-empty gl-px-5 gl-py-4">
-        {{ $options.i18n.emptyMessage }}
+        {{ emptyMessage }}
       </div>
       <template v-else>
         <div
diff --git a/ee/app/assets/javascripts/protected_environments/store/edit/actions.js b/ee/app/assets/javascripts/protected_environments/store/edit/actions.js
index 53bc5757a77cf0e2f0b0ff5f251170d91961988b..ce41f90cf597111d10730dc54aec045dcc18e8f8 100644
--- a/ee/app/assets/javascripts/protected_environments/store/edit/actions.js
+++ b/ee/app/assets/javascripts/protected_environments/store/edit/actions.js
@@ -10,7 +10,7 @@ import {
 import * as types from './mutation_types';
 
 const fetchUsersForRuleForProject = (
-  projectId,
+  entityId,
   {
     user_id: userId,
     group_id: groupId,
@@ -27,7 +27,7 @@ const fetchUsersForRuleForProject = (
     );
   }
 
-  return getProjectMembers(projectId, groupInheritanceType === INHERITED_GROUPS).then(({ data }) =>
+  return getProjectMembers(entityId, groupInheritanceType === INHERITED_GROUPS).then(({ data }) =>
     data.filter(({ access_level: memberAccessLevel }) => memberAccessLevel >= accessLevel),
   );
 };
@@ -39,7 +39,7 @@ export const fetchProtectedEnvironments = ({ state, commit, dispatch }) => {
     page: state.pageInfo?.page ?? null,
   };
 
-  return Api.protectedEnvironments(state.projectId, params)
+  return Api.protectedEnvironments(state.entityId, state.entityType, params)
     .then(({ data, headers }) => {
       commit(types.RECEIVE_PROTECTED_ENVIRONMENTS_SUCCESS, data);
       dispatch('fetchAllMembers');
@@ -78,7 +78,7 @@ export const fetchAllMembersForEnvironment = ({ dispatch }, environment) => {
 };
 
 export const fetchMembers = ({ state, commit }, { type, rule }) => {
-  return fetchUsersForRuleForProject(state.projectId, rule)
+  return fetchUsersForRuleForProject(state.entityId, rule)
     .then((users) => {
       commit(types.RECEIVE_MEMBER_SUCCESS, { type, rule, users });
     })
@@ -135,7 +135,7 @@ export const updateRule = ({ dispatch, state, commit }, { environment, ruleKey,
 export const updateEnvironment = ({ state, commit, dispatch }, environment) => {
   commit(types.REQUEST_UPDATE_PROTECTED_ENVIRONMENT);
 
-  return Api.updateProtectedEnvironment(state.projectId, environment)
+  return Api.updateProtectedEnvironment(state.entityId, state.entityType, environment)
     .then(({ data }) => {
       commit(types.RECEIVE_UPDATE_PROTECTED_ENVIRONMENT_SUCCESS, data);
       dispatch('fetchAllMembersForEnvironment', data);
@@ -157,7 +157,7 @@ export const editRule = ({ commit }, rule) => commit(types.EDIT_RULE, rule);
 export const unprotectEnvironment = ({ state, commit, dispatch }, environment) => {
   commit(types.REQUEST_UPDATE_PROTECTED_ENVIRONMENT);
 
-  return Api.deleteProtectedEnvironment(state.projectId, environment)
+  return Api.deleteProtectedEnvironment(state.entityId, state.entityType, environment)
     .then(() => {
       commit(types.DELETE_PROTECTED_ENVIRONMENT_SUCCESS, environment);
 
diff --git a/ee/app/assets/javascripts/protected_environments/store/edit/state.js b/ee/app/assets/javascripts/protected_environments/store/edit/state.js
index a199acde693abf065a8cf64d06d28e4fcef2f480..d662e2292e519bc1a9595486d7ba3cf97853cdc9 100644
--- a/ee/app/assets/javascripts/protected_environments/store/edit/state.js
+++ b/ee/app/assets/javascripts/protected_environments/store/edit/state.js
@@ -1,5 +1,6 @@
-export const state = ({ projectId }) => ({
-  projectId,
+export const state = ({ entityId, entityType }) => ({
+  entityId,
+  entityType,
   loading: false,
   protectedEnvironments: [],
   pageInfo: {},
diff --git a/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml b/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml
index e129d4655c6bfa953ded297145f58ff750cd9370..ed23b116e5c27fcfcc001678746bd517d8888f87 100644
--- a/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml
+++ b/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml
@@ -11,7 +11,8 @@
       %p.gl-text-secondary
         = s_('ProtectedEnvironment|Only specified users can execute deployments in a protected environment.')
     .settings-content
-      #js-protected-environments{ data: { project_id: @project.id,
+      #js-protected-environments{ data: { entity_id: @project.id,
+                                      entity_type: 'projects',
                                       api_link: help_page_path('api/protected_environments'),
                                       docs_link: help_page_path('ci/environments/deployment_approvals', anchor: 'add-multiple-approval-rules') } }
 
diff --git a/ee/spec/frontend/api_spec.js b/ee/spec/frontend/api_spec.js
index 76f812298f654833de0883773129826481f07584..9a85a94376f26fe9e88416b816327e90611888a5 100644
--- a/ee/spec/frontend/api_spec.js
+++ b/ee/spec/frontend/api_spec.js
@@ -311,12 +311,23 @@ describe('Api', () => {
   });
 
   describe('protectedEnvironments', () => {
-    it('fetches all protected environments', () => {
+    it('fetches all protected environments for projects', () => {
       const response = [{ name: 'staging ' }];
       const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/protected_environments/`;
       mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, response);
 
-      return Api.protectedEnvironments(1).then(({ data, config }) => {
+      return Api.protectedEnvironments(1, 'projects').then(({ data, config }) => {
+        expect(data).toEqual(response);
+        expect(config.url).toEqual(expectedUrl);
+      });
+    });
+
+    it('fetches all protected environments for groups', () => {
+      const response = [{ name: 'staging ' }];
+      const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/protected_environments/`;
+      mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, response);
+
+      return Api.protectedEnvironments(1, 'groups').then(({ data, config }) => {
         expect(data).toEqual(response);
         expect(config.url).toEqual(expectedUrl);
       });
@@ -324,12 +335,23 @@ describe('Api', () => {
   });
 
   describe('updateProtectedEnvironment', () => {
-    it('puts changes to a protected environment', () => {
+    it('puts changes to a protected environment for projects', () => {
       const response = { name: 'staging' };
       const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/protected_environments/staging`;
       mock.onPut(expectedUrl, response).reply(HTTP_STATUS_OK, response);
 
-      return Api.updateProtectedEnvironment(1, response).then(({ data, config }) => {
+      return Api.updateProtectedEnvironment(1, 'projects', response).then(({ data, config }) => {
+        expect(data).toEqual(response);
+        expect(config.url).toBe(expectedUrl);
+      });
+    });
+
+    it('puts changes to a protected environment for groups', () => {
+      const response = { name: 'staging' };
+      const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/protected_environments/staging`;
+      mock.onPut(expectedUrl, response).reply(HTTP_STATUS_OK, response);
+
+      return Api.updateProtectedEnvironment(1, 'groups', response).then(({ data, config }) => {
         expect(data).toEqual(response);
         expect(config.url).toBe(expectedUrl);
       });
@@ -337,13 +359,25 @@ describe('Api', () => {
   });
 
   describe('deleteProtectedEnvironment', () => {
-    it('deletes a protected environment', () => {
+    it('deletes a protected environment for projects', () => {
       const environment = { name: 'staging' };
       const response = {};
       const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/protected_environments/staging`;
       mock.onDelete(expectedUrl, environment).reply(HTTP_STATUS_OK, response);
 
-      return Api.deleteProtectedEnvironment(1, environment).then(({ data, config }) => {
+      return Api.deleteProtectedEnvironment(1, 'projects', environment).then(({ data, config }) => {
+        expect(data).toEqual(response);
+        expect(config.url).toBe(expectedUrl);
+      });
+    });
+
+    it('deletes a protected environment for groups', () => {
+      const environment = { name: 'staging' };
+      const response = {};
+      const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/protected_environments/staging`;
+      mock.onDelete(expectedUrl, environment).reply(HTTP_STATUS_OK, response);
+
+      return Api.deleteProtectedEnvironment(1, 'groups', environment).then(({ data, config }) => {
         expect(data).toEqual(response);
         expect(config.url).toBe(expectedUrl);
       });
diff --git a/ee/spec/frontend/protected_environments/add_approvers_spec.js b/ee/spec/frontend/protected_environments/add_approvers_spec.js
index 278f8e93ed9e2afc8236a93626be04562a42687e..2d3459d705c1df0a5830f26c4806593525c1515a 100644
--- a/ee/spec/frontend/protected_environments/add_approvers_spec.js
+++ b/ee/spec/frontend/protected_environments/add_approvers_spec.js
@@ -6,46 +6,54 @@ import waitForPromises from 'helpers/wait_for_promises';
 import { TEST_HOST } from 'helpers/test_constants';
 import axios from '~/lib/utils/axios_utils';
 import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
+import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import { ACCESS_LEVELS } from 'ee/protected_environments/constants';
 import AddApprovers from 'ee/protected_environments/add_approvers.vue';
 import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { __, s__ } from '~/locale';
 
 const PROJECT_ID = '0';
 
 const API_LINK = `${TEST_HOST}/docs/api.md`;
 const DOCS_LINK = `${TEST_HOST}/docs/protected_environments.md`;
+const accessLevelsData = [
+  {
+    id: 40,
+    text: 'Maintainers',
+    before_divider: true,
+  },
+  {
+    id: 30,
+    text: 'Developers + Maintainers',
+    before_divider: true,
+  },
+];
 
 describe('ee/protected_environments/add_approvers.vue', () => {
   let wrapper;
   let mockAxios;
 
-  const createComponent = ({ projectId = PROJECT_ID, disabled = false } = {}) => {
+  const createComponent = ({
+    entityId = PROJECT_ID,
+    disabled = false,
+    entityType = 'projects',
+  } = {}) => {
     wrapper = mountExtended(AddApprovers, {
       propsData: {
-        projectId,
+        entityId,
         disabled,
       },
       provide: {
-        accessLevelsData: [
-          {
-            id: 40,
-            text: 'Maintainers',
-            before_divider: true,
-          },
-          {
-            id: 30,
-            text: 'Developers + Maintainers',
-            before_divider: true,
-          },
-        ],
+        accessLevelsData,
         apiLink: API_LINK,
         docsLink: DOCS_LINK,
+        entityType,
       },
     });
   };
 
   const findApproverDropdown = () => wrapper.findComponent(AccessDropdown);
+  const findGroupsApproverDropdown = () => wrapper.findComponent(GroupsAccessDropdown);
+  const findApproverFormGroup = () => wrapper.findByTestId('create-approver-dropdown');
 
   const findRequiredCountForApprover = (name) =>
     wrapper
@@ -70,17 +78,44 @@ describe('ee/protected_environments/add_approvers.vue', () => {
     mockAxios = new MockAdapter(axios);
   });
 
-  it('renders a dropdown for selecting approvers', () => {
+  it('renders a dropdown for selecting approvers on the project level', () => {
     createComponent();
 
     const approvers = findApproverDropdown();
 
     expect(approvers.props()).toMatchObject({
       accessLevel: ACCESS_LEVELS.DEPLOY,
-      label: __('Select users'),
+      label: 'Select users',
     });
   });
 
+  it('renders a dropdown for selecting approvers on the group level', () => {
+    createComponent({ entityType: 'groups' });
+
+    const approvers = findGroupsApproverDropdown();
+
+    expect(approvers.props()).toMatchObject({
+      accessLevelsData,
+      label: 'Select users',
+    });
+  });
+
+  it('renders correct help text for the approval dropdown on the project level', () => {
+    createComponent();
+
+    expect(findApproverFormGroup().text()).toContain(
+      'Set which groups, access levels, or users are required to approve. Groups and users must be members of the project.',
+    );
+  });
+
+  it('renders correct help text for the approval dropdown on the group level', () => {
+    createComponent({ entityType: 'groups' });
+
+    expect(findApproverFormGroup().text()).toContain(
+      'Set which groups, access levels, or users are required to approve in this environment tier.',
+    );
+  });
+
   it('alerts users to the removal of unified approval rules', () => {
     createComponent();
     const formGroup = wrapper.findComponent(GlFormGroup);
@@ -110,11 +145,7 @@ describe('ee/protected_environments/add_approvers.vue', () => {
     await waitForPromises();
 
     const [[event]] = wrapper.emitted('error').reverse();
-    expect(event).toBe(
-      s__(
-        'ProtectedEnvironments|An error occurred while fetching information on the selected approvers.',
-      ),
-    );
+    expect(event).toBe('An error occurred while fetching information on the selected approvers.');
   });
 
   it('emits an empty error value when fetching new details', async () => {
@@ -155,8 +186,8 @@ describe('ee/protected_environments/add_approvers.vue', () => {
 
     expect(button.props('icon')).toBe('remove');
     expect(button.attributes()).toMatchObject({
-      title: s__('ProtectedEnvironments|Remove approval rule'),
-      'aria-label': s__('ProtectedEnvironments|Remove approval rule'),
+      title: 'Remove approval rule',
+      'aria-label': 'Remove approval rule',
     });
   });
 
diff --git a/ee/spec/frontend/protected_environments/create_protected_environment_spec.js b/ee/spec/frontend/protected_environments/create_protected_environment_spec.js
index 5f087661155fe2db9c285bfdb97776c9d67936f2..3374a7a5dc7056d67e3604255605c826d366e044 100644
--- a/ee/spec/frontend/protected_environments/create_protected_environment_spec.js
+++ b/ee/spec/frontend/protected_environments/create_protected_environment_spec.js
@@ -7,6 +7,7 @@ import { TEST_HOST } from 'helpers/test_constants';
 import Api from 'ee/api';
 import axios from '~/lib/utils/axios_utils';
 import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
+import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import { ACCESS_LEVELS } from 'ee/protected_environments/constants';
 import AddApprovers from 'ee/protected_environments/add_approvers.vue';
 import CreateProtectedEnvironment from 'ee/protected_environments/create_protected_environment.vue';
@@ -18,6 +19,19 @@ const SEARCH_URL = '/search';
 const PROJECT_ID = '0';
 const API_LINK = `${TEST_HOST}/docs/api.md`;
 const DOCS_LINK = `${TEST_HOST}/docs/protected_environments.md`;
+const accessLevelsData = [
+  {
+    id: 40,
+    text: 'Maintainers',
+    before_divider: true,
+  },
+  {
+    id: 30,
+    text: 'Developers + Maintainers',
+    before_divider: true,
+  },
+];
+const tiers = ['production', 'staging'];
 
 jest.mock('lodash');
 
@@ -32,6 +46,8 @@ describe('ee/protected_environments/create_protected_environment.vue', () => {
     wrapper.findByTestId('create-environment').findComponent(GlCollapsibleListbox);
   const findAccessDropdown = () =>
     wrapper.findByTestId('create-deployer-dropdown').findComponent(AccessDropdown);
+  const findGroupsAccessDropdown = () =>
+    wrapper.findByTestId('create-deployer-dropdown').findComponent(GroupsAccessDropdown);
   const findAddApprovers = () => wrapper.findComponent(AddApprovers);
   const findCancelButton = () => wrapper.findByTestId('cancel-button');
   const findForm = () => wrapper.findComponent(GlForm);
@@ -52,26 +68,18 @@ describe('ee/protected_environments/create_protected_environment.vue', () => {
 
   const createComponent = ({
     searchUnprotectedEnvironmentsUrl = SEARCH_URL,
-    projectId = PROJECT_ID,
+    entityId = PROJECT_ID,
+    entityType = 'projects',
   } = {}) => {
     wrapper = mountExtended(CreateProtectedEnvironment, {
       provide: {
         apiLink: API_LINK,
         docsLink: DOCS_LINK,
         searchUnprotectedEnvironmentsUrl,
-        projectId,
-        accessLevelsData: [
-          {
-            id: 40,
-            text: 'Maintainers',
-            before_divider: true,
-          },
-          {
-            id: 30,
-            text: 'Developers + Maintainers',
-            before_divider: true,
-          },
-        ],
+        entityId,
+        entityType,
+        accessLevelsData,
+        tiers,
       },
     });
   };
@@ -96,90 +104,148 @@ describe('ee/protected_environments/create_protected_environment.vue', () => {
     await findForm().trigger('submit');
   };
 
-  it('renders AccessDropdown and passes down the props', () => {
-    createComponent();
-    const dropdown = findAccessDropdown();
+  const submitGroupsForm = async (deployAccessLevels, name, requiredApprovalCount) => {
+    findEnvironmentsListbox().vm.$emit('select', name);
+    findGroupsAccessDropdown().vm.$emit('select', deployAccessLevels);
+    findAddApprovers().vm.$emit(
+      'change',
+      deployAccessLevels.map((rule) => ({ ...rule, required_approvals: requiredApprovalCount })),
+    );
+    await findForm().trigger('submit');
+  };
 
-    expect(dropdown.props()).toMatchObject({
-      accessLevel: ACCESS_LEVELS.DEPLOY,
-      label: __('Select users'),
+  describe('on project level', () => {
+    it('renders AccessDropdown and passes down the props', () => {
+      createComponent();
+      const dropdown = findAccessDropdown();
+
+      expect(dropdown.props()).toMatchObject({
+        accessLevel: ACCESS_LEVELS.DEPLOY,
+        label: __('Select users'),
+      });
     });
-  });
 
-  it('emits cancel event when the cancel button is clicked', () => {
-    createComponent();
-    findCancelButton().trigger('click');
+    it('emits cancel event when the cancel button is clicked', () => {
+      createComponent();
+      findCancelButton().trigger('click');
 
-    expect(wrapper.emitted('cancel').length).toBe(1);
-  });
+      expect(wrapper.emitted('cancel').length).toBe(1);
+    });
 
-  it('searchs the environment name', async () => {
-    const query = 'staging';
-    mockAxios
-      .onGet(SEARCH_URL, { params: { query: '' } })
-      .reply(HTTP_STATUS_OK, ['production', query]);
-    createComponent();
-    jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
-    await waitForPromises();
+    it('searches the environment name', async () => {
+      const query = 'staging';
+      mockAxios
+        .onGet(SEARCH_URL, { params: { query: '' } })
+        .reply(HTTP_STATUS_OK, ['production', query]);
+      createComponent();
+      jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+      await waitForPromises();
 
-    mockAxios.onGet(SEARCH_URL, { params: { query } }).reply(HTTP_STATUS_OK, [query]);
+      mockAxios.onGet(SEARCH_URL, { params: { query } }).reply(HTTP_STATUS_OK, [query]);
 
-    const environmentSearch = findEnvironmentsListbox();
-    environmentSearch.vm.$emit('search', query);
-    jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
-    await waitForPromises();
+      const environmentSearch = findEnvironmentsListbox();
+      environmentSearch.vm.$emit('search', query);
+      jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+      await waitForPromises();
 
-    expect(environmentSearch.props('items')).toEqual([{ value: query, text: query }]);
-  });
+      expect(environmentSearch.props('items')).toEqual([{ value: query, text: query }]);
+    });
 
-  it('renders a dropdown for selecting approvers', () => {
-    createComponent();
+    it('renders a dropdown for selecting approvers', () => {
+      createComponent();
 
-    const approvers = findAddApprovers();
+      const approvers = findAddApprovers();
 
-    expect(approvers.props()).toMatchObject({
-      disabled: false,
+      expect(approvers.props()).toMatchObject({
+        disabled: false,
+      });
     });
-  });
 
-  it('should make a request when submit is clicked', async () => {
-    createComponent();
+    it('should make a request when submit is clicked', async () => {
+      createComponent();
+
+      jest.spyOn(Api, 'createProtectedEnvironment');
+      const deployAccessLevels = [{ user_id: 1 }];
+      const name = 'production';
+      const requiredApprovalCount = '3';
 
-    jest.spyOn(Api, 'createProtectedEnvironment');
-    const deployAccessLevels = [{ user_id: 1 }];
-    const name = 'production';
-    const requiredApprovalCount = '3';
+      await submitForm(deployAccessLevels, name, requiredApprovalCount);
 
-    await submitForm(deployAccessLevels, name, requiredApprovalCount);
+      expect(Api.createProtectedEnvironment).toHaveBeenCalledWith(PROJECT_ID, 'projects', {
+        deploy_access_levels: deployAccessLevels,
+        approval_rules: [{ user_id: 1, required_approvals: requiredApprovalCount }],
+        name,
+      });
+    });
 
-    expect(Api.createProtectedEnvironment).toHaveBeenCalledWith(PROJECT_ID, {
-      deploy_access_levels: deployAccessLevels,
-      approval_rules: [{ user_id: 1, required_approvals: requiredApprovalCount }],
-      name,
+    describe('on failed protected environment', () => {
+      it.each([
+        {
+          statusCode: HTTP_STATUS_BAD_REQUEST,
+          expectedMessage: 'Failed to protect the environment.',
+        },
+        {
+          statusCode: HTTP_STATUS_BAD_REQUEST,
+          responseData: { message: 'Serverside message' },
+          expectedMessage: 'Failed to protect the environment. Serverside message',
+        },
+      ])(
+        'should show a correct error message',
+        async ({ statusCode, expectedMessage, responseData }) => {
+          mockAxios.onPost().replyOnce(statusCode, responseData);
+          createComponent();
+          await submitForm();
+          await waitForPromises();
+
+          expect(findAlert().text()).toBe(expectedMessage);
+        },
+      );
     });
   });
 
-  describe('on failed protected environment', () => {
-    it.each([
-      {
-        statusCode: HTTP_STATUS_BAD_REQUEST,
-        expectedMessage: 'Failed to protect the environment.',
-      },
-      {
-        statusCode: HTTP_STATUS_BAD_REQUEST,
-        responseData: { message: 'Serverside message' },
-        expectedMessage: 'Failed to protect the environment. Serverside message',
-      },
-    ])(
-      'should show a correct error message',
-      async ({ statusCode, expectedMessage, responseData }) => {
-        mockAxios.onPost().replyOnce(statusCode, responseData);
-        createComponent();
-        await submitForm();
-        await waitForPromises();
-
-        expect(findAlert().text()).toBe(expectedMessage);
-      },
-    );
+  describe('on group level', () => {
+    beforeEach(() => {
+      createComponent({ entityType: 'groups' });
+    });
+
+    it('renders AccessDropdown and passes down the props', () => {
+      const dropdown = findGroupsAccessDropdown();
+
+      expect(dropdown.props()).toMatchObject({
+        accessLevelsData,
+        label: __('Select users'),
+      });
+    });
+
+    it('renders environment tier selector', () => {
+      expect(findEnvironmentsListbox().props('toggleText')).toBe('Select environment tier');
+      expect(findEnvironmentsListbox().props('items')).toEqual([
+        { value: 'production', text: 'production' },
+        { value: 'staging', text: 'staging' },
+      ]);
+    });
+
+    it('renders a dropdown for selecting approvers', () => {
+      const approvers = findAddApprovers();
+
+      expect(approvers.props()).toMatchObject({
+        disabled: false,
+      });
+    });
+
+    it('should make a request when submit is clicked', async () => {
+      jest.spyOn(Api, 'createProtectedEnvironment');
+      const deployAccessLevels = [{ group_id: 1 }];
+      const name = 'production';
+      const requiredApprovalCount = '3';
+
+      await submitGroupsForm(deployAccessLevels, name, requiredApprovalCount);
+
+      expect(Api.createProtectedEnvironment).toHaveBeenCalledWith(PROJECT_ID, 'groups', {
+        deploy_access_levels: deployAccessLevels,
+        approval_rules: [{ group_id: 1, required_approvals: requiredApprovalCount }],
+        name,
+      });
+    });
   });
 });
diff --git a/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js b/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js
index eebcfed8f2884d3d0913d59e00e771f210169a19..125204fce5de8db10ae268f9c36714b893c84166 100644
--- a/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js
+++ b/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js
@@ -8,8 +8,8 @@ import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 import { TEST_HOST } from 'helpers/test_constants';
 import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
 import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
+import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import { createStore } from 'ee/protected_environments/store/edit';
 import AddRuleModal from 'ee/protected_environments/add_rule_modal.vue';
 import AddApprovers from 'ee/protected_environments/add_approvers.vue';
@@ -104,8 +104,8 @@ describe('ee/protected_environments/edit_protected_environments_list.vue', () =>
   let wrapper;
   let mock;
 
-  const createComponent = async () => {
-    store = createStore({ projectId: DEFAULT_PROJECT_ID });
+  const createComponent = async ({ entityType = 'projects' } = {}) => {
+    store = createStore({ entityId: DEFAULT_PROJECT_ID, entityType });
 
     wrapper = mountExtended(EditProtectedEnvironmentsList, {
       store,
@@ -113,372 +113,530 @@ describe('ee/protected_environments/edit_protected_environments_list.vue', () =>
         accessLevelsData: DEFAULT_ACCESS_LEVELS_DATA,
         apiLink: API_LINK,
         docsLink: DOCS_LINK,
+        entityType,
       },
     });
 
     await waitForPromises();
   };
 
-  const findDeployerDeleteButton = () =>
-    wrapper.findByTitle(s__('ProtectedEnvironments|Delete deployer rule'));
-  const findApproverDeleteButton = () =>
-    wrapper.findByTitle(s__('ProtectedEnvironments|Delete approver rule'));
-  const findApproverEditButton = (w = wrapper) =>
-    w.findByRole('button', { name: s__('ProtectedEnvironments|Edit') });
+  const findDeployerDeleteButton = () => wrapper.findByTitle('Delete deployer rule');
+  const findApproverDeleteButton = () => wrapper.findByTitle('Delete approver rule');
+  const findApproverEditButton = (w = wrapper) => w.findByRole('button', { name: 'Edit' });
   const findInheritanceToggle = (w = wrapper) => w.findComponent(GlToggle);
-  const findApproverSaveButton = () =>
-    wrapper.findByRole('button', { name: s__('ProtectedEnvironments|Save') });
+  const findApproverSaveButton = () => wrapper.findByRole('button', { name: 'Save' });
   const findApprovalsInput = () =>
-    wrapper.findByRole('textbox', { name: s__('ProtectedEnvironments|Required approval count') });
+    wrapper.findByRole('textbox', { name: 'Required approval count' });
   const findProtectedEnvironments = () => wrapper.findComponent(ProtectedEnvironments);
   const findItemToggleButton = () => wrapper.findByTestId('protected-environment-item-toggle');
+  const findAddRuleModal = () => wrapper.findComponent(AddRuleModal);
+  const findAddApprovers = () => wrapper.findComponent(AddApprovers);
+
+  describe('on the project level', () => {
+    beforeEach(() => {
+      mock = new MockAdapter(axios);
+      window.gon = { api_version: 'v4' };
+      mock
+        .onGet('/api/v4/projects/8/protected_environments/')
+        .reply(HTTP_STATUS_OK, DEFAULT_ENVIRONMENTS);
+      mock
+        .onGet('/api/v4/groups/1/members/all')
+        .reply(HTTP_STATUS_OK, [{ name: 'root', avatar_url: '/avatar.png' }]);
+      mock
+        .onGet('/api/v4/users/1')
+        .reply(HTTP_STATUS_OK, { name: 'root', avatar_url: '/avatar.png' });
+      mock.onGet('/api/v4/projects/8/members').reply(HTTP_STATUS_OK, [
+        {
+          name: 'root',
+          access_level: MAINTAINER_ACCESS_LEVEL.toString(),
+          avatar_url: '/avatar.png',
+        },
+      ]);
+    });
 
-  beforeEach(() => {
-    mock = new MockAdapter(axios);
-    window.gon = { api_version: 'v4' };
-    mock
-      .onGet('/api/v4/projects/8/protected_environments/')
-      .reply(HTTP_STATUS_OK, DEFAULT_ENVIRONMENTS);
-    mock
-      .onGet('/api/v4/groups/1/members/all')
-      .reply(HTTP_STATUS_OK, [{ name: 'root', avatar_url: '/avatar.png' }]);
-    mock
-      .onGet('/api/v4/users/1')
-      .reply(HTTP_STATUS_OK, { name: 'root', avatar_url: '/avatar.png' });
-    mock.onGet('/api/v4/projects/8/members').reply(HTTP_STATUS_OK, [
-      {
-        name: 'root',
-        access_level: MAINTAINER_ACCESS_LEVEL.toString(),
-        avatar_url: '/avatar.png',
-      },
-    ]);
-  });
-
-  afterEach(() => {
-    mock.restore();
-    mock.resetHistory();
-  });
+    afterEach(() => {
+      mock.restore();
+      mock.resetHistory();
+    });
 
-  it('shows a header for the protected environment', async () => {
-    await createComponent();
+    it('shows a header for the protected environment', async () => {
+      await createComponent();
 
-    expect(wrapper.findByRole('button', { name: 'staging' }).exists()).toBe(true);
-  });
+      expect(wrapper.findByRole('button', { name: 'staging' }).exists()).toBe(true);
+    });
 
-  it('shows member avatars in each row', async () => {
-    await createComponent();
+    it('shows member avatars in each row', async () => {
+      await createComponent();
 
-    const avatars = wrapper.findAllComponents(GlAvatar).wrappers;
+      const avatars = wrapper.findAllComponents(GlAvatar).wrappers;
 
-    expect(avatars).toHaveLength(6);
-    avatars.forEach((avatar) => expect(avatar.props('src')).toBe('/avatar.png'));
-  });
+      expect(avatars).toHaveLength(6);
+      avatars.forEach((avatar) => expect(avatar.props('src')).toBe('/avatar.png'));
+    });
 
-  it('shows the description of the rule', async () => {
-    const [{ deploy_access_levels: deployAccessLevels, approval_rules: approvalRules }] =
-      DEFAULT_ENVIRONMENTS;
+    it('shows the description of the rule', async () => {
+      const [{ deploy_access_levels: deployAccessLevels, approval_rules: approvalRules }] =
+        DEFAULT_ENVIRONMENTS;
 
-    const ruleDescriptions = [
-      ...deployAccessLevels.map((d) => d.access_level_description),
-      ...approvalRules.map((a) => a.access_level_description),
-    ];
+      const ruleDescriptions = [
+        ...deployAccessLevels.map((d) => d.access_level_description),
+        ...approvalRules.map((a) => a.access_level_description),
+      ];
 
-    await createComponent();
+      await createComponent();
 
-    const descriptions = wrapper.findAllByTestId('rule-description').wrappers;
+      const descriptions = wrapper.findAllByTestId('rule-description').wrappers;
 
-    descriptions.forEach((description, i) => {
-      expect(description.text()).toBe(ruleDescriptions[i]);
+      descriptions.forEach((description, i) => {
+        expect(description.text()).toBe(ruleDescriptions[i]);
+      });
     });
-  });
 
-  describe('add deployer rule', () => {
-    let environment;
-    let dropdown;
-    let modal;
+    describe('add deployer rule', () => {
+      let environment;
+      let dropdown;
 
-    beforeEach(async () => {
-      [environment] = DEFAULT_ENVIRONMENTS;
+      beforeEach(async () => {
+        [environment] = DEFAULT_ENVIRONMENTS;
 
-      await createComponent();
+        await createComponent();
 
-      wrapper
-        .findComponent(EditProtectedEnvironmentRulesCard)
-        .vm.$emit('addRule', { environment, ruleKey: DEPLOYER_RULE_KEY });
+        wrapper
+          .findComponent(EditProtectedEnvironmentRulesCard)
+          .vm.$emit('addRule', { environment, ruleKey: DEPLOYER_RULE_KEY });
 
-      await nextTick();
+        await nextTick();
 
-      dropdown = wrapper.findComponent(AccessDropdown);
-      modal = wrapper.findComponent(AddRuleModal);
-    });
+        dropdown = wrapper.findComponent(AccessDropdown);
+      });
 
-    it('titles the modal appropriately', () => {
-      expect(modal.props('title')).toBe(s__('ProtectedEnvironments|Create deployment rule'));
-    });
+      it('titles the modal appropriately', () => {
+        expect(findAddRuleModal().props('title')).toBe('Create deployment rule');
+      });
 
-    it('puts the access level dropdown into the modal form', () => {
-      expect(dropdown.exists()).toBe(true);
-    });
+      it('puts the access level dropdown into the modal form', () => {
+        expect(dropdown.exists()).toBe(true);
+      });
 
-    it('sends new rules to be added', async () => {
-      mock.onPut().reply(HTTP_STATUS_OK);
+      it('sends new rules to be added', async () => {
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      const rule = [{ user_id: 5 }];
-      dropdown.vm.$emit('hidden', rule);
+        const rule = [{ user_id: 5 }];
+        dropdown.vm.$emit('hidden', rule);
 
-      modal.vm.$emit('saveRule');
+        findAddRuleModal().vm.$emit('saveRule');
 
-      await waitForPromises();
+        await waitForPromises();
 
-      expect(mock.history.put).toHaveLength(1);
+        expect(mock.history.put).toHaveLength(1);
 
-      const [{ data }] = mock.history.put;
-      expect(JSON.parse(data)).toMatchObject({ ...environment, deploy_access_levels: rule });
+        const [{ data }] = mock.history.put;
+        expect(JSON.parse(data)).toMatchObject({ ...environment, deploy_access_levels: rule });
+      });
     });
-  });
 
-  describe('deployer delete rule', () => {
-    it('sends the deleted rule with _destroy set', async () => {
-      const [environment] = DEFAULT_ENVIRONMENTS;
+    describe('deployer delete rule', () => {
+      it('sends the deleted rule with _destroy set', async () => {
+        const [environment] = DEFAULT_ENVIRONMENTS;
 
-      await createComponent();
+        await createComponent();
 
-      findItemToggleButton().vm.$emit('click');
+        findItemToggleButton().vm.$emit('click');
 
-      const button = findDeployerDeleteButton();
+        const button = findDeployerDeleteButton();
 
-      mock.onPut().reply(HTTP_STATUS_OK);
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      const destroyedRule = {
-        access_level: DEVELOPER_ACCESS_LEVEL,
-        access_level_description: 'Deployers + Maintainers',
-        _destroy: true,
-      };
+        const destroyedRule = {
+          access_level: DEVELOPER_ACCESS_LEVEL,
+          access_level_description: 'Deployers + Maintainers',
+          _destroy: true,
+        };
 
-      button.trigger('click');
+        button.trigger('click');
 
-      await waitForPromises();
+        await waitForPromises();
 
-      expect(mock.history.put).toHaveLength(1);
+        expect(mock.history.put).toHaveLength(1);
 
-      const [{ data }] = mock.history.put;
-      expect(JSON.parse(data)).toMatchObject({
-        name: environment.name,
-        deploy_access_levels: [destroyedRule],
+        const [{ data }] = mock.history.put;
+        expect(JSON.parse(data)).toMatchObject({
+          name: environment.name,
+          deploy_access_levels: [destroyedRule],
+        });
       });
-    });
 
-    it('hides the button if there is only one rule', async () => {
-      const [environment] = DEFAULT_ENVIRONMENTS;
-      const [rule] = environment.deploy_access_levels;
-      mock.onGet('/api/v4/projects/8/protected_environments/').reply(HTTP_STATUS_OK, [
-        {
-          ...environment,
-          deploy_access_levels: [rule],
-        },
-      ]);
+      it('hides the button if there is only one rule', async () => {
+        const [environment] = DEFAULT_ENVIRONMENTS;
+        const [rule] = environment.deploy_access_levels;
+        mock.onGet('/api/v4/projects/8/protected_environments/').reply(HTTP_STATUS_OK, [
+          {
+            ...environment,
+            deploy_access_levels: [rule],
+          },
+        ]);
 
-      await createComponent();
+        await createComponent();
 
-      findItemToggleButton().vm.$emit('click');
+        findItemToggleButton().vm.$emit('click');
 
-      const button = findDeployerDeleteButton();
+        const button = findDeployerDeleteButton();
 
-      expect(button.exists()).toBe(false);
+        expect(button.exists()).toBe(false);
+      });
     });
-  });
 
-  describe('add approval rule', () => {
-    let environment;
-    let addApprover;
-    let modal;
+    describe('add approval rule', () => {
+      let environment;
 
-    beforeEach(async () => {
-      [environment] = DEFAULT_ENVIRONMENTS;
+      beforeEach(async () => {
+        [environment] = DEFAULT_ENVIRONMENTS;
 
-      await createComponent();
+        await createComponent();
 
-      wrapper
-        .findComponent(EditProtectedEnvironmentRulesCard)
-        .vm.$emit('addRule', { environment, ruleKey: APPROVER_RULE_KEY });
+        wrapper
+          .findComponent(EditProtectedEnvironmentRulesCard)
+          .vm.$emit('addRule', { environment, ruleKey: APPROVER_RULE_KEY });
 
-      await nextTick();
+        await nextTick();
+      });
 
-      addApprover = wrapper.findComponent(AddApprovers);
-      modal = wrapper.findComponent(AddRuleModal);
-    });
+      it('titles the modal appropriately', () => {
+        expect(findAddRuleModal().props('title')).toBe('Create approval rule');
+      });
 
-    it('titles the modal appropriately', () => {
-      expect(modal.props('title')).toBe(s__('ProtectedEnvironments|Create approval rule'));
-    });
+      it('puts the access level dropdown into the modal form', () => {
+        expect(findAddApprovers().exists()).toBe(true);
+      });
+
+      it('sends new rules to be added', async () => {
+        mock.onPut().reply(HTTP_STATUS_OK);
+
+        const rule = [{ user_id: 5, required_approvals: 3 }];
+        findAddApprovers().vm.$emit('change', rule);
+
+        findAddRuleModal().vm.$emit('saveRule');
+
+        await waitForPromises();
 
-    it('puts the access level dropdown into the modal form', () => {
-      expect(addApprover.exists()).toBe(true);
+        expect(mock.history.put).toHaveLength(1);
+
+        const [{ data }] = mock.history.put;
+        expect(JSON.parse(data)).toMatchObject({ ...environment, approval_rules: rule });
+      });
     });
 
-    it('sends new rules to be added', async () => {
-      mock.onPut().reply(HTTP_STATUS_OK);
+    describe('approver delete rule', () => {
+      it('sends the deleted rule with _destroy set', async () => {
+        const [environment] = DEFAULT_ENVIRONMENTS;
+
+        await createComponent();
+
+        wrapper.findComponent(GlButton).vm.$emit('click');
 
-      const rule = [{ user_id: 5, required_approvals: 3 }];
-      addApprover.vm.$emit('change', rule);
+        const button = findApproverDeleteButton();
 
-      modal.vm.$emit('saveRule');
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      await waitForPromises();
+        const destroyedRule = {
+          access_level: DEVELOPER_ACCESS_LEVEL,
+          access_level_description: 'Deployers + Maintainers',
+          _destroy: true,
+        };
 
-      expect(mock.history.put).toHaveLength(1);
+        button.trigger('click');
 
-      const [{ data }] = mock.history.put;
-      expect(JSON.parse(data)).toMatchObject({ ...environment, approval_rules: rule });
+        await waitForPromises();
+
+        expect(mock.history.put).toHaveLength(1);
+
+        const [{ data }] = mock.history.put;
+        expect(JSON.parse(data)).toMatchObject({
+          name: environment.name,
+          approval_rules: [destroyedRule],
+        });
+      });
     });
-  });
 
-  describe('approver delete rule', () => {
-    it('sends the deleted rule with _destroy set', async () => {
-      const [environment] = DEFAULT_ENVIRONMENTS;
+    describe('approver edit rule', () => {
+      let environment;
 
-      await createComponent();
+      beforeEach(async () => {
+        [environment] = DEFAULT_ENVIRONMENTS;
+        await createComponent();
 
-      wrapper.findComponent(GlButton).vm.$emit('click');
+        findItemToggleButton().vm.$emit('click');
 
-      const button = findApproverDeleteButton();
+        await nextTick();
+      });
 
-      mock.onPut().reply(HTTP_STATUS_OK);
+      it('allows editing of an approval rule', async () => {
+        const [rule] = environment.approval_rules;
+        const value = '2';
 
-      const destroyedRule = {
-        access_level: DEVELOPER_ACCESS_LEVEL,
-        access_level_description: 'Deployers + Maintainers',
-        _destroy: true,
-      };
+        mock.onPut().reply(HTTP_STATUS_OK);
+
+        const button = findApproverEditButton();
+
+        await button.trigger('click');
+
+        const input = findApprovalsInput();
+
+        expect(input.exists()).toBe(true);
+
+        await input.setValue(value);
+
+        findApproverSaveButton().trigger('click');
+
+        await waitForPromises();
+
+        expect(mock.history.put.length).toBe(1);
+        const [{ data }] = mock.history.put;
+        expect(JSON.parse(data)).toMatchObject({
+          name: environment.name,
+          approval_rules: [
+            {
+              id: rule.id,
+              access_level: rule.access_level,
+              access_level_description: rule.access_level_description,
+              required_approvals: value,
+            },
+          ],
+        });
+      });
+
+      it('shows a toggle for group ID rules', async () => {
+        const [, rule] = environment.approval_rules;
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      button.trigger('click');
+        const row = wrapper.findByTestId(`approval_rules-${rule.id}`);
+        const button = findApproverEditButton(extendedWrapper(row));
 
-      await waitForPromises();
+        expect(findInheritanceToggle(row).props('value')).toBe(true);
+        expect(findInheritanceToggle(row).props('disabled')).toBe(true);
 
-      expect(mock.history.put).toHaveLength(1);
+        await button.trigger('click');
 
-      const [{ data }] = mock.history.put;
-      expect(JSON.parse(data)).toMatchObject({
-        name: environment.name,
-        approval_rules: [destroyedRule],
+        expect(findInheritanceToggle(row).props('value')).toBe(true);
+        expect(findInheritanceToggle(row).props('disabled')).toBe(false);
+
+        await findInheritanceToggle(row).vm.$emit('change', false);
+
+        findApproverSaveButton().trigger('click');
+
+        await waitForPromises();
+
+        expect(mock.history.put.length).toBe(1);
+        const [{ data }] = mock.history.put;
+        expect(JSON.parse(data)).toMatchObject({
+          name: environment.name,
+          approval_rules: [
+            {
+              id: rule.id,
+              group_id: rule.group_id,
+              access_level_description: rule.access_level_description,
+              group_inheritance_type: 0,
+            },
+          ],
+        });
+      });
+
+      it('hides the toggle for non-group rules', () => {
+        const { id } = environment.approval_rules.find(({ user_id: userId }) => userId);
+        const row = wrapper.findByTestId(`approval_rules-${id}`);
+
+        expect(findInheritanceToggle(row).exists()).toBe(false);
+      });
+
+      it('hides the edit button for user rules', () => {
+        const { id } = environment.approval_rules.find(({ user_id: userId }) => userId);
+        const row = wrapper.findByTestId(`approval_rules-${id}`);
+        const button = findApproverEditButton(extendedWrapper(row));
+
+        expect(button.exists()).toBe(false);
+      });
+    });
+
+    describe('unprotect environment', () => {
+      it('unprotects an environment when emitted', async () => {
+        const [environment] = DEFAULT_ENVIRONMENTS;
+
+        mock.onDelete().reply(HTTP_STATUS_OK);
+
+        await createComponent();
+
+        findProtectedEnvironments().vm.$emit('unprotect', environment);
+        await waitForPromises();
+
+        expect(mock.history.delete).toHaveLength(1);
+
+        const [{ url }] = mock.history.delete;
+        expect(url).toBe(`/api/v4/projects/8/protected_environments/${environment.name}`);
       });
     });
   });
 
-  describe('approver edit rule', () => {
-    let environment;
+  describe('on the group level', () => {
+    const [environment] = DEFAULT_ENVIRONMENTS;
 
     beforeEach(async () => {
-      [environment] = DEFAULT_ENVIRONMENTS;
-      await createComponent();
+      mock = new MockAdapter(axios);
+      window.gon = { api_version: 'v4' };
+      mock
+        .onGet('/api/v4/groups/8/protected_environments/')
+        .reply(HTTP_STATUS_OK, DEFAULT_ENVIRONMENTS);
+      mock
+        .onGet('/api/v4/groups/1/members/all')
+        .reply(HTTP_STATUS_OK, [{ name: 'root', avatar_url: '/avatar.png' }]);
+      mock
+        .onGet('/api/v4/users/1')
+        .reply(HTTP_STATUS_OK, { name: 'root', avatar_url: '/avatar.png' });
+      mock.onGet('/api/v4/groups/8/members').reply(HTTP_STATUS_OK, [
+        {
+          name: 'root',
+          access_level: MAINTAINER_ACCESS_LEVEL.toString(),
+          avatar_url: '/avatar.png',
+        },
+      ]);
 
-      findItemToggleButton().vm.$emit('click');
+      await createComponent({ entityType: 'groups' });
+    });
 
-      await nextTick();
+    afterEach(() => {
+      mock.restore();
+      mock.resetHistory();
     });
 
-    it('allows editing of an approval rule', async () => {
-      const [rule] = environment.approval_rules;
-      const value = '2';
+    it('requests the protected environments for the group', () => {
+      expect(mock.history.get[0].url).toBe('/api/v4/groups/8/protected_environments/');
+    });
 
-      mock.onPut().reply(HTTP_STATUS_OK);
+    describe('add deployer rule', () => {
+      let dropdown;
 
-      const button = findApproverEditButton();
+      beforeEach(async () => {
+        wrapper
+          .findComponent(EditProtectedEnvironmentRulesCard)
+          .vm.$emit('addRule', { environment, ruleKey: DEPLOYER_RULE_KEY });
 
-      await button.trigger('click');
+        await nextTick();
 
-      const input = findApprovalsInput();
+        dropdown = wrapper.findComponent(GroupsAccessDropdown);
+      });
 
-      expect(input.exists()).toBe(true);
+      it('renders the group access level dropdown in the modal form', () => {
+        expect(dropdown.props()).toMatchObject({
+          label: 'Select users',
+          accessLevelsData: DEFAULT_ACCESS_LEVELS_DATA,
+        });
+      });
 
-      await input.setValue(value);
+      it('sends new rules to the groups endpoint', async () => {
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      findApproverSaveButton().trigger('click');
+        const rule = [{ user_id: 5 }];
+        dropdown.vm.$emit('hidden', rule);
 
-      await waitForPromises();
+        findAddRuleModal().vm.$emit('saveRule');
 
-      expect(mock.history.put.length).toBe(1);
-      const [{ data }] = mock.history.put;
-      expect(JSON.parse(data)).toMatchObject({
-        name: environment.name,
-        approval_rules: [
-          {
-            id: rule.id,
-            access_level: rule.access_level,
-            access_level_description: rule.access_level_description,
-            required_approvals: value,
-          },
-        ],
+        await waitForPromises();
+
+        expect(mock.history.put).toHaveLength(1);
+        expect(mock.history.put[0].url).toBe(
+          `/api/v4/groups/8/protected_environments/${environment.name}`,
+        );
       });
     });
 
-    it('shows a toggle for group ID rules', async () => {
-      const [, rule] = environment.approval_rules;
-      mock.onPut().reply(HTTP_STATUS_OK);
+    describe('deployer delete rule', () => {
+      it('sends the deleted rule to the groups endpoint', async () => {
+        findItemToggleButton().vm.$emit('click');
 
-      const row = wrapper.findByTestId(`approval_rules-${rule.id}`);
-      const button = findApproverEditButton(extendedWrapper(row));
+        const button = findDeployerDeleteButton();
 
-      expect(findInheritanceToggle(row).props('value')).toBe(true);
-      expect(findInheritanceToggle(row).props('disabled')).toBe(true);
+        mock.onPut().reply(HTTP_STATUS_OK);
+        button.trigger('click');
 
-      await button.trigger('click');
+        await waitForPromises();
 
-      expect(findInheritanceToggle(row).props('value')).toBe(true);
-      expect(findInheritanceToggle(row).props('disabled')).toBe(false);
+        expect(mock.history.put).toHaveLength(1);
+        expect(mock.history.put[0].url).toBe(
+          `/api/v4/groups/8/protected_environments/${environment.name}`,
+        );
+      });
+    });
 
-      await findInheritanceToggle(row).vm.$emit('change', false);
+    describe('add approval rule', () => {
+      it('sends new rules to the groups endpoint', async () => {
+        mock.onPut().reply(HTTP_STATUS_OK);
+        wrapper
+          .findComponent(EditProtectedEnvironmentRulesCard)
+          .vm.$emit('addRule', { environment, ruleKey: APPROVER_RULE_KEY });
 
-      findApproverSaveButton().trigger('click');
+        await nextTick();
 
-      await waitForPromises();
+        const rule = [{ user_id: 5, required_approvals: 3 }];
+        findAddApprovers().vm.$emit('change', rule);
 
-      expect(mock.history.put.length).toBe(1);
-      const [{ data }] = mock.history.put;
-      expect(JSON.parse(data)).toMatchObject({
-        name: environment.name,
-        approval_rules: [
-          {
-            id: rule.id,
-            group_id: rule.group_id,
-            access_level_description: rule.access_level_description,
-            group_inheritance_type: 0,
-          },
-        ],
+        findAddRuleModal().vm.$emit('saveRule');
+
+        await waitForPromises();
+
+        expect(mock.history.put).toHaveLength(1);
+        expect(mock.history.put[0].url).toBe(
+          `/api/v4/groups/8/protected_environments/${environment.name}`,
+        );
       });
     });
 
-    it('hides the toggle for non-group rules', () => {
-      const { id } = environment.approval_rules.find(({ user_id: userId }) => userId);
-      const row = wrapper.findByTestId(`approval_rules-${id}`);
+    describe('approver delete rule', () => {
+      it('sends the deleted rule to the groups endpoint', async () => {
+        wrapper.findComponent(GlButton).vm.$emit('click');
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      expect(findInheritanceToggle(row).exists()).toBe(false);
-    });
+        findApproverDeleteButton().trigger('click');
 
-    it('hides the edit button for user rules', () => {
-      const { id } = environment.approval_rules.find(({ user_id: userId }) => userId);
-      const row = wrapper.findByTestId(`approval_rules-${id}`);
-      const button = findApproverEditButton(extendedWrapper(row));
+        await waitForPromises();
 
-      expect(button.exists()).toBe(false);
+        expect(mock.history.put).toHaveLength(1);
+        expect(mock.history.put[0].url).toBe(
+          `/api/v4/groups/8/protected_environments/${environment.name}`,
+        );
+      });
     });
-  });
 
-  describe('unprotect environment', () => {
-    it('unprotects an environment when emitted', async () => {
-      const [environment] = DEFAULT_ENVIRONMENTS;
+    describe('approver edit rule', () => {
+      it('sends the editing request to the groups endpoint', async () => {
+        mock.onPut().reply(HTTP_STATUS_OK);
 
-      mock.onDelete().reply(HTTP_STATUS_OK);
+        findItemToggleButton().vm.$emit('click');
+        await nextTick();
+        await findApproverEditButton().trigger('click');
+        await findApprovalsInput().setValue('2');
 
-      await createComponent();
+        findApproverSaveButton().trigger('click');
 
-      findProtectedEnvironments().vm.$emit('unprotect', environment);
-      await waitForPromises();
+        await waitForPromises();
 
-      expect(mock.history.delete).toHaveLength(1);
+        expect(mock.history.put.length).toBe(1);
+        expect(mock.history.put[0].url).toBe(
+          `/api/v4/groups/8/protected_environments/${environment.name}`,
+        );
+      });
+    });
 
-      const [{ url }] = mock.history.delete;
-      expect(url).toBe(`/api/v4/projects/8/protected_environments/${environment.name}`);
+    describe('unprotect environment', () => {
+      it('sends unprotect request to the groups endpoint', async () => {
+        mock.onDelete().reply(HTTP_STATUS_OK);
+
+        findProtectedEnvironments().vm.$emit('unprotect', environment);
+        await waitForPromises();
+
+        expect(mock.history.delete).toHaveLength(1);
+        expect(mock.history.delete[0].url).toBe(
+          `/api/v4/groups/8/protected_environments/${environment.name}`,
+        );
+      });
     });
   });
 });
diff --git a/ee/spec/frontend/protected_environments/protected_environments_spec.js b/ee/spec/frontend/protected_environments/protected_environments_spec.js
index 7a1ae6312921efa9052729d5eba9dd35360db2aa..a9be2521b0448e588bfa5834667e2810b90a62ce 100644
--- a/ee/spec/frontend/protected_environments/protected_environments_spec.js
+++ b/ee/spec/frontend/protected_environments/protected_environments_spec.js
@@ -65,9 +65,9 @@ describe('ee/protected_environments/protected_environments.vue', () => {
   const setPageMock = jest.fn(() => Promise.resolve());
   const fetchProtectedEnvironmentsMock = jest.fn(() => Promise.resolve());
 
-  const createStore = ({ pageInfo } = {}) => {
+  const createStore = ({ pageInfo, entityType } = {}) => {
     return new Vuex.Store({
-      state: { ...state, pageInfo },
+      state: { ...state, pageInfo, entityType },
       actions: {
         setPage: setPageMock,
         fetchProtectedEnvironments: fetchProtectedEnvironmentsMock,
@@ -78,9 +78,10 @@ describe('ee/protected_environments/protected_environments.vue', () => {
   const createComponent = ({
     environments = DEFAULT_ENVIRONMENTS,
     pageInfo = DEFAULT_PAGE_INFO,
+    entityType = 'projects',
   } = {}) => {
     wrapper = mountExtended(ProtectedEnvironments, {
-      store: createStore({ pageInfo }),
+      store: createStore({ pageInfo, entityType }),
       propsData: {
         environments,
       },
@@ -90,6 +91,7 @@ describe('ee/protected_environments/protected_environments.vue', () => {
       provide: {
         apiLink: '',
         docsLink: '',
+        entityType,
       },
       // Stub access dropdown since it triggers some requests that are out-of-scope here
       stubs: ['AccessDropdown', 'CreateProtectedEnvironment'],
diff --git a/ee/spec/frontend/protected_environments/store/edit/actions_spec.js b/ee/spec/frontend/protected_environments/store/edit/actions_spec.js
index 93774b8f5ea012c82b647ab8574bed5a80ca636e..6d1d00286f1c3ea08ef881262aa270f3a3def250 100644
--- a/ee/spec/frontend/protected_environments/store/edit/actions_spec.js
+++ b/ee/spec/frontend/protected_environments/store/edit/actions_spec.js
@@ -31,7 +31,7 @@ describe('ee/protected_environments/store/edit/actions', () => {
   let mock;
 
   beforeEach(() => {
-    mockedState = state({ projectId: '8' });
+    mockedState = state({ entityId: '8', entityType: 'projects' });
     mock = new MockAdapter(axios);
     window.gon = { api_version: 'v4' };
   });
@@ -153,7 +153,7 @@ describe('ee/protected_environments/store/edit/actions', () => {
       type                                | rule                                                                                                     | url                               | response
       ${'group with integer inheritance'} | ${{ group_id: 1, user_id: null, access_level: null, group_inheritance_type: 1 }}                         | ${'/api/v4/groups/1/members/all'} | ${[{ name: 'root' }]}
       ${'group without inheritance'}      | ${{ group_id: 1, user_id: null, access_level: null, group_inheritance_type: 0 }}                         | ${'/api/v4/groups/1/members'}     | ${[{ name: 'root' }]}
-      ${'user'}                           | ${{ group_id: null, user_id: 1, access_level: null, group_ineritance_type: null }}                       | ${'/api/v4/users/1'}              | ${{ name: 'root' }}
+      ${'user'}                           | ${{ group_id: null, user_id: 1, access_level: null, group_inheritance_type: null }}                      | ${'/api/v4/users/1'}              | ${{ name: 'root' }}
       ${'access level'}                   | ${{ group_id: null, user_id: null, access_level: MAINTAINER_ACCESS_LEVEL, group_inheritance_type: '0' }} | ${'/api/v4/projects/8/members'}   | ${[{ name: 'root', access_level: MAINTAINER_ACCESS_LEVEL.toString() }]}
     `(
       'successfully fetches members for a given deploy access rule of type $type',
@@ -232,7 +232,7 @@ describe('ee/protected_environments/store/edit/actions', () => {
     it.each`
       type              | rule                                                                                                            | updatedRule
       ${'group'}        | ${{ id: 1, group_id: 1, user_id: null, access_level: null, group_inheritance_type: '1' }}                       | ${{ id: 1, group_id: 1, group_inheritance_type: '1', _destroy: true }}
-      ${'user'}         | ${{ id: 1, group_id: null, user_id: 1, access_level: null, group_ineritance_type: null }}                       | ${{ id: 1, user_id: 1, _destroy: true }}
+      ${'user'}         | ${{ id: 1, group_id: null, user_id: 1, access_level: null, group_inheritance_type: null }}                      | ${{ id: 1, user_id: 1, _destroy: true }}
       ${'access level'} | ${{ id: 1, group_id: null, user_id: null, access_level: MAINTAINER_ACCESS_LEVEL, group_inheritance_type: '0' }} | ${{ id: 1, access_level: MAINTAINER_ACCESS_LEVEL, group_inheritance_type: '0', _destroy: true }}
     `('marks a rule for deletion of type $type', ({ rule, updatedRule }) => {
       return testAction(
diff --git a/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js b/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js
index 988890d49ebad3819bf6a3663450d582b31c4638..4f3e724ab1125654193ece3ff5085e49525b8255 100644
--- a/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js
+++ b/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js
@@ -6,7 +6,7 @@ describe('ee/protected_environments/store/edit/mutations', () => {
   let mockedState;
 
   beforeEach(() => {
-    mockedState = state({ projectId: '8' });
+    mockedState = state({ entityId: '8', entityType: 'projects' });
   });
 
   describe(types.REQUEST_PROTECTED_ENVIRONMENTS, () => {
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 62765d3c93a67290d53b8dbee7b9df96225d23b1..f2b667ced1809468059a8e1faa7b600d00f9347e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -21953,10 +21953,10 @@ msgstr ""
 msgid "Failed to load group activity metrics. Please try again."
 msgstr ""
 
-msgid "Failed to load groups, users and deploy keys."
+msgid "Failed to load groups and users."
 msgstr ""
 
-msgid "Failed to load groups."
+msgid "Failed to load groups, users and deploy keys."
 msgstr ""
 
 msgid "Failed to load labels. Please try again."
@@ -42856,9 +42856,15 @@ msgstr ""
 msgid "ProtectedEnvironments|Select users"
 msgstr ""
 
+msgid "ProtectedEnvironments|Set which groups, access levels, or users are required to approve in this environment tier."
+msgstr ""
+
 msgid "ProtectedEnvironments|Set which groups, access levels, or users are required to approve. Groups and users must be members of the project."
 msgstr ""
 
+msgid "ProtectedEnvironments|Set which groups, access levels, or users can deploy in this environment tier."
+msgstr ""
+
 msgid "ProtectedEnvironments|Set which groups, access levels, or users can deploy to this environment. Groups and users must be members of the project."
 msgstr ""
 
@@ -42904,6 +42910,9 @@ msgstr ""
 msgid "ProtectedEnvironment|Failed to protect the environment."
 msgstr ""
 
+msgid "ProtectedEnvironment|No environments in this group are protected."
+msgstr ""
+
 msgid "ProtectedEnvironment|No environments in this project are protected."
 msgstr ""
 
@@ -42925,6 +42934,9 @@ msgstr ""
 msgid "ProtectedEnvironment|Protect an environment"
 msgstr ""
 
+msgid "ProtectedEnvironment|Protect environment tier"
+msgstr ""
+
 msgid "ProtectedEnvironment|Protected Environment (%{protected_environments_count})"
 msgstr ""
 
@@ -42940,6 +42952,9 @@ msgstr ""
 msgid "ProtectedEnvironment|Select environment"
 msgstr ""
 
+msgid "ProtectedEnvironment|Select environment tier"
+msgstr ""
+
 msgid "ProtectedEnvironment|Select groups"
 msgstr ""
 
diff --git a/spec/frontend/groups/settings/components/access_dropdown_spec.js b/spec/frontend/groups/settings/components/access_dropdown_spec.js
index 67a514ef35dbef740740bd637406c2644b841653..a1f930e0df58bb379a324ce033d63ebf91a34bb0 100644
--- a/spec/frontend/groups/settings/components/access_dropdown_spec.js
+++ b/spec/frontend/groups/settings/components/access_dropdown_spec.js
@@ -1,7 +1,9 @@
-import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
+import { last } from 'lodash';
 import { nextTick } from 'vue';
-import { getSubGroups } from '~/groups/settings/api/access_dropdown_api';
+import waitForPromises from 'helpers/wait_for_promises';
+import { getSubGroups, getUsers } from '~/groups/settings/api/access_dropdown_api';
 import AccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 
 jest.mock('~/groups/settings/api/access_dropdown_api', () => ({
@@ -12,8 +14,26 @@ jest.mock('~/groups/settings/api/access_dropdown_api', () => ({
       { id: 6, name: 'group6' },
     ],
   }),
+  getUsers: jest.fn().mockResolvedValue({
+    data: [
+      { id: 1, name: 'user1', avatar_url: 'avatar1' },
+      { id: 2, name: 'user2', avatar_url: 'avatar2' },
+      { id: 3, name: 'user3', avatar_url: 'avatar3' },
+    ],
+  }),
 }));
 
+const accessLevelsData = [
+  {
+    id: 7,
+    text: 'role1',
+  },
+  {
+    id: 8,
+    text: 'role2',
+  },
+];
+
 describe('Access Level Dropdown', () => {
   let wrapper;
   const createComponent = ({ ...optionalProps } = {}) => {
@@ -28,6 +48,13 @@ describe('Access Level Dropdown', () => {
   };
 
   const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+  const findDropdown = () => wrapper.findComponent(GlDropdown);
+  const findDropdownToggleLabel = () => findDropdown().props('text');
+  const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
+  const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader);
+
+  const findDropdownItemWithText = (items, text) =>
+    items.filter((item) => item.text().includes(text)).at(0);
 
   describe('data request', () => {
     it('should make an api call for sub-groups', () => {
@@ -39,13 +66,32 @@ describe('Access Level Dropdown', () => {
       });
     });
 
-    it('should not make an API call sub groups when user does not have a license', () => {
-      createComponent({ hasLicense: false });
-      expect(getSubGroups).not.toHaveBeenCalled();
+    it('should make an api call for group members if `showUsers` prop is `true`', () => {
+      createComponent({ showUsers: true });
+      expect(getUsers).toHaveBeenCalledTimes(1);
     });
 
-    it('should make api calls when search query is updated', async () => {
+    it('should not make an api call for group members if `showUsers` prop is `false`', () => {
       createComponent();
+      expect(getUsers).not.toHaveBeenCalled();
+    });
+
+    describe('when user does not have a license', () => {
+      beforeEach(() => {
+        createComponent({ hasLicense: false });
+      });
+
+      it('should not make an API call sub groups', () => {
+        expect(getSubGroups).not.toHaveBeenCalled();
+      });
+
+      it('should not make an API call group members', () => {
+        expect(getUsers).not.toHaveBeenCalled();
+      });
+    });
+
+    it('should make api calls when search query is updated', async () => {
+      createComponent({ showUsers: true });
       const search = 'root';
 
       findSearchBox().vm.$emit('input', search);
@@ -55,6 +101,119 @@ describe('Access Level Dropdown', () => {
         includeParentSharedGroups: true,
         search,
       });
+      expect(getUsers).toHaveBeenCalledWith(search);
+    });
+  });
+
+  describe('layout', () => {
+    beforeEach(async () => {
+      createComponent({
+        accessLevelsData,
+        showUsers: true,
+      });
+      await waitForPromises();
+    });
+
+    it.each`
+      header      | index
+      ${'Roles'}  | ${0}
+      ${'Groups'} | ${1}
+      ${'Users'}  | ${2}
+    `('renders header for $header at $index', ({ header, index }) => {
+      expect(findAllDropdownHeaders().at(index).text()).toBe(header);
+    });
+
+    it('renders dropdown item for each access level type', () => {
+      expect(findAllDropdownItems()).toHaveLength(8);
+    });
+  });
+
+  describe('toggleLabel', () => {
+    it('when no items selected and custom label provided, displays it', () => {
+      const customLabel = 'Set the access level';
+      createComponent({ label: customLabel });
+      expect(findDropdownToggleLabel()).toBe(customLabel);
+    });
+
+    it('when no items selected, displays a default fallback label', () => {
+      createComponent();
+      expect(findDropdownToggleLabel()).toBe('Select groups');
+    });
+
+    it('displays selected items for each group level', async () => {
+      createComponent({ accessLevelsData, showUsers: true });
+      await waitForPromises();
+
+      findAllDropdownItems().wrappers.forEach((item) => {
+        item.trigger('click');
+      });
+      await nextTick();
+      expect(findDropdownToggleLabel()).toBe('2 roles, 3 groups, 3 users');
+    });
+
+    it('with only role selected displays the role name', async () => {
+      createComponent({ accessLevelsData, showUsers: true });
+      await waitForPromises();
+
+      await findDropdownItemWithText(findAllDropdownItems(), 'role1').trigger('click');
+      expect(findDropdownToggleLabel()).toBe('role1');
+    });
+
+    it('with only groups selected displays the number of selected groups', async () => {
+      createComponent();
+      await waitForPromises();
+
+      await findDropdownItemWithText(findAllDropdownItems(), 'group4').trigger('click');
+      expect(findDropdownToggleLabel()).toBe('1 group');
+    });
+
+    it('with only users selected displays the number of selected users', async () => {
+      createComponent({ showUsers: true });
+      await waitForPromises();
+
+      await findDropdownItemWithText(findAllDropdownItems(), 'user1').trigger('click');
+      await findDropdownItemWithText(findAllDropdownItems(), 'user2').trigger('click');
+      expect(findDropdownToggleLabel()).toBe('2 users');
+    });
+
+    it('with users and groups selected displays the number of selected users & groups', async () => {
+      createComponent({ showUsers: true });
+      await waitForPromises();
+
+      await findDropdownItemWithText(findAllDropdownItems(), 'group4').trigger('click');
+      await findDropdownItemWithText(findAllDropdownItems(), 'user2').trigger('click');
+      expect(findDropdownToggleLabel()).toBe('1 group, 1 user');
+    });
+  });
+
+  describe('selecting an item', () => {
+    it('selects the item on click and deselects on the next click', async () => {
+      createComponent();
+      await waitForPromises();
+
+      const item = findAllDropdownItems().at(1);
+      item.trigger('click');
+      await nextTick();
+      expect(item.props('isChecked')).toBe(true);
+      item.trigger('click');
+      await nextTick();
+      expect(item.props('isChecked')).toBe(false);
+    });
+
+    it('emits a formatted update on selection', async () => {
+      createComponent({ accessLevelsData, showUsers: true });
+      await waitForPromises();
+      const dropdownItems = findAllDropdownItems();
+
+      findDropdownItemWithText(dropdownItems, 'role1').trigger('click');
+      findDropdownItemWithText(dropdownItems, 'group4').trigger('click');
+      findDropdownItemWithText(dropdownItems, 'user3').trigger('click');
+
+      expect(last(wrapper.emitted('select'))[0]).toStrictEqual([
+        { access_level: 7 },
+        { group_id: 4 },
+        { user_id: 3 },
+      ]);
     });
   });
 });