Commit 6075cbe0 authored by Fernando's avatar Fernando

Implemeent Vuex module for fetching security configurations

* Impleement actions, mutations, mutation types, and store

Add unit tests

* Add mutations, actions unit tests

Implement code review suggestions

* Tweak mutations and update unit tests

Update actions spec to use async/await

* Update tests

First pass of approval rules

Add different row states

* Add template markup and logic
* Add state for job enabled, but approval rule not configured
* Add state for job disabled and rule not configured
* Add state for approval rule configured

Tweak rendering logic

* Still show row when job not properly configured

Run prettier and linter

* Lint and clean up formatting

Add doc links

* Add GLSprintf component for doc links

Run prettier and linter

* clean up code formatting

Add loading state

Run prettier and linter
parent c6e88d58
......@@ -22,8 +22,20 @@ export default {
},
computed: {
...mapState('createModal', {
rule: 'data',
rule(state) {
/*
* rule-form component expects undefined if we pre-populate the form input,
* otherwise populate with existing rule
*/
return state.data?.initRuleField ? undefined : state.data;
},
originalData: 'data',
}),
initRuleFieldName() {
return this.originalData?.initRuleField && this.originalData?.name
? this.originalData.name
: '';
},
title() {
return this.rule ? __('Update approval rule') : __('Add approval rule');
},
......@@ -47,6 +59,11 @@ export default {
size="sm"
@ok.prevent="submit"
>
<rule-form ref="form" :init-rule="rule" :is-mr-edit="isMrEdit" />
<rule-form
ref="form"
:init-rule="rule"
:is-mr-edit="isMrEdit"
:init-rule-field-name="initRuleFieldName"
/>
</gl-modal-vuex>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { n__, sprintf } from '~/locale';
import { __, n__, sprintf } from '~/locale';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from '../rules.vue';
......@@ -8,6 +8,7 @@ import RuleControls from '../rule_controls.vue';
import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
import UnconfiguredSecurityRule from '../security_configuration/unconfigured_security_rule.vue';
export default {
components: {
......@@ -17,12 +18,36 @@ export default {
EmptyRule,
RuleInput,
RuleBranches,
UnconfiguredSecurityRule,
},
inject: {
securityConfigurationPath: {
type: String,
required: true,
from: 'securityConfigurationPath',
default: '',
},
vulnerabilityCheckHelpPagePath: {
type: String,
required: true,
from: 'vulnerabilityCheckHelpPagePath',
default: '',
},
licenseCheckHelpPagePath: {
type: String,
required: true,
from: 'licenseCheckHelpPagePath',
default: '',
},
},
computed: {
...mapState(['settings']),
...mapState({
rules: state => state.approvals.rules,
hasApprovalsLoaded: state => state.approvals.hasLoaded,
hasSecurityConfigurationLoaded: state => state.securityConfiguration.hasLoaded,
}),
...mapState('securityConfiguration', ['configuration']),
hasNamedRule() {
return this.rules.some(rule => rule.ruleType === RULE_TYPE_REGULAR);
},
......@@ -32,6 +57,33 @@ export default {
!this.rules.some(rule => rule.ruleType === RULE_TYPE_ANY_APPROVER)
);
},
isRulesLoading() {
return !this.hasApprovalsLoaded || !this.hasSecurityConfigurationLoaded;
},
securityRules() {
return [
{
name: 'Vulnerability-Check',
description: __(
'One or more of the security scanners must be enabled %{linkStart}more information%{linkEnd}',
),
enableDescription: __(
'Requires approval for vulnerabilties of Critical, High, or Unknown severity %{linkStart}more information%{linkEnd}',
),
docsPath: this.vulnerabilityCheckHelpPagePath,
},
{
name: 'License-Check',
description: __(
'License Scanning must be enabled %{linkStart}more information%{linkEnd}',
),
enableDescription: __(
'Requires license policy rules for licenses of Allowed, or Denied %{linkStart}more information%{linkEnd}',
),
docsPath: this.licenseCheckHelpPagePath,
},
];
},
},
watch: {
rules: {
......@@ -46,8 +98,17 @@ export default {
immediate: true,
},
},
mounted() {
this.setSecurityConfigurationEndpoint(this.securityConfigurationPath);
this.fetchSecurityConfiguration();
},
methods: {
...mapActions(['addEmptyRule']),
...mapActions({ openCreateModal: 'createModal/open' }),
...mapActions('securityConfiguration', [
'setSecurityConfigurationEndpoint',
'fetchSecurityConfiguration',
]),
summaryText(rule) {
return this.settings.allowMultiRule
? this.summaryMultipleRulesText(rule)
......@@ -131,6 +192,16 @@ export default {
</td>
</tr>
</template>
<unconfigured-security-rule
v-for="securityRule in securityRules"
:key="securityRule.name"
:configuration="configuration"
:rules="rules"
:is-loading="isRulesLoading"
:match-rule="securityRule"
@enable-btn-clicked="openCreateModal({ name: securityRule.name, initRuleField: true })"
/>
</template>
</rules>
</template>
......@@ -9,7 +9,8 @@ import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
const DEFAULT_NAME = 'Default';
const DEFAULT_NAME_FOR_LICENSE_REPORT = 'License-Check';
const READONLY_NAMES = [DEFAULT_NAME_FOR_LICENSE_REPORT];
const DEFAULT_NAME_FOR_VULNERABILITY_CHECK = 'Vulnerability-Check';
const READONLY_NAMES = [DEFAULT_NAME_FOR_LICENSE_REPORT, DEFAULT_NAME_FOR_VULNERABILITY_CHECK];
export default {
components: {
......@@ -28,10 +29,15 @@ export default {
default: true,
required: false,
},
initRuleFieldName: {
type: String,
required: false,
default: '',
},
},
data() {
return {
name: '',
name: this.initRuleFieldName,
approvalsRequired: 1,
minApprovalsRequired: 0,
approvers: [],
......@@ -132,7 +138,9 @@ export default {
return !this.settings.lockedApprovalsRuleName;
},
isNameDisabled() {
return this.isPersisted && READONLY_NAMES.includes(this.name);
return (
Boolean(this.isPersisted || this.initRuleFieldName) && READONLY_NAMES.includes(this.name)
);
},
removeHiddenGroups() {
return this.containsHiddenGroups && !this.approversByType[TYPE_HIDDEN_GROUPS];
......
<script>
import { camelCase } from 'lodash';
import { GlButton, GlLink, GlSprintf, GlSkeletonLoading } from '@gitlab/ui';
export default {
components: {
GlButton,
GlLink,
GlSprintf,
GlSkeletonLoading,
},
featureTypes: {
vulnerabilityCheck: [
'sast',
'dast',
'dependency_scanning',
'secret_detection',
'coverage_fuzzing',
],
licenseCheck: ['license_scanning'],
},
securityRules: ['Vulnerability-Check', 'License-Check'],
props: {
configuration: {
type: Object,
required: true,
},
rules: {
type: Array,
required: true,
},
matchRule: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
computed: {
hasApprovalRuleDefined() {
return this.rules?.some(rule => {
return this.matchRule.name === rule.name;
}, this);
},
hasConfiguredJob() {
const { features } = this.configuration;
return this.$options.featureTypes[camelCase(this.matchRule.name)].some(featureType => {
return Boolean(
features?.some(feature => {
return feature.type === featureType && feature.configured;
}),
);
});
},
},
};
</script>
<template>
<!--
Excessive conditional logic is due to:
- Can't create wrapper <div> for conditional rendering
because parent component is a table and expects a root <tr> element
- Can't have multiple root <tr> nodes
- Can't create wrapper <div> since <tr> expects a <td> child element
- Root element can't be another <template>
-->
<tr v-if="!hasApprovalRuleDefined || !hasConfiguredJob">
<td v-if="isLoading" colspan="3">
<gl-skeleton-loading :lines="3" />
</td>
<template v-else>
<template v-if="hasConfiguredJob">
<td class="js-name" colspan="4">
<div>{{ matchRule.name }}</div>
<div class="gl-text-gray-500">
<gl-sprintf :message="matchRule.enableDescription">
<template #link="{ content }">
<gl-link :href="matchRule.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</td>
<td class="gl-px-2! gl-text-right">
<gl-button @click="$emit('enable-btn-clicked')">
{{ s__('Enable') }}
</gl-button>
</td>
</template>
<td v-else class="js-name" colspan="3">
<div>{{ matchRule.name }}</div>
<div class="gl-text-gray-500">
<gl-sprintf :message="matchRule.description">
<template #link="{ content }">
<gl-link :href="matchRule.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</td>
</template>
</tr>
</template>
......@@ -12,6 +12,12 @@ export default function mountProjectSettingsApprovals(el) {
return null;
}
const {
securityConfigurationPath,
vulnerabilityCheckHelpPagePath,
licenseCheckHelpPagePath,
} = el.dataset;
const store = createStore(projectSettingsModule(), {
...el.dataset,
prefix: 'project-settings',
......@@ -22,6 +28,11 @@ export default function mountProjectSettingsApprovals(el) {
return new Vue({
el,
store,
provide: {
securityConfigurationPath,
vulnerabilityCheckHelpPagePath,
licenseCheckHelpPagePath,
},
render(h) {
return h(ProjectSettingsApp);
},
......
import Vuex from 'vuex';
import modalModule from '~/vuex_shared/modules/modal';
import securityConfigurationModule from 'ee/security_configuration/modules/configuration';
import state from './state';
export const createStoreOptions = (approvalsModule, settings) => ({
......@@ -8,6 +9,7 @@ export const createStoreOptions = (approvalsModule, settings) => ({
...(approvalsModule ? { approvals: approvalsModule } : {}),
createModal: modalModule(),
deleteModal: modalModule(),
securityConfiguration: securityConfigurationModule(),
},
});
......
......@@ -2,9 +2,9 @@ import state from './state';
import mutations from './mutations';
import * as actions from './actions';
export default {
export default () => ({
namespaced: true,
state,
mutations,
actions,
};
});
......@@ -10,6 +10,7 @@ export default {
},
[types.RECEIVE_SECURITY_CONFIGURATION_SUCCESS](state, payload) {
state.isLoading = false;
state.hasLoaded = true;
state.errorLoading = false;
state.configuration = payload;
},
......
export default () => ({
securityConfigurationPath: '',
isLoading: false,
hasLoaded: false,
errorLoading: false,
configuration: {},
});
......@@ -13,7 +13,10 @@
'rules_path': expose_path(api_v4_projects_approval_settings_rules_path(id: @project.id)),
'allow_multi_rule': @project.multiple_approval_rules_available?.to_s,
'eligible_approvers_docs_path': help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers'),
'security_approvals_help_page_path': help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')} }
'security_approvals_help_page_path': help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate'),
'security_configuration_path': project_security_configuration_path(@project),
'vulnerability_check_help_page_path': help_page_path('user/application_security/index', anchor: 'enabling-security-approvals-within-a-project'),
'license_check_help_page_path': help_page_path('user/application_security/index', anchor: 'enabling-license-approvals-within-a-project')} }
.text-center.gl-mt-3
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment