Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									06bcbc77e4
								
							
						
					
					
						commit
						b7b44de429
					
				|  | @ -164,8 +164,8 @@ overrides: | |||
|       #'@graphql-eslint/unique-fragment-name': error | ||||
|       # TODO: Uncomment these rules when then `schema` is available | ||||
|       #'@graphql-eslint/fragments-on-composite-type': error | ||||
|       #'@graphql-eslint/known-argument-names': error | ||||
|       #'@graphql-eslint/known-type-names': error | ||||
|       '@graphql-eslint/known-argument-names': error | ||||
|       '@graphql-eslint/known-type-names': error | ||||
|       '@graphql-eslint/no-anonymous-operations': error | ||||
|       '@graphql-eslint/unique-operation-name': error | ||||
|       '@graphql-eslint/require-id-when-available': error | ||||
|  |  | |||
|  | @ -589,7 +589,6 @@ Layout/LineLength: | |||
|     - 'app/services/compare_service.rb' | ||||
|     - 'app/services/concerns/base_service_utility.rb' | ||||
|     - 'app/services/concerns/exclusive_lease_guard.rb' | ||||
|     - 'app/services/concerns/members/bulk_create_users.rb' | ||||
|     - 'app/services/concerns/merge_requests/assigns_merge_params.rb' | ||||
|     - 'app/services/concerns/rate_limited_service.rb' | ||||
|     - 'app/services/concerns/schedule_bulk_repository_shard_moves_methods.rb' | ||||
|  |  | |||
|  | @ -1,32 +1,9 @@ | |||
| <script> | ||||
| import { mapState, mapActions } from 'vuex'; | ||||
| import CiVariableModal from './ci_variable_modal.vue'; | ||||
| import CiVariableTable from './ci_variable_table.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     CiVariableModal, | ||||
|     CiVariableTable, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState(['isGroup']), | ||||
|   }, | ||||
|   mounted() { | ||||
|     if (!this.isGroup) { | ||||
|       this.fetchEnvironments(); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['fetchEnvironments']), | ||||
|   }, | ||||
| }; | ||||
| export default {}; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="row"> | ||||
|     <div class="col-lg-12"> | ||||
|       <ci-variable-table /> | ||||
|       <ci-variable-modal /> | ||||
|     </div> | ||||
|     <div class="col-lg-12"></div> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,81 @@ | |||
| <script> | ||||
| import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; | ||||
| import { mapGetters } from 'vuex'; | ||||
| import { __, sprintf } from '~/locale'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'CiEnvironmentsDropdown', | ||||
|   components: { | ||||
|     GlDropdown, | ||||
|     GlDropdownItem, | ||||
|     GlDropdownDivider, | ||||
|     GlSearchBoxByType, | ||||
|   }, | ||||
|   props: { | ||||
|     value: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchTerm: '', | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters(['joinedEnvironments']), | ||||
|     composedCreateButtonLabel() { | ||||
|       return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); | ||||
|     }, | ||||
|     shouldRenderCreateButton() { | ||||
|       return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm); | ||||
|     }, | ||||
|     filteredResults() { | ||||
|       const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); | ||||
|       return this.joinedEnvironments.filter((resultString) => | ||||
|         resultString.toLowerCase().includes(lowerCasedSearchTerm), | ||||
|       ); | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     selectEnvironment(selected) { | ||||
|       this.$emit('selectEnvironment', selected); | ||||
|       this.searchTerm = ''; | ||||
|     }, | ||||
|     createClicked() { | ||||
|       this.$emit('createClicked', this.searchTerm); | ||||
|       this.searchTerm = ''; | ||||
|     }, | ||||
|     isSelected(env) { | ||||
|       return this.value === env; | ||||
|     }, | ||||
|     clearSearch() { | ||||
|       this.searchTerm = ''; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <gl-dropdown :text="value" @show="clearSearch"> | ||||
|     <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> | ||||
|     <gl-dropdown-item | ||||
|       v-for="environment in filteredResults" | ||||
|       :key="environment" | ||||
|       :is-checked="isSelected(environment)" | ||||
|       is-check-item | ||||
|       @click="selectEnvironment(environment)" | ||||
|     > | ||||
|       {{ environment }} | ||||
|     </gl-dropdown-item> | ||||
|     <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ | ||||
|       __('No matching results') | ||||
|     }}</gl-dropdown-item> | ||||
|     <template v-if="shouldRenderCreateButton"> | ||||
|       <gl-dropdown-divider /> | ||||
|       <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked"> | ||||
|         {{ composedCreateButtonLabel }} | ||||
|       </gl-dropdown-item> | ||||
|     </template> | ||||
|   </gl-dropdown> | ||||
| </template> | ||||
|  | @ -0,0 +1,426 @@ | |||
| <script> | ||||
| import { | ||||
|   GlAlert, | ||||
|   GlButton, | ||||
|   GlCollapse, | ||||
|   GlFormCheckbox, | ||||
|   GlFormCombobox, | ||||
|   GlFormGroup, | ||||
|   GlFormSelect, | ||||
|   GlFormInput, | ||||
|   GlFormTextarea, | ||||
|   GlIcon, | ||||
|   GlLink, | ||||
|   GlModal, | ||||
|   GlSprintf, | ||||
| } from '@gitlab/ui'; | ||||
| import { mapActions, mapState } from 'vuex'; | ||||
| import { getCookie, setCookie } from '~/lib/utils/common_utils'; | ||||
| import { __ } from '~/locale'; | ||||
| import Tracking from '~/tracking'; | ||||
| import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import { mapComputed } from '~/vuex_shared/bindings'; | ||||
| import { | ||||
|   AWS_TOKEN_CONSTANTS, | ||||
|   ADD_CI_VARIABLE_MODAL_ID, | ||||
|   AWS_TIP_DISMISSED_COOKIE_NAME, | ||||
|   AWS_TIP_MESSAGE, | ||||
|   CONTAINS_VARIABLE_REFERENCE_MESSAGE, | ||||
|   ENVIRONMENT_SCOPE_LINK_TITLE, | ||||
|   EVENT_LABEL, | ||||
|   EVENT_ACTION, | ||||
| } from '../constants'; | ||||
| import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; | ||||
| import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; | ||||
| 
 | ||||
| const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); | ||||
| 
 | ||||
