Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-16 09:09:15 +00:00
parent 06bcbc77e4
commit b7b44de429
102 changed files with 2137 additions and 855 deletions

View File

@ -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

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);
}
}
};

View File

@ -1,3 +1,3 @@
mutation addItems($items: [Item]) {
mutation addItems($items: [ItemInput]) {
addToolbarItems(items: $items) @client
}

View File

@ -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
}

View File

@ -1,3 +1,3 @@
mutation updateItem($id: ID!, $propsToUpdate: Item!) {
mutation updateItem($id: ID!, $propsToUpdate: ItemInput!) {
updateToolbarItem(id: $id, propsToUpdate: $propsToUpdate) @client
}

View File

@ -1,4 +1,4 @@
mutation action($action: LocalAction) {
mutation action($action: LocalActionInput) {
action(action: $action) @client {
errors
}

View File

@ -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
}

View File

@ -1,4 +1,4 @@
mutation importGroups($importRequests: [ImportGroupInput!]!) {
mutation importGroups($importRequests: [ImportRequestInput!]!) {
importGroups(importRequests: $importRequests) @client {
id
lastImportTarget {

View File

@ -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
}

View File

@ -0,0 +1,10 @@
type LogTreeCommit {
sha: String
message: String
titleHtml: String
committedDate: Time
commitPath: String
fileName: String
filePath: String
type: String
}

View File

@ -1,3 +1,3 @@
mutation addDataToTerraformState($terraformState: State!) {
mutation addDataToTerraformState($terraformState: LocalTerraformStateInput!) {
addDataToTerraformState(terraformState: $terraformState) @client
}

View File

@ -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
}

View File

@ -21,7 +21,7 @@ extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
type LocalWorkItemAssigneesInput {
input LocalWorkItemAssigneesInput {
id: WorkItemID!
assigneeIds: [ID!]
}

View File

@ -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';

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
user,
access_level,
current_user: current_user,
expires_at: expires_at)
.execute
Members::Projects::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass
project,
user,
access_level,
current_user: current_user,
expires_at: expires_at)
end
# Remove all users from project team

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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)) }

View File

@ -4,20 +4,21 @@
.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
- 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 }
= sprite_icon("status_#{status}" )
.domain-name
= external_link(domain.url, domain.url)
- if domain.certificate
%div
= gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject })
- if domain.expired?
= gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
%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: "gl-mr-5 ci-status-icon-#{status}", title: tooltip }
= sprite_icon("status_#{status}" )
.domain-name
= external_link(domain.url, domain.url)
- if domain.certificate
%div
= gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject })
- if domain.expired?
= gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
%div
= link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
= link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
80535374849c10d41663d339b95b9ffddbec9b40a8af4585c18602cbe92c14d1

View File

@ -0,0 +1 @@
92a7ed079521ccb8ab04e59826947778c37bccd30d47f1b0e29727f769e3ff32

View File

@ -0,0 +1 @@
f49e691c46ddaaf1b18d95726e7c2473fab946ea79885727ba09bb92591e4a01

View File

@ -0,0 +1 @@
96d899efc1fa39cf3433987ee4d8062456f7a6af6248b97eda2ddc5491dcf7f5

View File

@ -0,0 +1 @@
bbfcaf59734b67142b237b7ea479c5eaa3c2152cdd84c87ad541e5a0e75466ef

View File

@ -0,0 +1 @@
33456ce3af299e010011b1346b4097ffa1ee642ffb90d342ea22171c3f079d7a

View File

@ -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);

View File

@ -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`.

View File

@ -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

View File

@ -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**.

View File

@ -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

View File

@ -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 |

View File

@ -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.
![SAML Group Links](img/saml_group_links_v13_9.png)
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
```

View File

@ -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.
![SAML Group Links](img/saml_group_links_v13_9.png)
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

View File

@ -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' })

View File

@ -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 },

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -12,8 +12,18 @@ RSpec.describe 'Group variables', :js do
group.add_owner(user)
gitlab_sign_in(user)
wait_for_requests
visit page_path
end
it_behaves_like 'variable list'
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

View File

@ -12,28 +12,36 @@ RSpec.describe 'Project variables', :js do
sign_in(user)
project.add_maintainer(user)
project.variables << variable
visit page_path
end
it_behaves_like 'variable list'
it 'adds a new variable with an environment scope' do
click_button('Add variable')
page.within('#add-ci-variable') do
fill_in 'Key', with: 'akey'
find('#ci-variable-value').set('akey_value')
find('[data-testid="environment-scope"]').click
find('[data-testid="ci-environment-search"]').set('review/*')
find('[data-testid="create-wildcard-button"]').click
click_button('Add variable')
# 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
wait_for_requests
it_behaves_like 'variable list'
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*')
it 'adds a new variable with an environment scope' do
click_button('Add variable')
page.within('#add-ci-variable') do
fill_in 'Key', with: 'akey'
find('#ci-variable-value').set('akey_value')
find('[data-testid="environment-scope"]').click
find('[data-testid="ci-environment-search"]').set('review/*')
find('[data-testid="create-wildcard-button"]').click
click_button('Add variable')
end
wait_for_requests
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*')
end
end
end
end

View File

@ -48,20 +48,48 @@ RSpec.describe 'Projects > Members > Manage members', :js do
end
end
it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do
visit_members_page
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 'Invite members'
click_on 'Guest'
wait_for_requests
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).not_to have_button('Owner')
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
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).not_to have_button('Owner')
end
end
end

View File

@ -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,

View File

@ -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,

View File

@ -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,
});
};

View File

@ -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,
});

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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) }

View File

@ -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) }

View File

@ -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(hook).to be_rate_limited
end
expect(hook).not_to be_rate_limited
end
context 'when there are no rate limits' do
before do
allow(hook).to receive(:rate_limit).and_return(nil)
it 'is true when hook has been rate limited' do
expect_next_instance_of(Gitlab::WebHooks::RateLimiter) do |rate_limiter|
expect(rate_limiter).to receive(:rate_limited?).and_return(true)
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
expect(hook).to be_rate_limited
end
end
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
expect(hook.rate_limit).to eq(10)
end
end

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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