| export default { | ||||
|   modalId: ADD_CI_VARIABLE_MODAL_ID, | ||||
|   tokens: awsTokens, | ||||
|   tokenList: awsTokenList, | ||||
|   awsTipMessage: AWS_TIP_MESSAGE, | ||||
|   containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, | ||||
|   environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, | ||||
|   components: { | ||||
|     CiEnvironmentsDropdown, | ||||
|     GlAlert, | ||||
|     GlButton, | ||||
|     GlCollapse, | ||||
|     GlFormCheckbox, | ||||
|     GlFormCombobox, | ||||
|     GlFormGroup, | ||||
|     GlFormSelect, | ||||
|     GlFormInput, | ||||
|     GlFormTextarea, | ||||
|     GlIcon, | ||||
|     GlLink, | ||||
|     GlModal, | ||||
|     GlSprintf, | ||||
|   }, | ||||
|   mixins: [glFeatureFlagsMixin(), trackingMixin], | ||||
|   data() { | ||||
|     return { | ||||
|       isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', | ||||
|       validationErrorEventProperty: '', | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState([ | ||||
|       'projectId', | ||||
|       'environments', | ||||
|       'typeOptions', | ||||
|       'variable', | ||||
|       'variableBeingEdited', | ||||
|       'isGroup', | ||||
|       'maskableRegex', | ||||
|       'selectedEnvironment', | ||||
|       'isProtectedByDefault', | ||||
|       'awsLogoSvgPath', | ||||
|       'awsTipDeployLink', | ||||
|       'awsTipCommandsLink', | ||||
|       'awsTipLearnLink', | ||||
|       'containsVariableReferenceLink', | ||||
|       'protectedEnvironmentVariablesLink', | ||||
|       'maskedEnvironmentVariablesLink', | ||||
|       'environmentScopeLink', | ||||
|     ]), | ||||
|     ...mapComputed( | ||||
|       [ | ||||
|         { key: 'key', updateFn: 'updateVariableKey' }, | ||||
|         { key: 'secret_value', updateFn: 'updateVariableValue' }, | ||||
|         { key: 'variable_type', updateFn: 'updateVariableType' }, | ||||
|         { key: 'environment_scope', updateFn: 'setEnvironmentScope' }, | ||||
|         { key: 'protected_variable', updateFn: 'updateVariableProtected' }, | ||||
|         { key: 'masked', updateFn: 'updateVariableMasked' }, | ||||
|       ], | ||||
|       false, | ||||
|       'variable', | ||||
|     ), | ||||
|     isTipVisible() { | ||||
|       return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); | ||||
|     }, | ||||
|     canSubmit() { | ||||
|       return ( | ||||
|         this.variableValidationState && | ||||
|         this.variable.key !== '' && | ||||
|         this.variable.secret_value !== '' | ||||
|       ); | ||||
|     }, | ||||
|     canMask() { | ||||
|       const regex = RegExp(this.maskableRegex); | ||||
|       return regex.test(this.variable.secret_value); | ||||
|     }, | ||||
|     containsVariableReference() { | ||||
|       const regex = /\$/; | ||||
|       return regex.test(this.variable.secret_value); | ||||
|     }, | ||||
|     displayMaskedError() { | ||||
|       return !this.canMask && this.variable.masked; | ||||
|     }, | ||||
|     maskedState() { | ||||
|       if (this.displayMaskedError) { | ||||
|         return false; | ||||
|       } | ||||
|       return true; | ||||
|     }, | ||||
|     modalActionText() { | ||||
|       return this.variableBeingEdited ? __('Update variable') : __('Add variable'); | ||||
|     }, | ||||
|     maskedFeedback() { | ||||
|       return this.displayMaskedError ? __('This variable can not be masked.') : ''; | ||||
|     }, | ||||
|     tokenValidationFeedback() { | ||||
|       const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; | ||||
|       if (!this.tokenValidationState && tokenSpecificFeedback) { | ||||
|         return tokenSpecificFeedback; | ||||
|       } | ||||
|       return ''; | ||||
|     }, | ||||
|     tokenValidationState() { | ||||
|       const validator = this.$options.tokens?.[this.variable.key]?.validation; | ||||
| 
 | ||||
|       if (validator) { | ||||
|         return validator(this.variable.secret_value); | ||||
|       } | ||||
| 
 | ||||
|       return true; | ||||
|     }, | ||||
|     scopedVariablesAvailable() { | ||||
|       return !this.isGroup || this.glFeatures.groupScopedCiVariables; | ||||
|     }, | ||||
|     variableValidationFeedback() { | ||||
|       return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; | ||||
|     }, | ||||
|     variableValidationState() { | ||||
|       return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     variable: { | ||||
|       handler() { | ||||
|         this.trackVariableValidationErrors(); | ||||
|       }, | ||||
|       deep: true, | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions([ | ||||
|       'addVariable', | ||||
|       'updateVariable', | ||||
|       'resetEditing', | ||||
|       'displayInputValue', | ||||
|       'clearModal', | ||||
|       'deleteVariable', | ||||
|       'setEnvironmentScope', | ||||
|       'addWildCardScope', | ||||
|       'resetSelectedEnvironment', | ||||
|       'setSelectedEnvironment', | ||||
|       'setVariableProtected', | ||||
|     ]), | ||||
|     dismissTip() { | ||||
|       setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); | ||||
|       this.isTipDismissed = true; | ||||
|     }, | ||||
|     deleteVarAndClose() { | ||||
|       this.deleteVariable(); | ||||
|       this.hideModal(); | ||||
|     }, | ||||
|     hideModal() { | ||||
|       this.$refs.modal.hide(); | ||||
|     }, | ||||
|     resetModalHandler() { | ||||
|       if (this.variableBeingEdited) { | ||||
|         this.resetEditing(); | ||||
|       } | ||||
| 
 | ||||
|       this.clearModal(); | ||||
|       this.resetSelectedEnvironment(); | ||||
|       this.resetValidationErrorEvents(); | ||||
|     }, | ||||
|     updateOrAddVariable() { | ||||
|       if (this.variableBeingEdited) { | ||||
|         this.updateVariable(); | ||||
|       } else { | ||||
|         this.addVariable(); | ||||
|       } | ||||
|       this.hideModal(); | ||||
|     }, | ||||
|     setVariableProtectedByDefault() { | ||||
|       if (this.isProtectedByDefault && !this.variableBeingEdited) { | ||||
|         this.setVariableProtected(); | ||||
|       } | ||||
|     }, | ||||
|     trackVariableValidationErrors() { | ||||
|       const property = this.getTrackingErrorProperty(); | ||||
|       if (!this.validationErrorEventProperty && property) { | ||||
|         this.track(EVENT_ACTION, { property }); | ||||
|         this.validationErrorEventProperty = property; | ||||
|       } | ||||
|     }, | ||||
|     getTrackingErrorProperty() { | ||||
|       let property; | ||||
|       if (this.variable.secret_value?.length && !property) { | ||||
|         if (this.displayMaskedError && this.maskableRegex?.length) { | ||||
|           const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); | ||||
|           const regex = new RegExp(supportedChars, 'g'); | ||||
|           property = this.variable.secret_value.replace(regex, ''); | ||||
|         } | ||||
|         if (this.containsVariableReference) { | ||||
|           property = '$'; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       return property; | ||||
|     }, | ||||
|     resetValidationErrorEvents() { | ||||
|       this.validationErrorEventProperty = ''; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <gl-modal | ||||
|     ref="modal" | ||||
|     :modal-id="$options.modalId" | ||||
|     :title="modalActionText" | ||||
|     static | ||||
|     lazy | ||||
|     @hidden="resetModalHandler" | ||||
|     @shown="setVariableProtectedByDefault" | ||||
|   > | ||||
|     <form> | ||||
|       <gl-form-combobox | ||||
|         v-model="key" | ||||
|         :token-list="$options.tokenList" | ||||
|         :label-text="__('Key')" | ||||
|         data-qa-selector="ci_variable_key_field" | ||||
|       /> | ||||
| 
 | ||||
|       <gl-form-group | ||||
|         :label="__('Value')" | ||||
|         label-for="ci-variable-value" | ||||
|         :state="variableValidationState" | ||||
|         :invalid-feedback="variableValidationFeedback" | ||||
|       > | ||||
|         <gl-form-textarea | ||||
|           id="ci-variable-value" | ||||
|           ref="valueField" | ||||
|           v-model="secret_value" | ||||
|           :state="variableValidationState" | ||||
|           rows="3" | ||||
|           max-rows="6" | ||||
|           data-qa-selector="ci_variable_value_field" | ||||
|           class="gl-font-monospace!" | ||||
|         /> | ||||
|       </gl-form-group> | ||||
| 
 | ||||
|       <div class="d-flex"> | ||||
|         <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5"> | ||||
|           <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> | ||||
|         </gl-form-group> | ||||
| 
 | ||||
|         <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope"> | ||||
|           <template #label> | ||||
|             {{ __('Environment scope') }} | ||||
|             <gl-link | ||||
|               :title="$options.environmentScopeLinkTitle" | ||||
|               :href="environmentScopeLink" | ||||
|               target="_blank" | ||||
|               data-testid="environment-scope-link" | ||||
|             > | ||||
|               <gl-icon name="question" :size="12" /> | ||||
|             </gl-link> | ||||
|           </template> | ||||
|           <ci-environments-dropdown | ||||
|             v-if="scopedVariablesAvailable" | ||||
|             class="w-100" | ||||
|             :value="environment_scope" | ||||
|             @selectEnvironment="setEnvironmentScope" | ||||
|             @createClicked="addWildCardScope" | ||||
|           /> | ||||
| 
 | ||||
|           <gl-form-input v-else v-model="environment_scope" class="w-100" readonly /> | ||||
|         </gl-form-group> | ||||
|       </div> | ||||
| 
 | ||||
|       <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> | ||||
|         <gl-form-checkbox | ||||
|           v-model="protected_variable" | ||||
|           class="mb-0" | ||||
|           data-testid="ci-variable-protected-checkbox" | ||||
|         > | ||||
|           {{ __('Protect variable') }} | ||||
|           <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> | ||||
|             <gl-icon name="question" :size="12" /> | ||||
|           </gl-link> | ||||
|           <p class="gl-mt-2 text-secondary"> | ||||
|             {{ __('Export variable to pipelines running on protected branches and tags only.') }} | ||||
|           </p> | ||||
|         </gl-form-checkbox> | ||||
| 
 | ||||
|         <gl-form-checkbox | ||||
|           ref="masked-ci-variable" | ||||
|           v-model="masked" | ||||
|           data-testid="ci-variable-masked-checkbox" | ||||
|         > | ||||
|           {{ __('Mask variable') }} | ||||
|           <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> | ||||
|             <gl-icon name="question" :size="12" /> | ||||
|           </gl-link> | ||||
|           <p class="gl-mt-2 gl-mb-0 text-secondary"> | ||||
|             {{ __('Variable will be masked in job logs.') }} | ||||
|             <span | ||||
|               :class="{ | ||||
|                 'bold text-plain': displayMaskedError, | ||||
|               }" | ||||
|             > | ||||
|               {{ __('Requires values to meet regular expression requirements.') }}</span | ||||
|             > | ||||
|             <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ | ||||
|               __('More information') | ||||
|             }}</gl-link> | ||||
|           </p> | ||||
|         </gl-form-checkbox> | ||||
|       </gl-form-group> | ||||
|     </form> | ||||
|     <gl-collapse :visible="isTipVisible"> | ||||
|       <gl-alert | ||||
|         :title="__('Deploying to AWS is easy with GitLab')" | ||||
|         variant="tip" | ||||
|         data-testid="aws-guidance-tip" | ||||
|         @dismiss="dismissTip" | ||||
|       > | ||||
|         <div class="gl-display-flex gl-flex-direction-row"> | ||||
|           <div> | ||||
|             <p> | ||||
|               <gl-sprintf :message="$options.awsTipMessage"> | ||||
|                 <template #deployLink="{ content }"> | ||||
|                   <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> | ||||
|                 </template> | ||||
|                 <template #commandsLink="{ content }"> | ||||
|                   <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link> | ||||
|                 </template> | ||||
|               </gl-sprintf> | ||||
|             </p> | ||||
|             <p> | ||||
|               <gl-button | ||||
|                 :href="awsTipLearnLink" | ||||
|                 target="_blank" | ||||
|                 category="secondary" | ||||
|                 variant="info" | ||||
|                 class="gl-overflow-wrap-break" | ||||
|                 >{{ __('Learn more about deploying to AWS') }}</gl-button | ||||
|               > | ||||
|             </p> | ||||
|           </div> | ||||
|           <img | ||||
|             class="gl-mt-3" | ||||
|             :alt="__('Amazon Web Services Logo')" | ||||
|             :src="awsLogoSvgPath" | ||||
|             height="32" | ||||
|           /> | ||||
|         </div> | ||||
|       </gl-alert> | ||||
|     </gl-collapse> | ||||
|     <gl-alert | ||||
|       v-if="containsVariableReference" | ||||
|       :title="__('Value might contain a variable reference')" | ||||
|       :dismissible="false" | ||||
|       variant="warning" | ||||
|       data-testid="contains-variable-reference" | ||||
|     > | ||||
|       <gl-sprintf :message="$options.containsVariableReferenceMessage"> | ||||
|         <template #code="{ content }"> | ||||
|           <code>{{ content }}</code> | ||||
|         </template> | ||||
|         <template #docsLink="{ content }"> | ||||
|           <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link> | ||||
|         </template> | ||||
|       </gl-sprintf> | ||||
|     </gl-alert> | ||||
|     <template #modal-footer> | ||||
|       <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> | ||||
|       <gl-button | ||||
|         v-if="variableBeingEdited" | ||||
|         ref="deleteCiVariable" | ||||
|         variant="danger" | ||||
|         category="secondary" | ||||
|         data-qa-selector="ci_variable_delete_button" | ||||
|         @click="deleteVarAndClose" | ||||
|         >{{ __('Delete variable') }}</gl-button | ||||
|       > | ||||
|       <gl-button | ||||
|         ref="updateOrAddVariable" | ||||
|         :disabled="!canSubmit" | ||||
|         variant="confirm" | ||||
|         category="primary" | ||||
|         data-testid="ciUpdateOrAddVariableBtn" | ||||
|         data-qa-selector="ci_variable_save_button" | ||||
|         @click="updateOrAddVariable" | ||||
|         >{{ modalActionText }} | ||||
|       </gl-button> | ||||
|     </template> | ||||
|   </gl-modal> | ||||
| </template> | ||||
|  | @ -0,0 +1,32 @@ | |||
| <script> | ||||
| import { mapState, mapActions } from 'vuex'; | ||||
| import LegacyCiVariableModal from './legacy_ci_variable_modal.vue'; | ||||
| import LegacyCiVariableTable from './legacy_ci_variable_table.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     LegacyCiVariableModal, | ||||
|     LegacyCiVariableTable, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapState(['isGroup']), | ||||
|   }, | ||||
|   mounted() { | ||||
|     if (!this.isGroup) { | ||||
|       this.fetchEnvironments(); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['fetchEnvironments']), | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="row"> | ||||
|     <div class="col-lg-12"> | ||||
|       <legacy-ci-variable-table /> | ||||
|       <legacy-ci-variable-modal /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,199 @@ | |||
| <script> | ||||
| import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui'; | ||||
| import { mapState, mapActions } from 'vuex'; | ||||
| import { s__, __ } from '~/locale'; | ||||
| import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; | ||||
| import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; | ||||
| import CiVariablePopover from './ci_variable_popover.vue'; | ||||
| 
 | ||||
| export default { | ||||
|   modalId: ADD_CI_VARIABLE_MODAL_ID, | ||||
|   trueIcon: 'mobile-issue-close', | ||||
|   falseIcon: 'close', | ||||
|   iconSize: 16, | ||||
|   fields: [ | ||||
|     { | ||||
|       key: 'variable_type', | ||||
|       label: s__('CiVariables|Type'), | ||||
|       customStyle: { width: '70px' }, | ||||
|     }, | ||||
|     { | ||||
|       key: 'key', | ||||
|       label: s__('CiVariables|Key'), | ||||
|       tdClass: 'text-plain', | ||||
|       sortable: true, | ||||
|       customStyle: { width: '40%' }, | ||||
|     }, | ||||
|     { | ||||
|       key: 'value', | ||||
|       label: s__('CiVariables|Value'), | ||||
|       customStyle: { width: '40%' }, | ||||
|     }, | ||||
|     { | ||||
|       key: 'protected', | ||||
|       label: s__('CiVariables|Protected'), | ||||
|       customStyle: { width: '100px' }, | ||||
|     }, | ||||
|     { | ||||
|       key: 'masked', | ||||
|       label: s__('CiVariables|Masked'), | ||||
|       customStyle: { width: '100px' }, | ||||
|     }, | ||||
|     { | ||||
|       key: 'environment_scope', | ||||
|       label: s__('CiVariables|Environments'), | ||||
|       customStyle: { width: '20%' }, | ||||
|     }, | ||||
|     { | ||||
|       key: 'actions', | ||||
|       label: '', | ||||
|       tdClass: 'text-right', | ||||
|       customStyle: { width: '35px' }, | ||||
|     }, | ||||
|   ], | ||||
|   components: { | ||||
|     CiVariablePopover, | ||||
|     GlButton, | ||||
|     GlIcon, | ||||
|     GlTable, | ||||
|     TooltipOnTruncate, | ||||
|   }, | ||||
|   directives: { | ||||
|     GlModalDirective, | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|   }, | ||||
|   mixins: [glFeatureFlagsMixin()], | ||||
|   computed: { | ||||
|     ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']), | ||||
|     valuesButtonText() { | ||||
|       return this.valuesHidden ? __('Reveal values') : __('Hide values'); | ||||
|     }, | ||||
|     isTableEmpty() { | ||||
|       return !this.variables || this.variables.length === 0; | ||||
|     }, | ||||
|     fields() { | ||||
|       return this.$options.fields; | ||||
|     }, | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.fetchVariables(); | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']), | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="ci-variable-table" data-testid="ci-variable-table"> | ||||
|     <gl-table | ||||
|       :fields="fields" | ||||
|       :items="variables" | ||||
|       tbody-tr-class="js-ci-variable-row" | ||||
|       data-qa-selector="ci_variable_table_content" | ||||
|       sort-by="key" | ||||
|       sort-direction="asc" | ||||
|       stacked="lg" | ||||
|       table-class="text-secondary" | ||||
|       fixed | ||||
|       show-empty | ||||
|       sort-icon-left | ||||
|       no-sort-reset | ||||
|     > | ||||
|       <template #table-colgroup="scope"> | ||||
|         <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> | ||||
|       </template> | ||||
|       <template #cell(key)="{ item }"> | ||||
|         <div class="gl-display-flex gl-align-items-center"> | ||||
|           <tooltip-on-truncate :title="item.key" truncate-target="child"> | ||||
|             <span | ||||
|               :id="`ci-variable-key-${item.id}`" | ||||
|               class="gl-display-inline-block gl-max-w-full gl-text-truncate" | ||||
|               >{{ item.key }}</span | ||||
|             > | ||||
|           </tooltip-on-truncate> | ||||
|           <gl-button | ||||
|             v-gl-tooltip | ||||
|             category="tertiary" | ||||
|             icon="copy-to-clipboard" | ||||
|             :title="__('Copy key')" | ||||
|             :data-clipboard-text="item.key" | ||||
|             :aria-label="__('Copy to clipboard')" | ||||
|           /> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template #cell(value)="{ item }"> | ||||
|         <div class="gl-display-flex gl-align-items-center"> | ||||
|           <span v-if="valuesHidden">*********************</span> | ||||
|           <span | ||||
|             v-else | ||||
|             :id="`ci-variable-value-${item.id}`" | ||||
|             class="gl-display-inline-block gl-max-w-full gl-text-truncate" | ||||
|             >{{ item.value }}</span | ||||
|           > | ||||
|           <gl-button | ||||
|             v-gl-tooltip | ||||
|             category="tertiary" | ||||
|             icon="copy-to-clipboard" | ||||
|             :title="__('Copy value')" | ||||
|             :data-clipboard-text="item.value" | ||||
|             :aria-label="__('Copy to clipboard')" | ||||
|           /> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template #cell(protected)="{ item }"> | ||||
|         <gl-icon v-if="item.protected" :size="$options.iconSize" :name="$options.trueIcon" /> | ||||
|         <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" /> | ||||
|       </template> | ||||
|       <template #cell(masked)="{ item }"> | ||||
|         <gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" /> | ||||
|         <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" /> | ||||
|       </template> | ||||
|       <template #cell(environment_scope)="{ item }"> | ||||
|         <div class="gl-display-flex"> | ||||
|           <span | ||||
|             :id="`ci-variable-env-${item.id}`" | ||||
|             class="gl-display-inline-block gl-max-w-full gl-text-truncate" | ||||
|             >{{ item.environment_scope }}</span | ||||
|           > | ||||
|           <ci-variable-popover | ||||
|             :target="`ci-variable-env-${item.id}`" | ||||
|             :value="item.environment_scope" | ||||
|             :tooltip-text="__('Copy environment')" | ||||
|           /> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template #cell(actions)="{ item }"> | ||||
|         <gl-button | ||||
|           v-gl-modal-directive="$options.modalId" | ||||
|           icon="pencil" | ||||
|           :aria-label="__('Edit')" | ||||
|           data-qa-selector="edit_ci_variable_button" | ||||
|           @click="editVariable(item)" | ||||
|         /> | ||||
|       </template> | ||||
|       <template #empty> | ||||
|         <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0"> | ||||
|           {{ __('There are no variables yet.') }} | ||||
|         </p> | ||||
|       </template> | ||||
|     </gl-table> | ||||
|     <div class="ci-variable-actions gl-display-flex gl-mt-5"> | ||||
|       <gl-button | ||||
|         v-gl-modal-directive="$options.modalId" | ||||
|         class="gl-mr-3" | ||||
|         data-qa-selector="add_ci_variable_button" | ||||
|         variant="confirm" | ||||
|         category="primary" | ||||
|         >{{ __('Add variable') }}</gl-button | ||||
|       > | ||||
|       <gl-button | ||||
|         v-if="!isTableEmpty" | ||||
|         data-qa-selector="reveal_ci_variable_value_button" | ||||
|         @click="toggleValues(!valuesHidden)" | ||||
|         >{{ valuesButtonText }}</gl-button | ||||
|       > | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -1,9 +1,62 @@ | |||
| import Vue from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import createDefaultClient from '~/lib/graphql'; | ||||
| import { parseBoolean } from '~/lib/utils/common_utils'; | ||||
| import CiVariableSettings from './components/ci_variable_settings.vue'; | ||||
| import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue'; | ||||
| import createStore from './store'; | ||||
| 
 | ||||
| const mountCiVariableListApp = (containerEl) => { | ||||
|   const { | ||||
|     awsLogoSvgPath, | ||||
|     awsTipCommandsLink, | ||||
|     awsTipDeployLink, | ||||
|     awsTipLearnLink, | ||||
|     containsVariableReferenceLink, | ||||
|     environmentScopeLink, | ||||
|     group, | ||||
|     maskedEnvironmentVariablesLink, | ||||
|     maskableRegex, | ||||
|     projectFullPath, | ||||
|     projectId, | ||||
|     protectedByDefault, | ||||
|     protectedEnvironmentVariablesLink, | ||||
|   } = containerEl.dataset; | ||||
| 
 | ||||
|   const isGroup = parseBoolean(group); | ||||
|   const isProtectedByDefault = parseBoolean(protectedByDefault); | ||||
| 
 | ||||
|   Vue.use(VueApollo); | ||||
| 
 | ||||
|   const apolloProvider = new VueApollo({ | ||||
|     defaultClient: createDefaultClient(), | ||||
|   }); | ||||
| 
 | ||||
|   return new Vue({ | ||||
|     el: containerEl, | ||||
|     apolloProvider, | ||||
|     provide: { | ||||
|       awsLogoSvgPath, | ||||
|       awsTipCommandsLink, | ||||
|       awsTipDeployLink, | ||||
|       awsTipLearnLink, | ||||
|       containsVariableReferenceLink, | ||||
|       environmentScopeLink, | ||||
|       isGroup, | ||||
|       isProtectedByDefault, | ||||
|       maskedEnvironmentVariablesLink, | ||||
|       maskableRegex, | ||||
|       projectFullPath, | ||||
|       projectId, | ||||
|       protectedEnvironmentVariablesLink, | ||||
|     }, | ||||
|     render(createElement) { | ||||
|       return createElement(CiVariableSettings); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| const mountLegacyCiVariableListApp = (containerEl) => { | ||||
|   const { | ||||
|     endpoint, | ||||
|     projectId, | ||||
|  | @ -42,7 +95,7 @@ const mountCiVariableListApp = (containerEl) => { | |||
|     el: containerEl, | ||||
|     store, | ||||
|     render(createElement) { | ||||
|       return createElement(CiVariableSettings); | ||||
|       return createElement(LegacyCiVariableSettings); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | @ -50,5 +103,11 @@ const mountCiVariableListApp = (containerEl) => { | |||
| export default (containerId = 'js-ci-project-variables') => { | ||||
|   const el = document.getElementById(containerId); | ||||
| 
 | ||||
|   return !el ? {} : mountCiVariableListApp(el); | ||||
|   if (el) { | ||||
|     if (gon.features?.ciVariableSettingsGraphql) { | ||||
|       mountCiVariableListApp(el); | ||||
|     } else { | ||||
|       mountLegacyCiVariableListApp(el); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -1,3 +1,3 @@ | |||
| mutation addItems($items: [Item]) { | ||||
| mutation addItems($items: [ItemInput]) { | ||||
|   addToolbarItems(items: $items) @client | ||||
| } | ||||
|  |  | |||
|  | @ -8,6 +8,16 @@ type Item { | |||
|   selectedLabel: String | ||||
| } | ||||
| 
 | ||||
| input ItemInput { | ||||
|   id: ID! | ||||
|   label: String! | ||||
|   icon: String | ||||
|   selected: Boolean | ||||
|   group: Int! | ||||
|   category: String | ||||
|   selectedLabel: String | ||||
| } | ||||
| 
 | ||||
| type Items { | ||||
|   nodes: [Item]! | ||||
| } | ||||
|  | @ -17,7 +27,7 @@ extend type Query { | |||
| } | ||||
| 
 | ||||
| extend type Mutation { | ||||
|   updateToolbarItem(id: ID!, propsToUpdate: Item!): LocalErrors | ||||
|   updateToolbarItem(id: ID!, propsToUpdate: ItemInput!): LocalErrors | ||||
|   removeToolbarItems(ids: [ID!]): LocalErrors | ||||
|   addToolbarItems(items: [Item]): LocalErrors | ||||
|   addToolbarItems(items: [ItemInput]): LocalErrors | ||||
| } | ||||
|  |  | |||
|  | @ -1,3 +1,3 @@ | |||
| mutation updateItem($id: ID!, $propsToUpdate: Item!) { | ||||
| mutation updateItem($id: ID!, $propsToUpdate: ItemInput!) { | ||||
|   updateToolbarItem(id: $id, propsToUpdate: $propsToUpdate) @client | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| mutation action($action: LocalAction) { | ||||
| mutation action($action: LocalActionInput) { | ||||
|   action(action: $action) @client { | ||||
|     errors | ||||
|   } | ||||
|  |  | |||
|  | @ -9,6 +9,11 @@ type LocalEnvironment { | |||
|   autoStopPath: String | ||||
| } | ||||
| 
 | ||||
| input LocalActionInput { | ||||
|   name: String! | ||||
|   playPath: String | ||||
| } | ||||
| 
 | ||||
| input LocalEnvironmentInput { | ||||
|   id: Int! | ||||
|   globalId: ID! | ||||
|  | @ -64,7 +69,7 @@ type LocalPageInfo { | |||
| 
 | ||||
| extend type Query { | ||||
|   environmentApp(page: Int, scope: String): LocalEnvironmentApp | ||||
|   folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder | ||||
|   folder(environment: NestedLocalEnvironmentInput, scope: String): LocalEnvironmentFolder | ||||
|   environmentToDelete: LocalEnvironment | ||||
|   pageInfo: LocalPageInfo | ||||
|   environmentToRollback: LocalEnvironment | ||||
|  | @ -82,5 +87,5 @@ extend type Mutation { | |||
|   setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors | ||||
|   setEnvironmentToStop(environment: LocalEnvironmentInput): LocalErrors | ||||
|   setEnvironmentToChangeCanary(environment: LocalEnvironmentInput, weight: Int): LocalErrors | ||||
|   action(environment: LocalEnvironmentInput): LocalErrors | ||||
|   action(action: LocalActionInput): LocalErrors | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| mutation importGroups($importRequests: [ImportGroupInput!]!) { | ||||
| mutation importGroups($importRequests: [ImportRequestInput!]!) { | ||||
|   importGroups(importRequests: $importRequests) @client { | ||||
|     id | ||||
|     lastImportTarget { | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| #import "ee_else_ce/repository/queries/commit.fragment.graphql" | ||||
| 
 | ||||
| query getCommit($fileName: String!, $type: String!, $path: String!, $maxOffset: Number!) { | ||||
| query getCommit($fileName: String!, $type: String!, $path: String!, $maxOffset: Int!) { | ||||
|   commit(path: $path, fileName: $fileName, type: $type, maxOffset: $maxOffset) @client { | ||||
|     ...TreeEntryCommit | ||||
|   } | ||||
|  |  | |||
|  | @ -0,0 +1,10 @@ | |||
| type LogTreeCommit { | ||||
|   sha: String | ||||
|   message: String | ||||
|   titleHtml: String | ||||
|   committedDate: Time | ||||
|   commitPath: String | ||||
|   fileName: String | ||||
|   filePath: String | ||||
|   type: String | ||||
| } | ||||
|  | @ -1,3 +1,3 @@ | |||
| mutation addDataToTerraformState($terraformState: State!) { | ||||
| mutation addDataToTerraformState($terraformState: LocalTerraformStateInput!) { | ||||
|   addDataToTerraformState(terraformState: $terraformState) @client | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,22 @@ | |||
| extend type TerraformState { | ||||
|   _showDetails: Boolean | ||||
|   errorMessages: [String] | ||||
|   loadingLock: Boolean | ||||
|   loadingRemove: Boolean | ||||
| } | ||||
| 
 | ||||
| input LocalTerraformStateInput { | ||||
|   _showDetails: Boolean | ||||
|   errorMessages: [String] | ||||
|   loadingLock: Boolean | ||||
|   loadingRemove: Boolean | ||||
|   id: ID! | ||||
|   name: String! | ||||
|   lockedAt: Time | ||||
|   updatedAt: Time! | ||||
|   deletedAt: Time | ||||
| } | ||||
| 
 | ||||
| extend type Mutation { | ||||
|   addDataToTerraformState(terraformState: LocalTerraformStateInput!): Boolean | ||||
| } | ||||
|  | @ -21,7 +21,7 @@ extend type WorkItem { | |||
|   mockWidgets: [LocalWorkItemWidget] | ||||
| } | ||||
| 
 | ||||
| type LocalWorkItemAssigneesInput { | ||||
| input LocalWorkItemAssigneesInput { | ||||
|   id: WorkItemID! | ||||
|   assigneeIds: [ID!] | ||||
| } | ||||
|  |  | |||
|  | @ -17,7 +17,6 @@ | |||
| @import './pages/note_form'; | ||||
| @import './pages/notes'; | ||||
| @import './pages/notifications'; | ||||
| @import './pages/pages'; | ||||
| @import './pages/pipelines'; | ||||
| @import './pages/profile'; | ||||
| @import './pages/profiles/preferences'; | ||||
|  |  | |||
|  | @ -9,6 +9,10 @@ | |||
| @import 'bootstrap/scss/buttons'; | ||||
| @import 'bootstrap/scss/forms'; | ||||
| 
 | ||||
| @import '@gitlab/ui/src/scss/variables'; | ||||
| @import '@gitlab/ui/src/scss/utility-mixins/index'; | ||||
| @import '@gitlab/ui/src/components/base/button/button'; | ||||
| 
 | ||||
| $body-color: #666; | ||||
| $header-color: #456; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,55 +0,0 @@ | |||
| .pages-domain-list { | ||||
|   &-item { | ||||
|     align-items: center; | ||||
| 
 | ||||
|     .domain-status { | ||||
|       display: inline-flex; | ||||
|       left: $gl-padding; | ||||
|       position: absolute; | ||||
|     } | ||||
| 
 | ||||
|     .domain-name { | ||||
|       flex-grow: 1; | ||||
|     } | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
|   &.has-verification-status > li { | ||||
|     padding-left: 3 * $gl-padding; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| .status-badge { | ||||
| 
 | ||||
|   display: inline-flex; | ||||
|   margin-bottom: $gl-padding-8; | ||||
| 
 | ||||
|   // Most of the following settings "stolen" from btn-sm | ||||
|   // Border radius is overwritten for both | ||||
|   .label, | ||||
|   .btn { | ||||
|     padding: $gl-padding-4 $gl-padding-8; | ||||
|     font-size: $gl-font-size; | ||||
|     line-height: $gl-btn-line-height; | ||||
|     border-radius: 0; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|   } | ||||
| 
 | ||||
|   .btn svg { | ||||
|     top: auto; | ||||
|   } | ||||
| 
 | ||||
|   :first-child { | ||||
|     line-height: $gl-line-height; | ||||
|   } | ||||
| 
 | ||||
|   :not(:first-child) { | ||||
|     border-left: 0; | ||||
|   } | ||||
| 
 | ||||
|   :last-child { | ||||
|     border-radius: $border-radius-default; | ||||
|   } | ||||
| } | ||||
|  | @ -13,6 +13,7 @@ module Projects | |||
|       before_action :define_variables | ||||
|       before_action do | ||||
|         push_frontend_feature_flag(:ajax_new_deploy_token, @project) | ||||
|         push_frontend_feature_flag(:ci_variable_settings_graphql, @project) | ||||
|       end | ||||
| 
 | ||||
|       helper_method :highlight_badge | ||||
|  |  | |||
|  | @ -29,7 +29,7 @@ module InviteMembersHelper | |||
|       invalid_groups: source.related_group_ids, | ||||
|       help_link: help_page_url('user/permissions'), | ||||
|       is_project: is_project, | ||||
|       access_levels: member_class.access_level_roles.to_json | ||||
|       access_levels: member_class.permissible_access_level_roles(current_user, source).to_json | ||||
|     }.merge(group_select_data(source)) | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -52,12 +52,6 @@ module ProjectsHelper | |||
|     content_tag(:span, username, name_tag_options) | ||||
|   end | ||||
| 
 | ||||
|   def permissible_access_level_roles(current_user, project) | ||||
|     # Access level roles that the current user is able to grant others. | ||||
|     # This method is a stopgap in preparation for https://gitlab.com/gitlab-org/gitlab/-/issues/364087 | ||||
|     current_user.can?(:manage_owners, project) ? Gitlab::Access.options_with_owner : Gitlab::Access.options | ||||
|   end | ||||
| 
 | ||||
|   def link_to_member(project, author, opts = {}, &block) | ||||
|     default_opts = { avatar: true, name: true, title: ":name" } | ||||
|     opts = default_opts.merge(opts) | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ module Clusters | |||
| 
 | ||||
|     scope :ordered_by_name, -> { order(:name) } | ||||
|     scope :with_name, -> (name) { where(name: name) } | ||||
|     scope :has_vulnerabilities, -> (value = true) { where(has_vulnerabilities: value) } | ||||
| 
 | ||||
|     validates :name, | ||||
|       presence: true, | ||||
|  |  | |||
|  | @ -362,7 +362,7 @@ class Group < Namespace | |||
|   end | ||||
| 
 | ||||
|   def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) | ||||
|     Members::Groups::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass | ||||
|     Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass | ||||
|       self, | ||||
|       users, | ||||
|       access_level, | ||||
|  | @ -374,7 +374,7 @@ class Group < Namespace | |||
|   end | ||||
| 
 | ||||
|   def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true) | ||||
|     Members::Groups::CreatorService.new( # rubocop:disable CodeReuse/ServiceClass | ||||
|     Members::Groups::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass | ||||
|       self, | ||||
|       user, | ||||
|       access_level, | ||||
|  | @ -382,7 +382,7 @@ class Group < Namespace | |||
|       expires_at: expires_at, | ||||
|       ldap: ldap, | ||||
|       blocking_refresh: blocking_refresh | ||||
|     ).execute | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   def add_guest(user, current_user = nil) | ||||
|  |  | |||
|  | @ -31,11 +31,6 @@ class ProjectHook < WebHook | |||
|     _('Webhooks') | ||||
|   end | ||||
| 
 | ||||
|   override :rate_limit | ||||
|   def rate_limit | ||||
|     project.actual_limits.limit_for(:web_hook_calls) | ||||
|   end | ||||
| 
 | ||||
|   override :application_context | ||||
|   def application_context | ||||
|     super.merge(project: project) | ||||
|  |  | |||
|  | @ -127,19 +127,12 @@ class WebHook < ApplicationRecord | |||
| 
 | ||||
|   # @return [Boolean] Whether or not the WebHook is currently throttled. | ||||
|   def rate_limited? | ||||
|     return false unless rate_limit | ||||
| 
 | ||||
|     Gitlab::ApplicationRateLimiter.peek( | ||||
|       :web_hook_calls, | ||||
|       scope: [self], | ||||
|       threshold: rate_limit | ||||
|     ) | ||||
|     rate_limiter.rate_limited? | ||||
|   end | ||||
| 
 | ||||
|   # Threshold for the rate-limit. | ||||
|   # Overridden in ProjectHook and GroupHook, other WebHooks are not rate-limited. | ||||
|   # @return [Integer] The rate limit for the WebHook. `0` for no limit. | ||||
|   def rate_limit | ||||
|     nil | ||||
|     rate_limiter.limit | ||||
|   end | ||||
| 
 | ||||
|   # Returns the associated Project or Group for the WebHook if one exists. | ||||
|  | @ -180,4 +173,8 @@ class WebHook < ApplicationRecord | |||
|   def initialize_url_variables | ||||
|     self.url_variables = {} if encrypted_url_variables.nil? | ||||
|   end | ||||
| 
 | ||||
|   def rate_limiter | ||||
|     @rate_limiter ||= Gitlab::WebHooks::RateLimiter.new(self) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -29,6 +29,12 @@ class GroupMember < Member | |||
| 
 | ||||
|   attr_accessor :last_owner, :last_blocked_owner | ||||
| 
 | ||||
|   # For those who get to see a modal with a role dropdown, here are the options presented | ||||
|   def self.permissible_access_level_roles(_, _) | ||||
|     # This method is a stopgap in preparation for https://gitlab.com/gitlab-org/gitlab/-/issues/364087 | ||||
|     access_level_roles | ||||
|   end | ||||
| 
 | ||||
|   def self.access_level_roles | ||||
|     Gitlab::Access.options_with_owner | ||||
|   end | ||||
|  |  | |||
|  | @ -44,7 +44,7 @@ class ProjectMember < Member | |||
|         project_ids.each do |project_id| | ||||
|           project = Project.find(project_id) | ||||
| 
 | ||||
|           Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass | ||||
|           Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass | ||||
|             project, | ||||
|             users, | ||||
|             access_level, | ||||
|  | @ -73,6 +73,16 @@ class ProjectMember < Member | |||
|       truncate_teams [project.id] | ||||
|     end | ||||
| 
 | ||||
|     # For those who get to see a modal with a role dropdown, here are the options presented | ||||
|     def permissible_access_level_roles(current_user, project) | ||||
|       # This method is a stopgap in preparation for https://gitlab.com/gitlab-org/gitlab/-/issues/364087 | ||||
|       if Ability.allowed?(current_user, :manage_owners, project) | ||||
|         Gitlab::Access.options_with_owner | ||||
|       else | ||||
|         ProjectMember.access_level_roles | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def access_level_roles | ||||
|       Gitlab::Access.options | ||||
|     end | ||||
|  |  | |||
|  | @ -44,7 +44,7 @@ class ProjectTeam | |||
|   end | ||||
| 
 | ||||
|   def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) | ||||
|     Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass | ||||
|     Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass | ||||
|       project, | ||||
|       users, | ||||
|       access_level, | ||||
|  | @ -56,12 +56,12 @@ class ProjectTeam | |||
|   end | ||||
| 
 | ||||
|   def add_user(user, access_level, current_user: nil, expires_at: nil) | ||||
|     Members::Projects::CreatorService.new(project, # rubocop:disable CodeReuse/ServiceClass | ||||
|     Members::Projects::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass | ||||
|       project, | ||||
|       user, | ||||
|       access_level, | ||||
|       current_user: current_user, | ||||
|       expires_at: expires_at) | ||||
|                                      .execute | ||||
|   end | ||||
| 
 | ||||
|   # Remove all users from project team | ||||
|  |  | |||
|  | @ -1,93 +0,0 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Members | ||||
|   module BulkCreateUsers | ||||
|     extend ActiveSupport::Concern | ||||
| 
 | ||||
|     included do | ||||
|       class << self | ||||
|         def add_users(source, users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) | ||||
|           return [] unless users.present? | ||||
| 
 | ||||
|           # If this user is attempting to manage Owner members and doesn't have permission, do not allow | ||||
|           return [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user) | ||||
| 
 | ||||
|           emails, users, existing_members = parse_users_list(source, users) | ||||
| 
 | ||||
|           Member.transaction do | ||||
|             (emails + users).map! do |user| | ||||
|               new(source, | ||||
|                   user, | ||||
|                   access_level, | ||||
|                   existing_members: existing_members, | ||||
|                   current_user: current_user, | ||||
|                   expires_at: expires_at, | ||||
|                   tasks_to_be_done: tasks_to_be_done, | ||||
|                   tasks_project_id: tasks_project_id) | ||||
|                 .execute | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         private | ||||
| 
 | ||||
|         def managing_owners?(current_user, access_level) | ||||
|           current_user && Gitlab::Access.sym_options_with_owner[access_level] == Gitlab::Access::OWNER | ||||
|         end | ||||
| 
 | ||||
|         def parse_users_list(source, list) | ||||
|           emails = [] | ||||
|           user_ids = [] | ||||
|           users = [] | ||||
|           existing_members = {} | ||||
| 
 | ||||
|           list.each do |item| | ||||
|             case item | ||||
|             when User | ||||
|               users << item | ||||
|             when Integer | ||||
|               user_ids << item | ||||
|             when /\A\d+\Z/ | ||||
|               user_ids << item.to_i | ||||
|             when Devise.email_regexp | ||||
|               emails << item | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           # the below will automatically discard invalid user_ids | ||||
|           users.concat(User.id_in(user_ids)) if user_ids.present? | ||||
|           users.uniq! # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times | ||||
| 
 | ||||
|           users_by_emails = source.users_by_emails(emails) # preloads our request store for all emails | ||||
|           # in case emails belong to a user that is being invited by user or user_id, remove them from | ||||
|           # emails and let users/user_ids handle it. | ||||
|           parsed_emails = emails.select do |email| | ||||
|             user = users_by_emails[email] | ||||
|             !user || (users.exclude?(user) && user_ids.exclude?(user.id)) | ||||
|           end | ||||
| 
 | ||||
|           if users.present? | ||||
|             # helps not have to perform another query per user id to see if the member exists later on when fetching | ||||
|             existing_members = source.members_and_requesters.with_user(users).index_by(&:user_id) | ||||
|           end | ||||
| 
 | ||||
|           [parsed_emails, users, existing_members] | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def initialize(source, user, access_level, **args) | ||||
|       super | ||||
| 
 | ||||
|       @existing_members = args[:existing_members] || (raise ArgumentError, "existing_members must be included in the args hash") | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
| 
 | ||||
|     attr_reader :existing_members | ||||
| 
 | ||||
|     def find_or_initialize_member_by_user | ||||
|       existing_members[user.id] || source.members.build(user_id: user.id) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -5,13 +5,18 @@ module JiraConnectSubscriptions | |||
|     include Gitlab::Utils::StrongMemoize | ||||
|     MERGE_REQUEST_SYNC_BATCH_SIZE = 20 | ||||
|     MERGE_REQUEST_SYNC_BATCH_DELAY = 1.minute.freeze | ||||
|     NOT_SITE_ADMIN = 'The Jira user is not a site administrator.' | ||||
| 
 | ||||
|     def execute | ||||
|       return error(NOT_SITE_ADMIN, 403) unless can_administer_jira? | ||||
|       if !params[:jira_user] | ||||
|         return error(s_('JiraConnect|Could not fetch user information from Jira. ' \ | ||||
|                         'Check the permissions in Jira and try again.'), 403) | ||||
|       elsif !can_administer_jira? | ||||
|         return error(s_('JiraConnect|The Jira user is not a site administrator. ' \ | ||||
|                         'Check the permissions in Jira and try again.'), 403) | ||||
|       end | ||||
| 
 | ||||
|       unless namespace && can?(current_user, :create_jira_connect_subscription, namespace) | ||||
|         return error('Invalid namespace. Please make sure you have sufficient permissions', 401) | ||||
|         return error(s_('JiraConnect|Cannot find namespace. Make sure you have sufficient permissions.'), 401) | ||||
|       end | ||||
| 
 | ||||
|       create_subscription | ||||
|  | @ -20,7 +25,7 @@ module JiraConnectSubscriptions | |||
|     private | ||||
| 
 | ||||
|     def can_administer_jira? | ||||
|       @params[:jira_user]&.site_admin? | ||||
|       params[:jira_user]&.site_admin? | ||||
|     end | ||||
| 
 | ||||
|     def create_subscription | ||||
|  |  | |||
|  | @ -12,6 +12,105 @@ module Members | |||
|       def access_levels | ||||
|         Gitlab::Access.sym_options_with_owner | ||||
|       end | ||||
| 
 | ||||
|       def add_users( # rubocop:disable Metrics/ParameterLists | ||||
|         source, | ||||
|         users, | ||||
|         access_level, | ||||
|         current_user: nil, | ||||
|         expires_at: nil, | ||||
|         tasks_to_be_done: [], | ||||
|         tasks_project_id: nil, | ||||
|         ldap: nil, | ||||
|         blocking_refresh: nil | ||||
|       ) | ||||
|         return [] unless users.present? | ||||
| 
 | ||||
|         # If this user is attempting to manage Owner members and doesn't have permission, do not allow | ||||
|         return [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user) | ||||
| 
 | ||||
|         emails, users, existing_members = parse_users_list(source, users) | ||||
| 
 | ||||
|         Member.transaction do | ||||
|           (emails + users).map! do |user| | ||||
|             new(source, | ||||
|                 user, | ||||
|                 access_level, | ||||
|                 existing_members: existing_members, | ||||
|                 current_user: current_user, | ||||
|                 expires_at: expires_at, | ||||
|                 tasks_to_be_done: tasks_to_be_done, | ||||
|                 tasks_project_id: tasks_project_id, | ||||
|                 ldap: ldap, | ||||
|                 blocking_refresh: blocking_refresh) | ||||
|               .execute | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       def add_user( # rubocop:disable Metrics/ParameterLists | ||||
|         source, | ||||
|         user, | ||||
|         access_level, | ||||
|         current_user: nil, | ||||
|         expires_at: nil, | ||||
|         ldap: nil, | ||||
|         blocking_refresh: nil | ||||
|       ) | ||||
|         add_users(source, | ||||
|                   [user], | ||||
|                   access_level, | ||||
|                   current_user: current_user, | ||||
|                   expires_at: expires_at, | ||||
|                   ldap: ldap, | ||||
|                   blocking_refresh: blocking_refresh).first | ||||
|       end | ||||
| 
 | ||||
|       private | ||||
| 
 | ||||
|       def managing_owners?(current_user, access_level) | ||||
|         current_user && Gitlab::Access.sym_options_with_owner[access_level] == Gitlab::Access::OWNER | ||||
|       end | ||||
| 
 | ||||
|       def parse_users_list(source, list) | ||||
|         emails = [] | ||||
|         user_ids = [] | ||||
|         users = [] | ||||
|         existing_members = {} | ||||
| 
 | ||||
|         list.each do |item| | ||||
|           case item | ||||
|           when User | ||||
|             users << item | ||||
|           when Integer | ||||
|             user_ids << item | ||||
|           when /\A\d+\Z/ | ||||
|             user_ids << item.to_i | ||||
|           when Devise.email_regexp | ||||
|             emails << item | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         # the below will automatically discard invalid user_ids | ||||
|         users.concat(User.id_in(user_ids)) if user_ids.present? | ||||
|         # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times | ||||
|         users.uniq! | ||||
| 
 | ||||
|         users_by_emails = source.users_by_emails(emails) # preloads our request store for all emails | ||||
|         # in case emails belong to a user that is being invited by user or user_id, remove them from | ||||
|         # emails and let users/user_ids handle it. | ||||
|         parsed_emails = emails.select do |email| | ||||
|           user = users_by_emails[email] | ||||
|           !user || (users.exclude?(user) && user_ids.exclude?(user.id)) | ||||
|         end | ||||
| 
 | ||||
|         if users.present? || users_by_emails.present? | ||||
|           # helps not have to perform another query per user id to see if the member exists later on when fetching | ||||
|           existing_members = source.members_and_requesters.with_user(users + users_by_emails.values).index_by(&:user_id) | ||||
|         end | ||||
| 
 | ||||
|         [parsed_emails, users, existing_members] | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def initialize(source, user, access_level, **args) | ||||
|  | @ -21,10 +120,12 @@ module Members | |||
|       @args = args | ||||
|     end | ||||
| 
 | ||||
|     private_class_method :new | ||||
| 
 | ||||
|     def execute | ||||
|       find_or_build_member | ||||
|       commit_member | ||||
|       create_member_task | ||||
|       after_commit_tasks | ||||
| 
 | ||||
|       member | ||||
|     end | ||||
|  | @ -92,6 +193,10 @@ module Members | |||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def after_commit_tasks | ||||
|       create_member_task | ||||
|     end | ||||
| 
 | ||||
|     def create_member_task | ||||
|       return unless member.persisted? | ||||
|       return if member_task_attributes.value?(nil) | ||||
|  | @ -163,15 +268,19 @@ module Members | |||
|     end | ||||
| 
 | ||||
|     def find_or_initialize_member_by_user | ||||
|       # have to use members and requesters here since project/group limits on requested_at being nil for members and | ||||
|       # wouldn't be found in `source.members` if it already existed | ||||
|       # this of course will not treat active invites the same since we aren't searching on email | ||||
|       source.members_and_requesters.find_or_initialize_by(user_id: user.id) # rubocop:disable CodeReuse/ActiveRecord | ||||
|       # We have to use `members_and_requesters` here since the given `members` is modified in the models | ||||
|       # to act more like a scope(removing the requested_at members) and therefore ActiveRecord has issues with that | ||||
|       # on build and refreshing that relation. | ||||
|       existing_members[user.id] || source.members_and_requesters.build(user_id: user.id) # rubocop:disable CodeReuse/ActiveRecord | ||||
|     end | ||||
| 
 | ||||
|     def ldap | ||||
|       args[:ldap] || false | ||||
|     end | ||||
| 
 | ||||
|     def existing_members | ||||
|       args[:existing_members] || {} | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,15 +0,0 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Members | ||||
|   module Groups | ||||
|     class BulkCreatorService < Members::Groups::CreatorService | ||||
|       include Members::BulkCreateUsers | ||||
| 
 | ||||
|       class << self | ||||
|         def cannot_manage_owners?(source, current_user) | ||||
|           source.max_member_access_for_user(current_user) < Gitlab::Access::OWNER | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -3,6 +3,12 @@ | |||
| module Members | ||||
|   module Groups | ||||
|     class CreatorService < Members::CreatorService | ||||
|       class << self | ||||
|         def cannot_manage_owners?(source, current_user) | ||||
|           source.max_member_access_for_user(current_user) < Gitlab::Access::OWNER | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       private | ||||
| 
 | ||||
|       def can_create_new_member? | ||||
|  |  | |||
|  | @ -1,15 +0,0 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Members | ||||
|   module Projects | ||||
|     class BulkCreatorService < Members::Projects::CreatorService | ||||
|       include Members::BulkCreateUsers | ||||
| 
 | ||||
|       class << self | ||||
|         def cannot_manage_owners?(source, current_user) | ||||
|           !Ability.allowed?(current_user, :manage_owners, source) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -3,6 +3,12 @@ | |||
| module Members | ||||
|   module Projects | ||||
|     class CreatorService < Members::CreatorService | ||||
|       class << self | ||||
|         def cannot_manage_owners?(source, current_user) | ||||
|           !Ability.allowed?(current_user, :manage_owners, source) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       private | ||||
| 
 | ||||
|       def can_create_new_member? | ||||
|  |  | |||
|  | @ -104,7 +104,7 @@ class WebHookService | |||
| 
 | ||||
|   def async_execute | ||||
|     Gitlab::ApplicationContext.with_context(hook.application_context) do | ||||
|       break log_rate_limited if rate_limited? | ||||
|       break log_rate_limited if rate_limit! | ||||
|       break log_recursion_blocked if recursion_blocked? | ||||
| 
 | ||||
|       params = { | ||||
|  | @ -215,24 +215,16 @@ class WebHookService | |||
|     string_size_limit(response_body, RESPONSE_BODY_SIZE_LIMIT) | ||||
|   end | ||||
| 
 | ||||
|   def rate_limited? | ||||
|     return false if rate_limit.nil? | ||||
| 
 | ||||
|     Gitlab::ApplicationRateLimiter.throttled?( | ||||
|       :web_hook_calls, | ||||
|       scope: [hook], | ||||
|       threshold: rate_limit | ||||
|     ) | ||||
|   # Increments rate-limit counter. | ||||
|   # Returns true if hook should be rate-limited. | ||||
|   def rate_limit! | ||||
|     Gitlab::WebHooks::RateLimiter.new(hook).rate_limit! | ||||
|   end | ||||
| 
 | ||||
|   def recursion_blocked? | ||||
|     Gitlab::WebHooks::RecursionDetection.block?(hook) | ||||
|   end | ||||
| 
 | ||||
|   def rate_limit | ||||
|     @rate_limit ||= hook.rate_limit | ||||
|   end | ||||
| 
 | ||||
|   def log_rate_limited | ||||
|     log_auth_error('Webhook rate limit exceeded') | ||||
|   end | ||||
|  |  | |||
|  | @ -11,6 +11,6 @@ | |||
|   %p | ||||
|     = s_('403|Please contact your GitLab administrator to get permission.') | ||||
|   .action-container.js-go-back{ hidden: true } | ||||
|     %button{ type: 'button', class: 'gl-button btn btn-success' } | ||||
|     = render Pajamas::ButtonComponent.new(variant: :confirm) do | ||||
|       = _('Go Back') | ||||
| = render "errors/footer" | ||||
|  |  | |||
|  | @ -52,5 +52,4 @@ | |||
|   .settings-content | ||||
|     = render 'groups/settings/ci_cd/auto_devops_form', group: @group | ||||
| 
 | ||||
| - if ::Feature.enabled?(:group_level_protected_environment, @group) | ||||
|   = render_if_exists 'groups/settings/ci_cd/protected_environments', expanded: expanded | ||||
| = render_if_exists 'groups/settings/ci_cd/protected_environments', expanded: expanded | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| - return unless can_admin_project_member?(project) | ||||
| 
 | ||||
| .js-invite-members-modal{ data: { is_project: 'true', | ||||
|   access_levels: ProjectMember.access_level_roles.to_json, | ||||
|   access_levels: ProjectMember.permissible_access_level_roles(current_user, project).to_json, | ||||
|   help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) } | ||||
|  |  | |||
|  | @ -4,12 +4,13 @@ | |||
|   .card | ||||
|     .card-header | ||||
|       Domains (#{@domains.size}) | ||||
|     %ul.list-group.list-group-flush.pages-domain-list{ class: ("has-verification-status" if verification_enabled) } | ||||
|     %ul.list-group.list-group-flush | ||||
|       - @domains.each do |domain| | ||||
|         %li.pages-domain-list-item.list-group-item.d-flex.justify-content-between | ||||
|         %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center | ||||
|           .gl-display-flex.gl-align-items-center | ||||
|             - if verification_enabled | ||||
|               - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success'] | ||||
|             .domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip } | ||||
|               .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip } | ||||
|                 = sprite_icon("status_#{status}" ) | ||||
|             .domain-name | ||||
|               = external_link(domain.url, domain.url) | ||||
|  |  | |||
|  | @ -19,10 +19,10 @@ | |||
|       .col-sm-2 | ||||
|         = _("Verification status") | ||||
|       .col-sm-10 | ||||
|         .status-badge | ||||
|         .gl-mb-3 | ||||
|           - text, status = domain_presenter.unverified? ? [_('Unverified'), :danger] : [_('Verified'), :success] | ||||
|           = gl_badge_tag text, variant: status | ||||
|           = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-ml-2 gl-button btn btn-default has-tooltip", title: _("Retry verification") | ||||
|           = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-ml-2 gl-button btn btn-sm btn-default has-tooltip", title: _("Retry verification") | ||||
|         .input-group | ||||
|           = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true | ||||
|           .input-group-append | ||||
|  |  | |||
|  | @ -36,7 +36,7 @@ | |||
|           resource: @project, | ||||
|           token: @resource_access_token, | ||||
|           scopes: @scopes, | ||||
|           access_levels: permissible_access_level_roles(current_user, @project), | ||||
|           access_levels: ProjectMember.permissible_access_level_roles(current_user, @project), | ||||
|           default_access_level: Gitlab::Access::MAINTAINER, | ||||
|           prefix: :resource_access_token, | ||||
|           help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token') | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| --- | ||||
| name: group_level_protected_environment | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88506 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/363450 | ||||
| name: ci_variable_settings_graphql | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89332 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364423 | ||||
| milestone: '15.1' | ||||
| type: development | ||||
| group: group::release | ||||
| group: group::pipeline authoring | ||||
| default_enabled: false | ||||
|  | @ -0,0 +1,8 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddWebHookCallsMedAndMaxToPlanLimits < Gitlab::Database::Migration[2.0] | ||||
|   def change | ||||
|     add_column :plan_limits, :web_hook_calls_mid, :integer, null: false, default: 0 | ||||
|     add_column :plan_limits, :web_hook_calls_low, :integer, null: false, default: 0 | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,81 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddWebHookCallsToPlanLimitsPaidTiers < Gitlab::Database::Migration[2.0] | ||||
|   restrict_gitlab_migration gitlab_schema: :gitlab_main | ||||
| 
 | ||||
|   MAX_RATE_LIMIT_NAME = 'web_hook_calls' | ||||
|   MID_RATE_LIMIT_NAME = 'web_hook_calls_mid' | ||||
|   MIN_RATE_LIMIT_NAME = 'web_hook_calls_low' | ||||
| 
 | ||||
|   UP_FREE_LIMITS = { | ||||
|     MAX_RATE_LIMIT_NAME => 500, | ||||
|     MID_RATE_LIMIT_NAME => 500, | ||||
|     MIN_RATE_LIMIT_NAME => 500 | ||||
|   }.freeze | ||||
| 
 | ||||
|   UP_PREMIUM_LIMITS = { | ||||
|     MAX_RATE_LIMIT_NAME => 4_000, | ||||
|     MID_RATE_LIMIT_NAME => 2_800, | ||||
|     MIN_RATE_LIMIT_NAME => 1_600 | ||||
|   }.freeze | ||||
| 
 | ||||
|   UP_ULTIMATE_LIMITS = { | ||||
|     MAX_RATE_LIMIT_NAME => 13_000, | ||||
|     MID_RATE_LIMIT_NAME => 9_000, | ||||
|     MIN_RATE_LIMIT_NAME => 6_000 | ||||
|   }.freeze | ||||
| 
 | ||||
|   DOWN_FREE_LIMITS = { | ||||
|     # 120 is the value for 'free' migrated in `db/migrate/20210601131742_update_web_hook_calls_limit.rb` | ||||
|     MAX_RATE_LIMIT_NAME => 120, | ||||
|     MID_RATE_LIMIT_NAME => 0, | ||||
|     MIN_RATE_LIMIT_NAME => 0 | ||||
|   }.freeze | ||||
| 
 | ||||
|   DOWN_PAID_LIMITS = { | ||||
|     MAX_RATE_LIMIT_NAME => 0, | ||||
|     MID_RATE_LIMIT_NAME => 0, | ||||
|     MIN_RATE_LIMIT_NAME => 0 | ||||
|   }.freeze | ||||
| 
 | ||||
|   def up | ||||
|     return unless Gitlab.com? | ||||
| 
 | ||||
|     apply_limits('free', UP_FREE_LIMITS) | ||||
| 
 | ||||
|     # Apply Premium limits | ||||
|     apply_limits('bronze', UP_PREMIUM_LIMITS) | ||||
|     apply_limits('silver', UP_PREMIUM_LIMITS) | ||||
|     apply_limits('premium', UP_PREMIUM_LIMITS) | ||||
|     apply_limits('premium_trial', UP_PREMIUM_LIMITS) | ||||
| 
 | ||||
|     # Apply Ultimate limits | ||||
|     apply_limits('gold', UP_ULTIMATE_LIMITS) | ||||
|     apply_limits('ultimate', UP_ULTIMATE_LIMITS) | ||||
|     apply_limits('ultimate_trial', UP_ULTIMATE_LIMITS) | ||||
|     apply_limits('opensource', UP_ULTIMATE_LIMITS) | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     return unless Gitlab.com? | ||||
| 
 | ||||
|     apply_limits('free', DOWN_FREE_LIMITS) | ||||
| 
 | ||||
|     apply_limits('bronze', DOWN_PAID_LIMITS) | ||||
|     apply_limits('silver', DOWN_PAID_LIMITS) | ||||
|     apply_limits('premium', DOWN_PAID_LIMITS) | ||||
|     apply_limits('premium_trial', DOWN_PAID_LIMITS) | ||||
|     apply_limits('gold', DOWN_PAID_LIMITS) | ||||
|     apply_limits('ultimate', DOWN_PAID_LIMITS) | ||||
|     apply_limits('ultimate_trial', DOWN_PAID_LIMITS) | ||||
|     apply_limits('opensource', DOWN_PAID_LIMITS) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def apply_limits(plan_name, limits) | ||||
|     limits.each_pair do |limit_name, limit| | ||||
|       create_or_update_plan_limit(limit_name, plan_name, limit) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,13 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddTimestampsToComplianceFrameworks < Gitlab::Database::Migration[2.0] | ||||
|   def up | ||||
|     add_column :compliance_management_frameworks, :created_at, :datetime_with_timezone, null: true | ||||
|     add_column :compliance_management_frameworks, :updated_at, :datetime_with_timezone, null: true | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     remove_column :compliance_management_frameworks, :created_at | ||||
|     remove_column :compliance_management_frameworks, :updated_at | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,9 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddHasVulnerabilitiesToClusterAgents < Gitlab::Database::Migration[2.0] | ||||
|   enable_lock_retries! | ||||
| 
 | ||||
|   def change | ||||
|     add_column :cluster_agents, :has_vulnerabilities, :boolean, default: false, null: false | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,17 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddCreatedAtIndexToComplianceManagementFrameworks < Gitlab::Database::Migration[2.0] | ||||
|   disable_ddl_transaction! | ||||
| 
 | ||||
|   INDEX_NAME = "i_compliance_frameworks_on_id_and_created_at" | ||||
| 
 | ||||
|   def up | ||||
|     add_concurrent_index :compliance_management_frameworks, | ||||
|                          [:id, :created_at, :pipeline_configuration_full_path], | ||||
|                          name: INDEX_NAME | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     remove_concurrent_index_by_name :compliance_management_frameworks, INDEX_NAME | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,17 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddIndexOnClustersAgentProjectIdAndHasVulnerabilitiesColumns < Gitlab::Database::Migration[2.0] | ||||
|   disable_ddl_transaction! | ||||
| 
 | ||||
|   INDEX_NAME = 'index_cluster_agents_on_project_id_and_has_vulnerabilities' | ||||
| 
 | ||||
|   def up | ||||
|     add_concurrent_index :cluster_agents, | ||||
|                          [:project_id, :has_vulnerabilities], | ||||
|                          name: INDEX_NAME | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     remove_concurrent_index_by_name :cluster_agents, INDEX_NAME | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1 @@ | |||
| 80535374849c10d41663d339b95b9ffddbec9b40a8af4585c18602cbe92c14d1 | ||||
|  | @ -0,0 +1 @@ | |||
| 92a7ed079521ccb8ab04e59826947778c37bccd30d47f1b0e29727f769e3ff32 | ||||
|  | @ -0,0 +1 @@ | |||
| f49e691c46ddaaf1b18d95726e7c2473fab946ea79885727ba09bb92591e4a01 | ||||
|  | @ -0,0 +1 @@ | |||
| 96d899efc1fa39cf3433987ee4d8062456f7a6af6248b97eda2ddc5491dcf7f5 | ||||
|  | @ -0,0 +1 @@ | |||
| bbfcaf59734b67142b237b7ea479c5eaa3c2152cdd84c87ad541e5a0e75466ef | ||||
|  | @ -0,0 +1 @@ | |||
| 33456ce3af299e010011b1346b4097ffa1ee642ffb90d342ea22171c3f079d7a | ||||
|  | @ -13346,6 +13346,7 @@ CREATE TABLE cluster_agents ( | |||
|     project_id bigint NOT NULL, | ||||
|     name text NOT NULL, | ||||
|     created_by_user_id bigint, | ||||
|     has_vulnerabilities boolean DEFAULT false NOT NULL, | ||||
|     CONSTRAINT check_3498369510 CHECK ((char_length(name) <= 255)) | ||||
| ); | ||||
| 
 | ||||
|  | @ -13795,6 +13796,8 @@ CREATE TABLE compliance_management_frameworks ( | |||
|     color text NOT NULL, | ||||
|     namespace_id integer NOT NULL, | ||||
|     pipeline_configuration_full_path text, | ||||
|     created_at timestamp with time zone, | ||||
|     updated_at timestamp with time zone, | ||||
|     CONSTRAINT check_08cd34b2c2 CHECK ((char_length(color) <= 10)), | ||||
|     CONSTRAINT check_1617e0b87e CHECK ((char_length(description) <= 255)), | ||||
|     CONSTRAINT check_ab00bc2193 CHECK ((char_length(name) <= 255)), | ||||
|  | @ -18780,7 +18783,9 @@ CREATE TABLE plan_limits ( | |||
|     pipeline_triggers integer DEFAULT 25000 NOT NULL, | ||||
|     project_ci_secure_files integer DEFAULT 100 NOT NULL, | ||||
|     repository_size bigint DEFAULT 0 NOT NULL, | ||||
|     security_policy_scan_execution_schedules integer DEFAULT 0 NOT NULL | ||||
|     security_policy_scan_execution_schedules integer DEFAULT 0 NOT NULL, | ||||
|     web_hook_calls_mid integer DEFAULT 0 NOT NULL, | ||||
|     web_hook_calls_low integer DEFAULT 0 NOT NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE SEQUENCE plan_limits_id_seq | ||||
|  | @ -26767,6 +26772,8 @@ CREATE INDEX i_batched_background_migration_job_transition_logs_on_job_id ON ONL | |||
| 
 | ||||
| CREATE UNIQUE INDEX i_ci_job_token_project_scope_links_on_source_and_target_project ON ci_job_token_project_scope_links USING btree (source_project_id, target_project_id); | ||||
| 
 | ||||
| CREATE INDEX i_compliance_frameworks_on_id_and_created_at ON compliance_management_frameworks USING btree (id, created_at, pipeline_configuration_full_path); | ||||
| 
 | ||||
| CREATE INDEX idx_analytics_devops_adoption_segments_on_namespace_id ON analytics_devops_adoption_segments USING btree (namespace_id); | ||||
| 
 | ||||
| CREATE INDEX idx_analytics_devops_adoption_snapshots_finalized ON analytics_devops_adoption_snapshots USING btree (namespace_id, end_time) WHERE (recorded_at >= end_time); | ||||
|  | @ -27543,6 +27550,8 @@ CREATE UNIQUE INDEX index_cluster_agent_tokens_on_token_encrypted ON cluster_age | |||
| 
 | ||||
| CREATE INDEX index_cluster_agents_on_created_by_user_id ON cluster_agents USING btree (created_by_user_id); | ||||
| 
 | ||||
| CREATE INDEX index_cluster_agents_on_project_id_and_has_vulnerabilities ON cluster_agents USING btree (project_id, has_vulnerabilities); | ||||
| 
 | ||||
| CREATE UNIQUE INDEX index_cluster_agents_on_project_id_and_name ON cluster_agents USING btree (project_id, name); | ||||
| 
 | ||||
| CREATE UNIQUE INDEX index_cluster_enabled_grants_on_namespace_id ON cluster_enabled_grants USING btree (namespace_id); | ||||
|  |  | |||
|  | @ -133,8 +133,9 @@ Limit the maximum daily member invitations allowed per group hierarchy. | |||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61151) in GitLab 13.12. | ||||
| > - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/330133) in GitLab 14.1. | ||||
| > - [Limit changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89591) from per-hook to per-top-level namespace in GitLab 15.1. | ||||
| 
 | ||||
| Limit the number of times any given webhook can be called per minute. | ||||
| Limit the number of times a webhook can be called per minute, per top-level namespace. | ||||
| This only applies to project and group webhooks. | ||||
| 
 | ||||
| Calls over the rate limit are logged into `auth.log`. | ||||
|  |  | |||
|  | @ -22,9 +22,9 @@ levels are defined in the `Gitlab::Access` module. Currently, these levels are v | |||
| - Maintainer (`40`) | ||||
| - Owner (`50`) - Only valid to set for groups | ||||
| 
 | ||||
| WARNING: | ||||
| Due to [an issue](https://gitlab.com/gitlab-org/gitlab/-/issues/219299), | ||||
| projects in personal namespaces don't show owner (`50`) permission. | ||||
| NOTE: | ||||
| From [GitLab 14.9](https://gitlab.com/gitlab-org/gitlab/-/issues/351211) and later, projects have a maximum role of Owner. | ||||
| Because of a [known issue](https://gitlab.com/gitlab-org/gitlab/-/issues/219299) in GitLab 14.8 and earlier, projects have a maximum role of Maintainer. | ||||
| 
 | ||||
| ## Add a member to a group or project | ||||
| 
 | ||||
|  |  | |||
|  | @ -233,7 +233,7 @@ To protect a group-level environment, make sure your environments have the corre | |||
| 
 | ||||
| #### Using the UI | ||||
| 
 | ||||
| > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/325249) in GitLab 15.1 with a flag named `group_level_protected_environment`. Disabled by default. | ||||
| > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/325249) in GitLab 15.1. | ||||
| 
 | ||||
| 1. On the top bar, select **Menu > Groups** and find your group. | ||||
| 1. On the left sidebar, select **Settings > CI/CD**. | ||||
|  |  | |||
|  | @ -407,6 +407,10 @@ The requirements are the same as the previous settings: | |||
|   } } | ||||
| ``` | ||||
| 
 | ||||
| ## Group Sync | ||||
| 
 | ||||
| For information on automatically managing GitLab group membership, see [SAML Group Sync](../user/group/saml_sso/group_sync.md). | ||||
| 
 | ||||
| ## Bypass two factor authentication | ||||
| 
 | ||||
| If you want some SAML authentication methods to count as 2FA on a per session | ||||
|  |  | |||
|  | @ -234,7 +234,7 @@ The following limits apply for [webhooks](../project/integrations/webhooks.md): | |||
| 
 | ||||
| | Setting              | Default for GitLab.com  | | ||||
| |----------------------|-------------------------| | ||||
| | Webhook rate limit   | `120` calls per minute for GitLab Free, unlimited for GitLab Premium and GitLab Ultimate | | ||||
| | Webhook rate limit   | `500` calls per minute for GitLab Free, unlimited for GitLab Premium and GitLab Ultimate. Webhook rate limits are applied per top-level namespace. | | ||||
| | Number of webhooks   | `100` per project, `50` per group | | ||||
| | Maximum payload size | 25 MB                   | | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,161 @@ | |||
| --- | ||||
| type: reference, howto | ||||
| stage: Manage | ||||
| group: Authentication and Authorization | ||||
| info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments | ||||
| --- | ||||
| 
 | ||||
| # SAML Group Sync **(PREMIUM)** | ||||
| 
 | ||||
| > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363084) for self-managed instances in GitLab 15.1. | ||||
| 
 | ||||
| WARNING: | ||||
| Changing Group Sync configuration can remove users from the mapped GitLab group. | ||||
| Removal happens if there is any mismatch between the group names and the list of `groups` in the SAML response. | ||||
| If changes must be made, ensure either the SAML response includes the `groups` attribute | ||||
| and the `AttributeValue` value matches the **SAML Group Name** in GitLab, | ||||
| or that all groups are removed from GitLab to disable Group Sync. | ||||
| 
 | ||||
| <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> | ||||
| For a demo of Group Sync using Azure, see [Demo: SAML Group Sync](https://youtu.be/Iqvo2tJfXjg). | ||||
| 
 | ||||
| ## Configure SAML Group Sync | ||||
| 
 | ||||
| To configure SAML Group Sync: | ||||
| 
 | ||||
| 1. Configure SAML authentication: | ||||
|    - For GitLab self-managed, see [SAML OmniAuth Provider](../../../integration/saml.md). | ||||
|    - For GitLab.com, see [SAML SSO for GitLab.com groups](index.md). | ||||
| 1. Ensure your SAML identity provider sends an attribute statement named `Groups` or `groups`. | ||||
| 
 | ||||
| NOTE: | ||||
| The value for `Groups` or `groups` in the SAML response can be either the group name or the group ID. | ||||
| 
 | ||||
| ```xml | ||||
| <saml:AttributeStatement> | ||||
|   <saml:Attribute Name="Groups"> | ||||
|     <saml:AttributeValue xsi:type="xs:string">Developers</saml:AttributeValue> | ||||
|     <saml:AttributeValue xsi:type="xs:string">Product Managers</saml:AttributeValue> | ||||
|   </saml:Attribute> | ||||
| </saml:AttributeStatement> | ||||
| ``` | ||||
| 
 | ||||
| Other attribute names such as `http://schemas.microsoft.com/ws/2008/06/identity/claims/groups` | ||||
| are not accepted as a source of groups. | ||||
| See the [SAML troubleshooting page](../../../administration/troubleshooting/group_saml_scim.md) | ||||
| for examples on configuring the required attribute name in the SAML identity provider's settings. | ||||
| 
 | ||||
| ## Configure SAML Group Links | ||||
| 
 | ||||
| When SAML is enabled, users with the Maintainer or Owner role | ||||
| see a new menu item in group **Settings > SAML Group Links**. You can configure one or more **SAML Group Links** to map | ||||
| a SAML identity provider group name to a GitLab role. This can be done for a top-level group or any subgroup. | ||||
| 
 | ||||
| To link the SAML groups: | ||||
| 
 | ||||
| 1. In **SAML Group Name**, enter the value of the relevant `saml:AttributeValue`. | ||||
| 1. Choose the role in **Access Level**. | ||||
| 1. Select **Save**. | ||||
| 1. Repeat to add additional group links if required. | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| If a user is a member of multiple SAML groups mapped to the same GitLab group, | ||||
| the user gets the highest role from the groups. For example, if one group | ||||
| is linked as Guest and another Maintainer, a user in both groups gets the Maintainer | ||||
| role. | ||||
| 
 | ||||
| Users granted: | ||||
| 
 | ||||
| - A higher role with Group Sync are displayed as having | ||||
|   [direct membership](../../project/members/#display-direct-members) of the group. | ||||
| - A lower or the same role with Group Sync are displayed as having | ||||
|   [inherited membership](../../project/members/#display-inherited-members) of the group. | ||||
| 
 | ||||
| ### Automatic member removal | ||||
| 
 | ||||
| After a group sync, for GitLab subgroups, users who are not members of a mapped SAML | ||||
| group are removed from the group. | ||||
| 
 | ||||
| FLAG: | ||||
| In [GitLab 15.1 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/364144), on GitLab.com, users in the top-level | ||||
| group are assigned the [default membership role](index.md#role) rather than removed. This setting is enabled with the | ||||
| `saml_group_sync_retain_default_membership` feature flag and can be configured by GitLab.com administrators only. | ||||
| 
 | ||||
| For example, in the following diagram: | ||||
| 
 | ||||
| - Alex Garcia signs into GitLab and is removed from GitLab Group C because they don't belong | ||||
|   to SAML Group C. | ||||
| - Sidney Jones belongs to SAML Group C, but is not added to GitLab Group C because they have | ||||
|   not yet signed in. | ||||
| 
 | ||||
| ```mermaid | ||||
| graph TB | ||||
|    subgraph SAML users | ||||
|       SAMLUserA[Sidney Jones] | ||||
|       SAMLUserB[Zhang Wei] | ||||
|       SAMLUserC[Alex Garcia] | ||||
|       SAMLUserD[Charlie Smith] | ||||
|    end | ||||
| 
 | ||||
|    subgraph SAML groups | ||||
|       SAMLGroupA["Group A"] --> SAMLGroupB["Group B"] | ||||
|       SAMLGroupA --> SAMLGroupC["Group C"] | ||||
|       SAMLGroupA --> SAMLGroupD["Group D"] | ||||
|    end | ||||
| 
 | ||||
|    SAMLGroupB --> |Member|SAMLUserA | ||||
|    SAMLGroupB --> |Member|SAMLUserB | ||||
| 
 | ||||
|    SAMLGroupC --> |Member|SAMLUserA | ||||
|    SAMLGroupC --> |Member|SAMLUserB | ||||
| 
 | ||||
|    SAMLGroupD --> |Member|SAMLUserD | ||||
|    SAMLGroupD --> |Member|SAMLUserC | ||||
| ``` | ||||
| 
 | ||||
| ```mermaid | ||||
| graph TB | ||||
|     subgraph GitLab users | ||||
|       GitLabUserA[Sidney Jones] | ||||
|       GitLabUserB[Zhang Wei] | ||||
|       GitLabUserC[Alex Garcia] | ||||
|       GitLabUserD[Charlie Smith] | ||||
|     end | ||||
| 
 | ||||
|    subgraph GitLab groups | ||||
|       GitLabGroupA["Group A (SAML configured)"] --> GitLabGroupB["Group B (SAML Group Link not configured)"] | ||||
|       GitLabGroupA --> GitLabGroupC["Group C (SAML Group Link configured)"] | ||||
|       GitLabGroupA --> GitLabGroupD["Group D (SAML Group Link configured)"] | ||||
|    end | ||||
| 
 | ||||
|    GitLabGroupB --> |Member|GitLabUserA | ||||
| 
 | ||||
|    GitLabGroupC --> |Member|GitLabUserB | ||||
|    GitLabGroupC --> |Member|GitLabUserC | ||||
| 
 | ||||
|    GitLabGroupD --> |Member|GitLabUserC | ||||
|    GitLabGroupD --> |Member|GitLabUserD | ||||
| ``` | ||||
| 
 | ||||
| ```mermaid | ||||
| graph TB | ||||
|    subgraph GitLab users | ||||
|       GitLabUserA[Sidney Jones] | ||||
|       GitLabUserB[Zhang Wei] | ||||
|       GitLabUserC[Alex Garcia] | ||||
|       GitLabUserD[Charlie Smith] | ||||
|    end | ||||
| 
 | ||||
|    subgraph GitLab groups after Alex Garcia signs in | ||||
|       GitLabGroupA[Group A] | ||||
|       GitLabGroupA["Group A (SAML configured)"] --> GitLabGroupB["Group B (SAML Group Link not configured)"] | ||||
|       GitLabGroupA --> GitLabGroupC["Group C (SAML Group Link configured)"] | ||||
|       GitLabGroupA --> GitLabGroupD["Group D (SAML Group Link configured)"] | ||||
|    end | ||||
| 
 | ||||
|    GitLabGroupB --> |Member|GitLabUserA | ||||
|    GitLabGroupC --> |Member|GitLabUserB | ||||
|    GitLabGroupD --> |Member|GitLabUserC | ||||
|    GitLabGroupD --> |Member|GitLabUserD | ||||
| ``` | ||||
|  | @ -372,7 +372,7 @@ To rescind a user's access to the group when only SAML SSO is configured, either | |||
| - Remove (in order) the user from: | ||||
|   1. The user data store on the identity provider or the list of users on the specific app. | ||||
|   1. The GitLab.com group. | ||||
| - Use Group Sync at the top-level of your group to [automatically remove the user](#automatic-member-removal). | ||||
| - Use Group Sync at the top-level of your group to [automatically remove the user](group_sync.md#automatic-member-removal). | ||||
| 
 | ||||
| To rescind a user's access to the group when also using SCIM, refer to [Blocking access](scim_setup.md#blocking-access). | ||||
| 
 | ||||
|  | @ -402,151 +402,7 @@ For example, to unlink the `MyOrg` account: | |||
| 
 | ||||
| ## Group Sync | ||||
| 
 | ||||
| WARNING: | ||||
| Changing Group Sync configuration can remove users from the relevant GitLab group. | ||||
| Removal happens if there is any mismatch between the group names and the list of `groups` in the SAML response. | ||||
| If changes must be made, ensure either the SAML response includes the `groups` attribute | ||||
| and the `AttributeValue` value matches the **SAML Group Name** in GitLab, | ||||
| or that all groups are removed from GitLab to disable Group Sync. | ||||
| 
 | ||||
| <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> | ||||
| For a demo of Group Sync using Azure, see [Demo: SAML Group Sync](https://youtu.be/Iqvo2tJfXjg). | ||||
| 
 | ||||
| When the SAML response includes a user and their group memberships from the SAML identity provider, | ||||
| GitLab uses that information to automatically manage that user's GitLab group memberships. | ||||
| 
 | ||||
| Ensure your SAML identity provider sends an attribute statement named `Groups` or `groups` like the following: | ||||
| 
 | ||||
| ```xml | ||||
| <saml:AttributeStatement> | ||||
|   <saml:Attribute Name="Groups"> | ||||
|     <saml:AttributeValue xsi:type="xs:string">Developers</saml:AttributeValue> | ||||
|     <saml:AttributeValue xsi:type="xs:string">Product Managers</saml:AttributeValue> | ||||
|   </saml:Attribute> | ||||
| </saml:AttributeStatement> | ||||
| ``` | ||||
| 
 | ||||
| Other attribute names such as `http://schemas.microsoft.com/ws/2008/06/identity/claims/groups` | ||||
| are not accepted as a source of groups. | ||||
| See the [SAML troubleshooting page](../../../administration/troubleshooting/group_saml_scim.md) | ||||
| for examples on configuring the required attribute name in the SAML identity provider's settings. | ||||
| 
 | ||||
| NOTE: | ||||
| The value for `Groups` or `groups` in the SAML response can be either the group name or the group ID. | ||||
| To inspect the SAML response, you can use one of these [SAML debugging tools](#saml-debugging-tools). | ||||
| 
 | ||||
| When SAML SSO is enabled for the top-level group, `Maintainer` and `Owner` level users | ||||
| see a new menu item in group **Settings > SAML Group Links**. You can configure one or more **SAML Group Links** to map | ||||
| a SAML identity provider group name to a GitLab Access Level. This can be done for the parent group or the subgroups. | ||||
| 
 | ||||
| To link the SAML groups from the `saml:AttributeStatement` example above: | ||||
| 
 | ||||
| 1. In the **SAML Group Name** box, enter the value of `saml:AttributeValue`. | ||||
| 1. Choose the desired **Access Level**. | ||||
| 1. **Save** the group link. | ||||
| 1. Repeat to add additional group links if desired. | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| If a user is a member of multiple SAML groups mapped to the same GitLab group, | ||||
| the user gets the highest access level from the groups. For example, if one group | ||||
| is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer` | ||||
| access. | ||||
| 
 | ||||
| Users granted: | ||||
| 
 | ||||
| - A higher role with Group Sync are displayed as having | ||||
|   [direct membership](../../project/members/#display-direct-members) of the group. | ||||
| - A lower or the same role with Group Sync are displayed as having | ||||
|   [inherited membership](../../project/members/#display-inherited-members) of the group. | ||||
| 
 | ||||
| ### Automatic member removal | ||||
| 
 | ||||
| After a group sync, for GitLab subgroups, users who are not members of a mapped SAML | ||||
| group are removed from the group. | ||||
| 
 | ||||
| FLAG: | ||||
| In [GitLab 15.1 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/364144), on GitLab.com, users in the top-level  | ||||
| group are assigned the [default membership role](#role) rather than removed. This setting is enabled with the  | ||||
| `saml_group_sync_retain_default_membership` feature flag and can be configured by GitLab.com administrators only. | ||||
| 
 | ||||
| For example, in the following diagram: | ||||
| 
 | ||||
| - Alex Garcia signs into GitLab and is removed from GitLab Group C because they don't belong | ||||
|   to SAML Group C. | ||||
| - Sidney Jones belongs to SAML Group C, but is not added to GitLab Group C because they have | ||||
|   not yet signed in. | ||||
| 
 | ||||
| ```mermaid | ||||
| graph TB | ||||
|    subgraph SAML users | ||||
|       SAMLUserA[Sidney Jones] | ||||
|       SAMLUserB[Zhang Wei] | ||||
|       SAMLUserC[Alex Garcia] | ||||
|       SAMLUserD[Charlie Smith] | ||||
|    end | ||||
| 
 | ||||
|    subgraph SAML groups | ||||
|       SAMLGroupA["Group A"] --> SAMLGroupB["Group B"] | ||||
|       SAMLGroupA --> SAMLGroupC["Group C"] | ||||
|       SAMLGroupA --> SAMLGroupD["Group D"] | ||||
|    end | ||||
| 
 | ||||
|    SAMLGroupB --> |Member|SAMLUserA | ||||
|    SAMLGroupB --> |Member|SAMLUserB | ||||
| 
 | ||||
|    SAMLGroupC --> |Member|SAMLUserA | ||||
|    SAMLGroupC --> |Member|SAMLUserB | ||||
| 
 | ||||
|    SAMLGroupD --> |Member|SAMLUserD | ||||
|    SAMLGroupD --> |Member|SAMLUserC | ||||
| ``` | ||||
| 
 | ||||
| ```mermaid | ||||
| graph TB | ||||
|     subgraph GitLab users | ||||
|       GitLabUserA[Sidney Jones] | ||||
|       GitLabUserB[Zhang Wei] | ||||
|       GitLabUserC[Alex Garcia] | ||||
|       GitLabUserD[Charlie Smith] | ||||
|     end | ||||
| 
 | ||||
|    subgraph GitLab groups | ||||
|       GitLabGroupA["Group A (SAML configured)"] --> GitLabGroupB["Group B (SAML Group Link not configured)"] | ||||
|       GitLabGroupA --> GitLabGroupC["Group C (SAML Group Link configured)"] | ||||
|       GitLabGroupA --> GitLabGroupD["Group D (SAML Group Link configured)"] | ||||
|    end | ||||
| 
 | ||||
|    GitLabGroupB --> |Member|GitLabUserA | ||||
| 
 | ||||
|    GitLabGroupC --> |Member|GitLabUserB | ||||
|    GitLabGroupC --> |Member|GitLabUserC | ||||
| 
 | ||||
|    GitLabGroupD --> |Member|GitLabUserC | ||||
|    GitLabGroupD --> |Member|GitLabUserD | ||||
| ``` | ||||
| 
 | ||||
| ```mermaid | ||||
| graph TB | ||||
|    subgraph GitLab users | ||||
|       GitLabUserA[Sidney Jones] | ||||
|       GitLabUserB[Zhang Wei] | ||||
|       GitLabUserC[Alex Garcia] | ||||
|       GitLabUserD[Charlie Smith] | ||||
|    end | ||||
| 
 | ||||
|    subgraph GitLab groups after Alex Garcia signs in | ||||
|       GitLabGroupA[Group A] | ||||
|       GitLabGroupA["Group A (SAML configured)"] --> GitLabGroupB["Group B (SAML Group Link not configured)"] | ||||
|       GitLabGroupA --> GitLabGroupC["Group C (SAML Group Link configured)"] | ||||
|       GitLabGroupA --> GitLabGroupD["Group D (SAML Group Link configured)"] | ||||
|    end | ||||
| 
 | ||||
|    GitLabGroupB --> |Member|GitLabUserA | ||||
|    GitLabGroupC --> |Member|GitLabUserB | ||||
|    GitLabGroupD --> |Member|GitLabUserC | ||||
|    GitLabGroupD --> |Member|GitLabUserD | ||||
| ``` | ||||
| For information on automatically managing GitLab group membership, see [SAML Group Sync](group_sync.md). | ||||
| 
 | ||||
| ## Passwords for users created via SAML SSO for Groups | ||||
| 
 | ||||
|  |  | |||
|  | @ -30,6 +30,8 @@ module Atlassian | |||
|         responses.compact | ||||
|       end | ||||
| 
 | ||||
|       # Fetch user information for the given account. | ||||
|       # https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-users/#api-rest-api-3-user-get | ||||
|       def user_info(account_id) | ||||
|         r = get('/rest/api/3/user', { accountId: account_id, expand: 'groups' }) | ||||
| 
 | ||||
|  |  | |||
|  | @ -32,6 +32,8 @@ module Gitlab | |||
|           group_testing_hook:           { threshold: 5, interval: 1.minute }, | ||||
|           profile_add_new_email:        { threshold: 5, interval: 1.minute }, | ||||
|           web_hook_calls:               { interval: 1.minute }, | ||||
|           web_hook_calls_mid:           { interval: 1.minute }, | ||||
|           web_hook_calls_low:           { interval: 1.minute }, | ||||
|           users_get_by_id:              { threshold: -> { application_settings.users_get_by_id_limit }, interval: 10.minutes }, | ||||
|           username_exists:              { threshold: 20, interval: 1.minute }, | ||||
|           user_sign_up:                 { threshold: 20, interval: 1.minute }, | ||||
|  |  | |||
|  | @ -0,0 +1,70 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module WebHooks | ||||
|     class RateLimiter | ||||
|       include Gitlab::Utils::StrongMemoize | ||||
| 
 | ||||
|       LIMIT_NAME = :web_hook_calls | ||||
|       NO_LIMIT = 0 | ||||
|       # SystemHooks (instance admin hooks) and ServiceHooks (integration hooks) | ||||
|       # are not rate-limited. | ||||
|       EXCLUDED_HOOK_TYPES = %w(SystemHook ServiceHook).freeze | ||||
| 
 | ||||
|       def initialize(hook) | ||||
|         @hook = hook | ||||
|         @parent = hook.parent | ||||
|       end | ||||
| 
 | ||||
|       # Increments the rate-limit counter. | ||||
|       # Returns true if the hook should be rate-limited. | ||||
|       def rate_limit! | ||||
|         return false if no_limit? | ||||
| 
 | ||||
|         ::Gitlab::ApplicationRateLimiter.throttled?( | ||||
|           limit_name, | ||||
|           scope: [root_namespace], | ||||
|           threshold: limit | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       # Returns true if the hook is currently over its rate-limit. | ||||
|       # It does not increment the rate-limit counter. | ||||
|       def rate_limited? | ||||
|         return false if no_limit? | ||||
| 
 | ||||
|         Gitlab::ApplicationRateLimiter.peek( | ||||
|           limit_name, | ||||
|           scope: [root_namespace], | ||||
|           threshold: limit | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       def limit | ||||
|         strong_memoize(:limit) do | ||||
|           next NO_LIMIT if hook.class.name.in?(EXCLUDED_HOOK_TYPES) | ||||
| 
 | ||||
|           root_namespace.actual_limits.limit_for(limit_name) || NO_LIMIT | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       private | ||||
| 
 | ||||
|       attr_reader :hook, :parent | ||||
| 
 | ||||
|       def no_limit? | ||||
|         limit == NO_LIMIT | ||||
|       end | ||||
| 
 | ||||
|       def root_namespace | ||||
|         @root_namespace ||= parent.root_ancestor | ||||
|       end | ||||
| 
 | ||||
|       def limit_name | ||||
|         LIMIT_NAME | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| Gitlab::WebHooks::RateLimiter.prepend_mod | ||||
|  | @ -21742,9 +21742,15 @@ msgstr "" | |||
| msgid "Jira-GitLab user mapping template" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|Cannot find namespace. Make sure you have sufficient permissions." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|Configure your Jira Connect Application ID." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|Could not fetch user information from Jira. Check the permissions in Jira and try again." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|Create branch for Jira issue %{jiraIssue}" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -21763,6 +21769,9 @@ msgstr "" | |||
| msgid "JiraConnect|New branch was successfully created." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|The Jira user is not a site administrator. Check the permissions in Jira and try again." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|You can now close this window and return to Jira." | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ module QA | |||
|             element :ci_variable_delete_button | ||||
|           end | ||||
| 
 | ||||
|           view 'app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue' do | ||||
|           view 'app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue' do | ||||
|             element :ci_variable_table_content | ||||
|             element :add_ci_variable_button | ||||
|             element :edit_ci_variable_button | ||||
|  |  | |||
|  | @ -12,8 +12,18 @@ RSpec.describe 'Group variables', :js do | |||
|     group.add_owner(user) | ||||
|     gitlab_sign_in(user) | ||||
|     wait_for_requests | ||||
|   end | ||||
| 
 | ||||
|   context 'with disabled ff `ci_variable_settings_graphql' do | ||||
|     before do | ||||
|       stub_feature_flags(ci_variable_settings_graphql: false) | ||||
|       visit page_path | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'variable list' | ||||
|   end | ||||
| 
 | ||||
|   # TODO: Uncomment when the new graphQL app for variable settings | ||||
|   # is enabled. | ||||
|   # it_behaves_like 'variable list' | ||||
| end | ||||
|  |  | |||
|  | @ -12,6 +12,13 @@ RSpec.describe 'Project variables', :js do | |||
|     sign_in(user) | ||||
|     project.add_maintainer(user) | ||||
|     project.variables << variable | ||||
|   end | ||||
| 
 | ||||
|   # TODO: Add same tests but with FF enabled context when | ||||
|   # the new graphQL app for variable settings is enabled. | ||||
|   context 'with disabled ff `ci_variable_settings_graphql' do | ||||
|     before do | ||||
|       stub_feature_flags(ci_variable_settings_graphql: false) | ||||
|       visit page_path | ||||
|     end | ||||
| 
 | ||||
|  | @ -36,4 +43,5 @@ RSpec.describe 'Project variables', :js do | |||
|         expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*') | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -48,6 +48,33 @@ RSpec.describe 'Projects > Members > Manage members', :js do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when owner' do | ||||
|     it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do | ||||
|       visit_members_page | ||||
| 
 | ||||
|       click_on 'Invite members' | ||||
| 
 | ||||
|       click_on 'Guest' | ||||
|       wait_for_requests | ||||
| 
 | ||||
|       page.within '.dropdown-menu' do | ||||
|         expect(page).to have_button('Guest') | ||||
|         expect(page).to have_button('Reporter') | ||||
|         expect(page).to have_button('Developer') | ||||
|         expect(page).to have_button('Maintainer') | ||||
|         expect(page).to have_button('Owner') | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when maintainer' do | ||||
|     let(:maintainer) { create(:user) } | ||||
| 
 | ||||
|     before do | ||||
|       project.add_maintainer(maintainer) | ||||
|       sign_in(maintainer) | ||||
|     end | ||||
| 
 | ||||
|     it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do | ||||
|       visit_members_page | ||||
| 
 | ||||
|  | @ -64,6 +91,7 @@ RSpec.describe 'Projects > Members > Manage members', :js do | |||
|         expect(page).not_to have_button('Owner') | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   it 'remove user from project' do | ||||
|     other_user = create(:user) | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; | |||
| import { mount } from '@vue/test-utils'; | ||||
| import Vue, { nextTick } from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
| import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; | ||||
| import LegacyCiEnvironmentsDropdown from '~/ci_variable_list/components/legacy_ci_environments_dropdown.vue'; | ||||
| 
 | ||||
| Vue.use(Vuex); | ||||
| 
 | ||||
|  | @ -20,7 +20,7 @@ describe('Ci environments dropdown', () => { | |||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     wrapper = mount(CiEnvironmentsDropdown, { | ||||
|     wrapper = mount(LegacyCiEnvironmentsDropdown, { | ||||
|       store, | ||||
|       propsData: { | ||||
|         value: term, | ||||
|  | @ -4,7 +4,7 @@ import Vue from 'vue'; | |||
| import Vuex from 'vuex'; | ||||
| import { mockTracking } from 'helpers/tracking_helper'; | ||||
| import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; | ||||
| import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; | ||||
| import LegacyCiVariableModal from '~/ci_variable_list/components/legacy_ci_variable_modal.vue'; | ||||
| import { | ||||
|   AWS_ACCESS_KEY_ID, | ||||
|   EVENT_LABEL, | ||||
|  | @ -30,7 +30,7 @@ describe('Ci variable modal', () => { | |||
|       isGroup: options.isGroup, | ||||
|       environmentScopeLink: '/help/environments', | ||||
|     }); | ||||
|     wrapper = method(CiVariableModal, { | ||||
|     wrapper = method(LegacyCiVariableModal, { | ||||
|       attachTo: document.body, | ||||
|       stubs: { | ||||
|         GlModal: ModalStub, | ||||
|  | @ -1,7 +1,7 @@ | |||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import Vue from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
| import CiVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue'; | ||||
| import LegacyCiVariableSettings from '~/ci_variable_list/components/legacy_ci_variable_settings.vue'; | ||||
| import createStore from '~/ci_variable_list/store'; | ||||
| 
 | ||||
| Vue.use(Vuex); | ||||
|  | @ -15,7 +15,7 @@ describe('Ci variable table', () => { | |||
|     store = createStore(); | ||||
|     store.state.isGroup = groupState; | ||||
|     jest.spyOn(store, 'dispatch').mockImplementation(); | ||||
|     wrapper = shallowMount(CiVariableSettings, { | ||||
|     wrapper = shallowMount(LegacyCiVariableSettings, { | ||||
|       store, | ||||
|     }); | ||||
|   }; | ||||
|  | @ -1,7 +1,7 @@ | |||
| import Vue from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
| import { mountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import CiVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; | ||||
| import LegacyCiVariableTable from '~/ci_variable_list/components/legacy_ci_variable_table.vue'; | ||||
| import createStore from '~/ci_variable_list/store'; | ||||
| import mockData from '../services/mock_data'; | ||||
| 
 | ||||
|  | @ -14,7 +14,7 @@ describe('Ci variable table', () => { | |||
|   const createComponent = () => { | ||||
|     store = createStore(); | ||||
|     jest.spyOn(store, 'dispatch').mockImplementation(); | ||||
|     wrapper = mountExtended(CiVariableTable, { | ||||
|     wrapper = mountExtended(LegacyCiVariableTable, { | ||||
|       attachTo: document.body, | ||||
|       store, | ||||
|     }); | ||||
|  | @ -355,30 +355,6 @@ RSpec.describe ProjectsHelper do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#permissible_access_level_roles' do | ||||
|     let_it_be(:owner) { create(:user) } | ||||
|     let_it_be(:maintainer) { create(:user) } | ||||
|     let_it_be(:group) { create(:group) } | ||||
|     let_it_be(:project) { create(:project, group: group) } | ||||
| 
 | ||||
|     before do | ||||
|       project.add_owner(owner) | ||||
|       project.add_maintainer(maintainer) | ||||
|     end | ||||
| 
 | ||||
|     context 'when member can manage owners' do | ||||
|       it 'returns Gitlab::Access.options_with_owner' do | ||||
|         expect(helper.permissible_access_level_roles(owner, project)).to eq(Gitlab::Access.options_with_owner) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when member cannot manage owners' do | ||||
|       it 'returns Gitlab::Access.options' do | ||||
|         expect(helper.permissible_access_level_roles(maintainer, project)).to eq(Gitlab::Access.options) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'default_clone_protocol' do | ||||
|     context 'when user is not logged in and gitlab protocol is HTTP' do | ||||
|       it 'returns HTTP' do | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ require 'spec_helper' | |||
| RSpec.describe Atlassian::JiraConnect::Client do | ||||
|   include StubRequests | ||||
| 
 | ||||
|   subject { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') } | ||||
|   subject(:client) { described_class.new('https://gitlab-test.atlassian.net', 'sample_secret') } | ||||
| 
 | ||||
|   let_it_be(:project) { create_default(:project, :repository) } | ||||
|   let_it_be(:mrs_by_title) { create_list(:merge_request, 4, :unique_branches, :jira_title) } | ||||
|  | @ -413,4 +413,41 @@ RSpec.describe Atlassian::JiraConnect::Client do | |||
|       expect { subject.send(:store_dev_info, project: project, merge_requests: merge_requests) }.not_to exceed_query_limit(control_count) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#user_info' do | ||||
|     let(:account_id) { '12345' } | ||||
|     let(:response_body) do | ||||
|       { | ||||
|         groups: { | ||||
|           items: [ | ||||
|             { name: 'site-admins' } | ||||
|           ] | ||||
|         } | ||||
|       }.to_json | ||||
|     end | ||||
| 
 | ||||
|     before do | ||||
|       stub_full_request("https://gitlab-test.atlassian.net/rest/api/3/user?accountId=#{account_id}&expand=groups") | ||||
|         .to_return(status: response_status, body: response_body, headers: { 'Content-Type': 'application/json' }) | ||||
|     end | ||||
| 
 | ||||
|     context 'with a successful response' do | ||||
|       let(:response_status) { 200 } | ||||
| 
 | ||||
|       it 'returns a JiraUser instance' do | ||||
|         jira_user = client.user_info(account_id) | ||||
| 
 | ||||
|         expect(jira_user).to be_a(Atlassian::JiraConnect::JiraUser) | ||||
|         expect(jira_user).to be_site_admin | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'with a failed response' do | ||||
|       let(:response_status) { 401 } | ||||
| 
 | ||||
|       it 'returns nil' do | ||||
|         expect(client.user_info(account_id)).to be_nil | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -77,6 +77,7 @@ RSpec.describe Gitlab::DatabaseImporters::InstanceAdministrators::CreateGroup do | |||
|         create(:user) | ||||
| 
 | ||||
|         expect(result[:status]).to eq(:success) | ||||
|         group.reset | ||||
|         expect(group.members.collect(&:user)).to contain_exactly(user, admin1, admin2) | ||||
|         expect(group.members.collect(&:access_level)).to contain_exactly( | ||||
|           Gitlab::Access::OWNER, | ||||
|  |  | |||
|  | @ -0,0 +1,123 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::WebHooks::RateLimiter, :clean_gitlab_redis_rate_limiting do | ||||
|   let_it_be(:plan) { create(:default_plan) } | ||||
|   let_it_be_with_reload(:project_hook) { create(:project_hook) } | ||||
|   let_it_be_with_reload(:system_hook) { create(:system_hook) } | ||||
|   let_it_be_with_reload(:integration_hook) { create(:jenkins_integration).service_hook } | ||||
|   let_it_be(:limit) { 1 } | ||||
| 
 | ||||
|   using RSpec::Parameterized::TableSyntax | ||||
| 
 | ||||
|   describe '#rate_limit!' do | ||||
|     def rate_limit!(hook) | ||||
|       described_class.new(hook).rate_limit! | ||||
|     end | ||||
| 
 | ||||
|     shared_examples 'a hook that is never rate limited' do | ||||
|       specify do | ||||
|         expect(Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) | ||||
| 
 | ||||
|         expect(rate_limit!(hook)).to eq(false) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when there is no plan limit' do | ||||
|       where(:hook) { [ref(:project_hook), ref(:system_hook), ref(:integration_hook)] } | ||||
| 
 | ||||
|       with_them { it_behaves_like 'a hook that is never rate limited' } | ||||
|     end | ||||
| 
 | ||||
|     context 'when there is a plan limit' do | ||||
|       before_all do | ||||
|         create(:plan_limits, plan: plan, web_hook_calls: limit) | ||||
|       end | ||||
| 
 | ||||
|       where(:hook, :limitless_hook_type) do | ||||
|         ref(:project_hook)     | false | ||||
|         ref(:system_hook)      | true | ||||
|         ref(:integration_hook) | true | ||||
|       end | ||||
| 
 | ||||
|       with_them do | ||||
|         if params[:limitless_hook_type] | ||||
|           it_behaves_like 'a hook that is never rate limited' | ||||
|         else | ||||
|           it 'rate limits the hook, returning true when rate limited' do | ||||
|             expect(Gitlab::ApplicationRateLimiter).to receive(:throttled?) | ||||
|               .exactly(3).times | ||||
|               .and_call_original | ||||
| 
 | ||||
|             freeze_time do | ||||
|               limit.times { expect(rate_limit!(hook)).to eq(false) } | ||||
|               expect(rate_limit!(hook)).to eq(true) | ||||
|             end | ||||
| 
 | ||||
|             travel_to(1.day.from_now) do | ||||
|               expect(rate_limit!(hook)).to eq(false) | ||||
|             end | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     describe 'rate limit scope' do | ||||
|       it 'rate limits all hooks from the same namespace', :freeze_time do | ||||
|         create(:plan_limits, plan: plan, web_hook_calls: limit) | ||||
|         project_hook_in_different_namespace = create(:project_hook) | ||||
|         project_hook_in_same_namespace = create(:project_hook, | ||||
|           project: create(:project, namespace: project_hook.project.namespace) | ||||
|         ) | ||||
| 
 | ||||
|         limit.times { expect(rate_limit!(project_hook)).to eq(false) } | ||||
|         expect(rate_limit!(project_hook)).to eq(true) | ||||
|         expect(rate_limit!(project_hook_in_same_namespace)).to eq(true) | ||||
|         expect(rate_limit!(project_hook_in_different_namespace)).to eq(false) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#rate_limited?' do | ||||
|     subject { described_class.new(hook).rate_limited? } | ||||
| 
 | ||||
|     context 'when no plan limit has been defined' do | ||||
|       where(:hook) { [ref(:project_hook), ref(:system_hook), ref(:integration_hook)] } | ||||
| 
 | ||||
|       with_them do | ||||
|         it { is_expected.to eq(false) } | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when there is a plan limit' do | ||||
|       before_all do | ||||
|         create(:plan_limits, plan: plan, web_hook_calls: limit) | ||||
|       end | ||||
| 
 | ||||
|       context 'when hook is not rate-limited' do | ||||
|         where(:hook) { [ref(:project_hook), ref(:system_hook), ref(:integration_hook)] } | ||||
| 
 | ||||
|         with_them do | ||||
|           it { is_expected.to eq(false) } | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when hook is rate-limited' do | ||||
|         before do | ||||
|           allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) | ||||
|         end | ||||
| 
 | ||||
|         where(:hook, :limitless_hook_type) do | ||||
|           ref(:project_hook)     | false | ||||
|           ref(:system_hook)      | true | ||||
|           ref(:integration_hook) | true | ||||
|         end | ||||
| 
 | ||||
|         with_them do | ||||
|           it { is_expected.to eq(!limitless_hook_type) } | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,101 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| require_migration! | ||||
| 
 | ||||
| RSpec.describe AddWebHookCallsToPlanLimitsPaidTiers do | ||||
|   let_it_be(:plans) { table(:plans) } | ||||
|   let_it_be(:plan_limits) { table(:plan_limits) } | ||||
| 
 | ||||
|   context 'when on Gitlab.com' do | ||||
|     let(:free_plan) { plans.create!(name: 'free') } | ||||
|     let(:bronze_plan) { plans.create!(name: 'bronze') } | ||||
|     let(:silver_plan) { plans.create!(name: 'silver') } | ||||
|     let(:gold_plan) { plans.create!(name: 'gold') } | ||||
|     let(:premium_plan) { plans.create!(name: 'premium') } | ||||
|     let(:premium_trial_plan) { plans.create!(name: 'premium_trial') } | ||||
|     let(:ultimate_plan) { plans.create!(name: 'ultimate') } | ||||
|     let(:ultimate_trial_plan) { plans.create!(name: 'ultimate_trial') } | ||||
|     let(:opensource_plan) { plans.create!(name: 'opensource') } | ||||
| 
 | ||||
|     before do | ||||
|       allow(Gitlab).to receive(:com?).and_return(true) | ||||
|       # 120 is the value for 'free' migrated in `db/migrate/20210601131742_update_web_hook_calls_limit.rb` | ||||
|       plan_limits.create!(plan_id: free_plan.id, web_hook_calls: 120) | ||||
|       plan_limits.create!(plan_id: bronze_plan.id) | ||||
|       plan_limits.create!(plan_id: silver_plan.id) | ||||
|       plan_limits.create!(plan_id: gold_plan.id) | ||||
|       plan_limits.create!(plan_id: premium_plan.id) | ||||
|       plan_limits.create!(plan_id: premium_trial_plan.id) | ||||
|       plan_limits.create!(plan_id: ultimate_plan.id) | ||||
|       plan_limits.create!(plan_id: ultimate_trial_plan.id) | ||||
|       plan_limits.create!(plan_id: opensource_plan.id) | ||||
|     end | ||||
| 
 | ||||
|     it 'correctly migrates up and down' do | ||||
|       reversible_migration do |migration| | ||||
|         migration.before -> { | ||||
|           expect( | ||||
|             plan_limits.pluck(:plan_id, :web_hook_calls, :web_hook_calls_mid, :web_hook_calls_low) | ||||
|           ).to contain_exactly( | ||||
|             [free_plan.id, 120, 0, 0], | ||||
|             [bronze_plan.id, 0, 0, 0], | ||||
|             [silver_plan.id, 0, 0, 0], | ||||
|             [gold_plan.id, 0, 0, 0], | ||||
|             [premium_plan.id, 0, 0, 0], | ||||
|             [premium_trial_plan.id, 0, 0, 0], | ||||
|             [ultimate_plan.id, 0, 0, 0], | ||||
|             [ultimate_trial_plan.id, 0, 0, 0], | ||||
|             [opensource_plan.id, 0, 0, 0] | ||||
|           ) | ||||
|         } | ||||
| 
 | ||||
|         migration.after -> { | ||||
|           expect( | ||||
|             plan_limits.pluck(:plan_id, :web_hook_calls, :web_hook_calls_mid, :web_hook_calls_low) | ||||
|           ).to contain_exactly( | ||||
|             [free_plan.id, 500, 500, 500], | ||||
|             [bronze_plan.id, 4_000, 2_800, 1_600], | ||||
|             [silver_plan.id, 4_000, 2_800, 1_600], | ||||
|             [gold_plan.id, 13_000, 9_000, 6_000], | ||||
|             [premium_plan.id, 4_000, 2_800, 1_600], | ||||
|             [premium_trial_plan.id, 4_000, 2_800, 1_600], | ||||
|             [ultimate_plan.id, 13_000, 9_000, 6_000], | ||||
|             [ultimate_trial_plan.id, 13_000, 9_000, 6_000], | ||||
|             [opensource_plan.id, 13_000, 9_000, 6_000] | ||||
|           ) | ||||
|         } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when on self hosted' do | ||||
|     let(:default_plan) { plans.create!(name: 'default') } | ||||
| 
 | ||||
|     before do | ||||
|       allow(Gitlab).to receive(:com?).and_return(false) | ||||
| 
 | ||||
|       plan_limits.create!(plan_id: default_plan.id) | ||||
|     end | ||||
| 
 | ||||
|     it 'does nothing' do | ||||
|       reversible_migration do |migration| | ||||
|         migration.before -> { | ||||
|           expect( | ||||
|             plan_limits.pluck(:plan_id, :web_hook_calls, :web_hook_calls_mid, :web_hook_calls_low) | ||||
|           ).to contain_exactly( | ||||
|             [default_plan.id, 0, 0, 0] | ||||
|           ) | ||||
|         } | ||||
| 
 | ||||
|         migration.after -> { | ||||
|           expect( | ||||
|             plan_limits.pluck(:plan_id, :web_hook_calls, :web_hook_calls_mid, :web_hook_calls_low) | ||||
|           ).to contain_exactly( | ||||
|             [default_plan.id, 0, 0, 0] | ||||
|           ) | ||||
|         } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -40,6 +40,39 @@ RSpec.describe Clusters::Agent do | |||
| 
 | ||||
|       it { is_expected.to contain_exactly(matching_name) } | ||||
|     end | ||||
| 
 | ||||
|     describe '.has_vulnerabilities' do | ||||
|       let_it_be(:without_vulnerabilities) { create(:cluster_agent, has_vulnerabilities: false) } | ||||
|       let_it_be(:with_vulnerabilities) { create(:cluster_agent, has_vulnerabilities: true) } | ||||
| 
 | ||||
|       context 'when value is not provided' do | ||||
|         subject { described_class.has_vulnerabilities } | ||||
| 
 | ||||
|         it 'returns agents which have vulnerabilities' do | ||||
|           is_expected.to contain_exactly(with_vulnerabilities) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when value is provided' do | ||||
|         subject { described_class.has_vulnerabilities(value) } | ||||
| 
 | ||||
|         context 'as true' do | ||||
|           let(:value) { true } | ||||
| 
 | ||||
|           it 'returns agents which have vulnerabilities' do | ||||
|             is_expected.to contain_exactly(with_vulnerabilities) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'as false' do | ||||
|           let(:value) { false } | ||||
| 
 | ||||
|           it 'returns agents which do not have vulnerabilities' do | ||||
|             is_expected.to contain_exactly(without_vulnerabilities) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'validation' do | ||||
|  |  | |||
|  | @ -31,15 +31,6 @@ RSpec.describe ProjectHook do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#rate_limit' do | ||||
|     let_it_be(:plan_limits) { create(:plan_limits, :default_plan, web_hook_calls: 100) } | ||||
|     let_it_be(:hook) { create(:project_hook) } | ||||
| 
 | ||||
|     it 'returns the default limit' do | ||||
|       expect(hook.rate_limit).to be(100) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#parent' do | ||||
|     it 'returns the associated project' do | ||||
|       project = build(:project) | ||||
|  |  | |||
|  | @ -23,14 +23,6 @@ RSpec.describe ServiceHook do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#rate_limit' do | ||||
|     let(:hook) { build(:service_hook) } | ||||
| 
 | ||||
|     it 'returns nil' do | ||||
|       expect(hook.rate_limit).to be_nil | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#parent' do | ||||
|     let(:hook) { build(:service_hook, integration: integration) } | ||||
| 
 | ||||
|  |  | |||
|  | @ -185,14 +185,6 @@ RSpec.describe SystemHook do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#rate_limit' do | ||||
|     let(:hook) { build(:system_hook) } | ||||
| 
 | ||||
|     it 'returns nil' do | ||||
|       expect(hook.rate_limit).to be_nil | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#application_context' do | ||||
|     let(:hook) { build(:system_hook) } | ||||
| 
 | ||||
|  |  | |||
|  | @ -493,31 +493,30 @@ RSpec.describe WebHook do | |||
|   end | ||||
| 
 | ||||
|   describe '#rate_limited?' do | ||||
|     context 'when there are rate limits' do | ||||
|       before do | ||||
|         allow(hook).to receive(:rate_limit).and_return(3) | ||||
|     it 'is false when hook has not been rate limited' do | ||||
|       expect_next_instance_of(Gitlab::WebHooks::RateLimiter) do |rate_limiter| | ||||
|         expect(rate_limiter).to receive(:rate_limited?).and_return(false) | ||||
|       end | ||||
| 
 | ||||
|       it 'is false when hook has not been rate limited' do | ||||
|         expect(Gitlab::ApplicationRateLimiter).to receive(:peek).and_return(false) | ||||
|       expect(hook).not_to be_rate_limited | ||||
|     end | ||||
| 
 | ||||
|     it 'is true when hook has been rate limited' do | ||||
|         expect(Gitlab::ApplicationRateLimiter).to receive(:peek).and_return(true) | ||||
|       expect_next_instance_of(Gitlab::WebHooks::RateLimiter) do |rate_limiter| | ||||
|         expect(rate_limiter).to receive(:rate_limited?).and_return(true) | ||||
|       end | ||||
| 
 | ||||
|       expect(hook).to be_rate_limited | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|     context 'when there are no rate limits' do | ||||
|       before do | ||||
|         allow(hook).to receive(:rate_limit).and_return(nil) | ||||
|   describe '#rate_limit' do | ||||
|     it 'returns the hook rate limit' do | ||||
|       expect_next_instance_of(Gitlab::WebHooks::RateLimiter) do |rate_limiter| | ||||
|         expect(rate_limiter).to receive(:limit).and_return(10) | ||||
|       end | ||||
| 
 | ||||
|       it 'does not call Gitlab::ApplicationRateLimiter, and is false' do | ||||
|         expect(Gitlab::ApplicationRateLimiter).not_to receive(:peek) | ||||
|         expect(hook).not_to be_rate_limited | ||||
|       end | ||||
|       expect(hook.rate_limit).to eq(10) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -47,6 +47,16 @@ RSpec.describe GroupMember do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#permissible_access_level_roles' do | ||||
|     let_it_be(:group) { create(:group) } | ||||
| 
 | ||||
|     it 'returns Gitlab::Access.options_with_owner' do | ||||
|       result = described_class.permissible_access_level_roles(group.first_owner, group) | ||||
| 
 | ||||
|       expect(result).to eq(Gitlab::Access.options_with_owner) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   it_behaves_like 'members notifications', :group | ||||
| 
 | ||||
|   describe '#namespace_id' do | ||||
|  |  | |||
|  | @ -23,6 +23,30 @@ RSpec.describe ProjectMember do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#permissible_access_level_roles' do | ||||
|     let_it_be(:owner) { create(:user) } | ||||
|     let_it_be(:maintainer) { create(:user) } | ||||
|     let_it_be(:group) { create(:group) } | ||||
|     let_it_be(:project) { create(:project, group: group) } | ||||
| 
 | ||||
|     before do | ||||
|       project.add_owner(owner) | ||||
|       project.add_maintainer(maintainer) | ||||
|     end | ||||
| 
 | ||||
|     context 'when member can manage owners' do | ||||
|       it 'returns Gitlab::Access.options_with_owner' do | ||||
|         expect(described_class.permissible_access_level_roles(owner, project)).to eq(Gitlab::Access.options_with_owner) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when member cannot manage owners' do | ||||
|       it 'returns Gitlab::Access.options' do | ||||
|         expect(described_class.permissible_access_level_roles(maintainer, project)).to eq(Gitlab::Access.options) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#real_source_type' do | ||||
|     subject { create(:project_member).real_source_type } | ||||
| 
 | ||||
|  |  | |||
|  | @ -213,6 +213,8 @@ RSpec.describe PlanLimits do | |||
|         storage_size_limit | ||||
|         daily_invites | ||||
|         web_hook_calls | ||||
|         web_hook_calls_mid | ||||
|         web_hook_calls_low | ||||
|         ci_daily_pipeline_schedule_triggers | ||||
|         repository_size | ||||
|         security_policy_scan_execution_schedules | ||||
|  |  | |||
|  | @ -59,13 +59,13 @@ RSpec.describe API::Invitations do | |||
| 
 | ||||
|       context 'when authenticated as a maintainer/owner' do | ||||
|         context 'and new member is already a requester' do | ||||
|           it 'does not transform the requester into a proper member' do | ||||
|           it 'transforms the requester into a proper member' do | ||||
|             expect do | ||||
|               post invitations_url(source, maintainer), | ||||
|                    params: { email: access_requester.email, access_level: Member::MAINTAINER } | ||||
| 
 | ||||
|               expect(response).to have_gitlab_http_status(:created) | ||||
|             end.not_to change { source.members.count } | ||||
|             end.to change { source.members.count }.by(1) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|  | @ -258,12 +258,13 @@ RSpec.describe API::Invitations do | |||
|         end | ||||
|       end | ||||
| 
 | ||||
|       it "returns a message if member already exists" do | ||||
|       it "updates an already existing active member" do | ||||
|         post invitations_url(source, maintainer), | ||||
|              params: { email: developer.email, access_level: Member::MAINTAINER } | ||||
| 
 | ||||
|         expect(response).to have_gitlab_http_status(:created) | ||||
|         expect(json_response['message'][developer.email]).to eq("User already exists in source") | ||||
|         expect(json_response['status']).to eq("success") | ||||
|         expect(source.members.find_by(user: developer).access_level).to eq Member::MAINTAINER | ||||
|       end | ||||
| 
 | ||||
|       it 'returns 400 when the invite params of email and user_id are not sent' do | ||||
|  | @ -328,7 +329,7 @@ RSpec.describe API::Invitations do | |||
| 
 | ||||
|       emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' | ||||
| 
 | ||||
|       unresolved_n_plus_ones = 44 # old 48 with 12 per new email, currently there are 11 queries added per email | ||||
|       unresolved_n_plus_ones = 40 # currently there are 10 queries added per email | ||||
| 
 | ||||
|       expect do | ||||
|         post invitations_url(project, maintainer), params: { email: emails, access_level: Member::DEVELOPER } | ||||
|  | @ -351,7 +352,7 @@ RSpec.describe API::Invitations do | |||
| 
 | ||||
|       emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' | ||||
| 
 | ||||
|       unresolved_n_plus_ones = 67 # currently there are 11 queries added per email | ||||
|       unresolved_n_plus_ones = 59 # currently there are 10 queries added per email | ||||
| 
 | ||||
|       expect do | ||||
|         post invitations_url(project, maintainer), params: { email: emails, access_level: Member::DEVELOPER } | ||||
|  | @ -373,7 +374,7 @@ RSpec.describe API::Invitations do | |||
| 
 | ||||
|       emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' | ||||
| 
 | ||||
|       unresolved_n_plus_ones = 36 # old 40 with 10 per new email, currently there are 9 queries added per email | ||||
|       unresolved_n_plus_ones = 32 # currently there are 8 queries added per email | ||||
| 
 | ||||
|       expect do | ||||
|         post invitations_url(group, maintainer), params: { email: emails, access_level: Member::DEVELOPER } | ||||
|  | @ -396,7 +397,7 @@ RSpec.describe API::Invitations do | |||
| 
 | ||||
|       emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' | ||||
| 
 | ||||
|       unresolved_n_plus_ones = 62 # currently there are 9 queries added per email | ||||
|       unresolved_n_plus_ones = 56 # currently there are 8 queries added per email | ||||
| 
 | ||||
|       expect do | ||||
|         post invitations_url(group, maintainer), params: { email: emails, access_level: Member::DEVELOPER } | ||||
|  |  | |||
|  | @ -3,9 +3,10 @@ | |||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe JiraConnectSubscriptions::CreateService do | ||||
|   let(:installation) { create(:jira_connect_installation) } | ||||
|   let(:current_user) { create(:user) } | ||||
|   let(:group) { create(:group) } | ||||
|   let_it_be(:installation) { create(:jira_connect_installation) } | ||||
|   let_it_be(:current_user) { create(:user) } | ||||
|   let_it_be(:group) { create(:group) } | ||||
| 
 | ||||
|   let(:path) { group.full_path } | ||||
|   let(:params) { { namespace_path: path, jira_user: jira_user } } | ||||
|   let(:jira_user) { double(:JiraUser, site_admin?: true) } | ||||
|  | @ -16,38 +17,31 @@ RSpec.describe JiraConnectSubscriptions::CreateService do | |||
|     group.add_maintainer(current_user) | ||||
|   end | ||||
| 
 | ||||
|   shared_examples 'a failed execution' do | ||||
|   shared_examples 'a failed execution' do |**status_attributes| | ||||
|     it 'does not create a subscription' do | ||||
|       expect { subject }.not_to change { installation.subscriptions.count } | ||||
|     end | ||||
| 
 | ||||
|     it 'returns an error status' do | ||||
|       expect(subject[:status]).to eq(:error) | ||||
|       expect(subject).to include(status_attributes) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'remote user does not have access' do | ||||
|     let(:jira_user) { double(site_admin?: false) } | ||||
| 
 | ||||
|     it 'does not create a subscription' do | ||||
|       expect { subject }.not_to change { installation.subscriptions.count } | ||||
|     end | ||||
| 
 | ||||
|     it 'returns error' do | ||||
|       expect(subject[:status]).to eq(:error) | ||||
|     end | ||||
|     it_behaves_like 'a failed execution', | ||||
|       http_status: 403, | ||||
|       message: 'The Jira user is not a site administrator. Check the permissions in Jira and try again.' | ||||
|   end | ||||
| 
 | ||||
|   context 'remote user cannot be retrieved' do | ||||
|     let(:jira_user) { nil } | ||||
| 
 | ||||
|     it 'does not create a subscription' do | ||||
|       expect { subject }.not_to change { installation.subscriptions.count } | ||||
|     end | ||||
| 
 | ||||
|     it 'returns error' do | ||||
|       expect(subject[:status]).to eq(:error) | ||||
|     end | ||||
|     it_behaves_like 'a failed execution', | ||||
|       http_status: 403, | ||||
|       message: 'Could not fetch user information from Jira. Check the permissions in Jira and try again.' | ||||
|   end | ||||
| 
 | ||||
|   context 'when user does have access' do | ||||
|  | @ -60,8 +54,8 @@ RSpec.describe JiraConnectSubscriptions::CreateService do | |||
|     end | ||||
| 
 | ||||
|     context 'namespace has projects' do | ||||
|       let!(:project_1) { create(:project, group: group) } | ||||
|       let!(:project_2) { create(:project, group: group) } | ||||
|       let_it_be(:project_1) { create(:project, group: group) } | ||||
|       let_it_be(:project_2) { create(:project, group: group) } | ||||
| 
 | ||||
|       before do | ||||
|         stub_const("#{described_class}::MERGE_REQUEST_SYNC_BATCH_SIZE", 1) | ||||
|  | @ -81,12 +75,18 @@ RSpec.describe JiraConnectSubscriptions::CreateService do | |||
|   context 'when path is invalid' do | ||||
|     let(:path) { 'some_invalid_namespace_path' } | ||||
| 
 | ||||
|     it_behaves_like 'a failed execution' | ||||
|     it_behaves_like 'a failed execution', | ||||
|       http_status: 401, | ||||
|       message: 'Cannot find namespace. Make sure you have sufficient permissions.' | ||||
|   end | ||||
| 
 | ||||
|   context 'when user does not have access' do | ||||
|     subject { described_class.new(installation, create(:user), namespace_path: path).execute } | ||||
|     let_it_be(:other_group) { create(:group) } | ||||
| 
 | ||||
|     it_behaves_like 'a failed execution' | ||||
|     let(:path) { other_group.full_path } | ||||
| 
 | ||||
|     it_behaves_like 'a failed execution', | ||||
|       http_status: 401, | ||||
|       message: 'Cannot find namespace. Make sure you have sufficient permissions.' | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ RSpec.describe Members::CreatorService do | |||
|   describe '#execute' do | ||||
|     it 'raises error for new member on authorization check implementation' do | ||||
|       expect do | ||||
|         described_class.new(source, user, :maintainer, current_user: current_user).execute | ||||
|         described_class.add_user(source, user, :maintainer, current_user: current_user) | ||||
|       end.to raise_error(NotImplementedError) | ||||
|     end | ||||
| 
 | ||||
|  | @ -19,7 +19,7 @@ RSpec.describe Members::CreatorService do | |||
|       source.add_developer(user) | ||||
| 
 | ||||
|       expect do | ||||
|         described_class.new(source, user, :maintainer, current_user: current_user).execute | ||||
|         described_class.add_user(source, user, :maintainer, current_user: current_user) | ||||
|       end.to raise_error(NotImplementedError) | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -1,14 +0,0 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Members::Groups::BulkCreatorService do | ||||
|   let_it_be(:source, reload: true) { create(:group, :public) } | ||||
|   let_it_be(:current_user) { create(:user) } | ||||
| 
 | ||||
|   it_behaves_like 'bulk member creation' do | ||||
|     let_it_be(:member_type) { GroupMember } | ||||
|   end | ||||
| 
 | ||||
|   it_behaves_like 'owner management' | ||||
| end | ||||
|  | @ -3,16 +3,24 @@ | |||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Members::Groups::CreatorService do | ||||
|   let_it_be(:source, reload: true) { create(:group, :public) } | ||||
|   let_it_be(:user) { create(:user) } | ||||
| 
 | ||||
|   describe '.access_levels' do | ||||
|     it 'returns Gitlab::Access.options_with_owner' do | ||||
|       expect(described_class.access_levels).to eq(Gitlab::Access.sym_options_with_owner) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#execute' do | ||||
|     let_it_be(:source, reload: true) { create(:group, :public) } | ||||
|     let_it_be(:user) { create(:user) } | ||||
|   it_behaves_like 'owner management' | ||||
| 
 | ||||
|   describe '.add_users' do | ||||
|     it_behaves_like 'bulk member creation' do | ||||
|       let_it_be(:member_type) { GroupMember } | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.add_user' do | ||||
|     it_behaves_like 'member creation' do | ||||
|       let_it_be(:member_type) { GroupMember } | ||||
|     end | ||||
|  | @ -22,7 +30,7 @@ RSpec.describe Members::Groups::CreatorService do | |||
|         expect(AuthorizedProjectsWorker).to receive(:bulk_perform_and_wait).once | ||||
| 
 | ||||
|         1.upto(3) do | ||||
|           described_class.new(source, user, :maintainer).execute | ||||
|           described_class.add_user(source, user, :maintainer) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -367,20 +367,21 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ | |||
| 
 | ||||
|     context 'when email is already a member with a user on the project' do | ||||
|       let!(:existing_member) { create(:project_member, :guest, project: project) } | ||||
|       let(:params) { { email: "#{existing_member.user.email}" } } | ||||
|       let(:params) { { email: "#{existing_member.user.email}", access_level: ProjectMember::MAINTAINER } } | ||||
| 
 | ||||
|       it 'returns an error for the already invited email' do | ||||
|         expect_not_to_create_members | ||||
|         expect(result[:message][existing_member.user.email]).to eq("User already exists in source") | ||||
|       it 'allows re-invite of an already invited email and updates the access_level' do | ||||
|         expect { result }.not_to change(ProjectMember, :count) | ||||
|         expect(result[:status]).to eq(:success) | ||||
|         expect(existing_member.reset.access_level).to eq ProjectMember::MAINTAINER | ||||
|       end | ||||
| 
 | ||||
|       context 'when email belongs to an existing user as a secondary email' do | ||||
|         let(:secondary_email) { create(:email, email: 'secondary@example.com', user: existing_member.user) } | ||||
|         let(:params) { { email: "#{secondary_email.email}" } } | ||||
| 
 | ||||
|         it 'returns an error for the already invited email' do | ||||
|           expect_not_to_create_members | ||||
|           expect(result[:message][secondary_email.email]).to eq("User already exists in source") | ||||
|         it 'allows re-invite to an already invited email' do | ||||
|           expect_to_create_members(count: 0) | ||||
|           expect(result[:status]).to eq(:success) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
|  | @ -1,14 +0,0 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Members::Projects::BulkCreatorService do | ||||
|   let_it_be(:source, reload: true) { create(:project, :public) } | ||||
|   let_it_be(:current_user) { create(:user) } | ||||
| 
 | ||||
|   it_behaves_like 'bulk member creation' do | ||||
|     let_it_be(:member_type) { ProjectMember } | ||||
|   end | ||||
| 
 | ||||
|   it_behaves_like 'owner management' | ||||
| end | ||||
|  | @ -3,16 +3,24 @@ | |||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Members::Projects::CreatorService do | ||||
|   let_it_be(:source, reload: true) { create(:project, :public) } | ||||
|   let_it_be(:user) { create(:user) } | ||||
| 
 | ||||
|   describe '.access_levels' do | ||||
|     it 'returns Gitlab::Access.sym_options_with_owner' do | ||||
|       expect(described_class.access_levels).to eq(Gitlab::Access.sym_options_with_owner) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#execute' do | ||||
|     let_it_be(:source, reload: true) { create(:project, :public) } | ||||
|     let_it_be(:user) { create(:user) } | ||||
|   it_behaves_like 'owner management' | ||||
| 
 | ||||
|   describe '.add_users' do | ||||
|     it_behaves_like 'bulk member creation' do | ||||
|       let_it_be(:member_type) { ProjectMember } | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.add_user' do | ||||
|     it_behaves_like 'member creation' do | ||||
|       let_it_be(:member_type) { ProjectMember } | ||||
|     end | ||||
|  | @ -22,7 +30,7 @@ RSpec.describe Members::Projects::CreatorService do | |||
|         expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to receive(:bulk_perform_in).once | ||||
| 
 | ||||
|         1.upto(3) do | ||||
|           described_class.new(source, user, :maintainer).execute | ||||
|           described_class.add_user(source, user, :maintainer) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue