Commit 476b71f3 authored by Alexander Turinske's avatar Alexander Turinske

Add ability to create scan execution policy MR

- when security project exists and is linked,
  create policy and open MR for modifications
  to scan_policy.yml
- create new mutations to create scan_execution policy
- create utils file for mutations
- update policy editor layout to show alert on error
- add tests
parent 8a6f6da1
mutation createMergeRequest($input: MergeRequestCreateInput!) {
mergeRequestCreate(input: $input) {
mergeRequest {
iid
}
errors
}
}
<script>
import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
import { GlAlert, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import { mapActions } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import EnvironmentPicker from '../environment_picker.vue';
......@@ -9,6 +9,7 @@ import ScanExecutionPolicyEditor from './scan_execution_policy/scan_execution_po
export default {
components: {
GlAlert,
GlFormGroup,
GlFormSelect,
EnvironmentPicker,
......@@ -17,6 +18,10 @@ export default {
},
mixins: [glFeatureFlagMixin()],
props: {
assignedPolicyProject: {
type: Object,
required: true,
},
existingPolicy: {
type: Object,
required: false,
......@@ -25,6 +30,7 @@ export default {
},
data() {
return {
error: '',
policyType: POLICY_KIND_OPTIONS.network.value,
};
},
......@@ -44,6 +50,9 @@ export default {
},
methods: {
...mapActions('threatMonitoring', ['fetchEnvironments']),
setError(error) {
this.error = error;
},
updatePolicyType(type) {
this.policyType = type;
},
......@@ -54,6 +63,9 @@ export default {
<template>
<section class="policy-editor">
<gl-alert v-if="error" dissmissable="true" variant="danger" @dismiss="setError('')">
{{ error }}
</gl-alert>
<header class="gl-pb-5">
<h3>{{ s__('NetworkPolicies|Policy description') }}</h3>
</header>
......@@ -69,6 +81,11 @@ export default {
</gl-form-group>
<environment-picker v-if="shouldShowEnvironmentPicker" />
</div>
<component :is="policyComponent" :existing-policy="existingPolicy" />
<component
:is="policyComponent"
:existing-policy="existingPolicy"
:assigned-policy-project="assignedPolicyProject"
@error="setError($event)"
/>
</section>
</template>
......@@ -23,6 +23,11 @@ export default {
required: false,
default: EDITOR_MODE_RULE,
},
disableUpdate: {
type: Boolean,
required: false,
default: false,
},
editorModes: {
type: Array,
required: false,
......@@ -132,6 +137,7 @@ export default {
variant="success"
data-testid="save-policy"
:loading="isUpdatingPolicy"
:disabled="disableUpdate"
@click="savePolicy"
>
<slot name="save-button-text">
......
import { s__ } from '~/locale';
export const DEFAULT_MR_TITLE = s__('SecurityOrchestration|Update scan execution policies');
export const GRAPHQL_ERROR_MESSAGE = s__(
'SecurityOrchestration|There was a problem creating the new security policy',
);
export { fromYaml } from './from_yaml';
export * from './constants';
export * from './utils';
export const DEFAULT_SCAN_EXECUTION_POLICY = `type: scan_execution_policy
name: ''
description: ''
......
import assignPolicyProject from 'ee/threat_monitoring/graphql/mutations/assign_policy_project.mutation.graphql';
import createPolicyProject from 'ee/threat_monitoring/graphql/mutations/create_policy_project.mutation.graphql';
import createScanExecutionPolicy from 'ee/threat_monitoring/graphql/mutations/create_scan_execution_policy.mutation.graphql';
import { gqClient } from 'ee/threat_monitoring/utils';
import createMergeRequestMutation from '~/graphql_shared/mutations/create_merge_request.mutation.graphql';
import { DEFAULT_MR_TITLE } from './constants';
/**
* Checks if an error exists and throws it if it does
* @param {Object} payload contains the errors if they exist
*/
const checkForErrors = ({ errors }) => {
if (errors?.length) {
throw new Error(errors);
}
};
/**
* Creates a new security policy project and assigns it to the current project
* @param {String} projectPath
* @returns {Object} contains the new security policy project and any errors
*/
const assignSecurityPolicyProject = async (projectPath) => {
const {
data: {
securityPolicyProjectCreate: { project, errors: createErrors },
},
} = await gqClient.mutate({
mutation: createPolicyProject,
variables: {
projectPath,
},
});
checkForErrors({ errors: createErrors });
const {
data: {
securityPolicyProjectAssign: { errors: assignErrors },
},
} = await gqClient.mutate({
mutation: assignPolicyProject,
variables: {
projectPath,
id: project.id,
},
});
return { ...project, errors: assignErrors };
};
/**
* Creates a merge request for the changes to the policy file
* @param {Object} payload contains the path to the project, the branch to merge on the project, and the branch to merge into
* @returns {Object} contains the id of the merge request and any errors
*/
const createMergeRequest = async ({ projectPath, sourceBranch, targetBranch }) => {
const input = {
projectPath,
sourceBranch,
targetBranch,
title: DEFAULT_MR_TITLE,
};
const {
data: {
mergeRequestCreate: {
mergeRequest: { iid: id },
errors,
},
},
} = await gqClient.mutate({
mutation: createMergeRequestMutation,
variables: { input },
});
return { id, errors };
};
/**
* Creates a new security policy on the security policy project's policy file
* @param {Object} payload contains the path to the project and the policy yaml value
* @returns {Object} contains the branch containing the updated policy file and any errors
*/
const updatePolicy = async ({ projectPath, yamlEditorValue }) => {
const {
data: {
scanExecutionPolicyCommit: { branch, errors },
},
} = await gqClient.mutate({
mutation: createScanExecutionPolicy,
variables: {
projectPath,
policyYaml: yamlEditorValue,
},
});
return { branch, errors };
};
/**
* Updates the assigned security policy project's policy file with the new policy yaml or creates one (project or file) if one does not exist
* @param {Object} payload contains the currently assigned security policy project (if one exists), the path to the project, and the policy yaml value
* @returns {Object} contains the currently assigned security policy project and the created merge request
*/
export const savePolicy = async ({ assignedPolicyProject, projectPath, yamlEditorValue }) => {
let currentAssignedPolicyProject = assignedPolicyProject;
if (!currentAssignedPolicyProject.fullPath) {
currentAssignedPolicyProject = await assignSecurityPolicyProject(projectPath);
}
checkForErrors(currentAssignedPolicyProject);
const newPolicyCommitBranch = await updatePolicy({
projectPath: currentAssignedPolicyProject.fullPath,
yamlEditorValue,
});
checkForErrors(newPolicyCommitBranch);
const mergeRequest = await createMergeRequest({
projectPath: currentAssignedPolicyProject.fullPath,
sourceBranch: newPolicyCommitBranch.branch,
targetBranch: currentAssignedPolicyProject.branch,
});
checkForErrors(mergeRequest);
return { currentAssignedPolicyProject, mergeRequest };
};
<script>
import { removeUnnecessaryDashes } from 'ee/threat_monitoring/utils';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { EDITOR_MODES, EDITOR_MODE_YAML } from '../constants';
import PolicyEditorLayout from '../policy_editor_layout.vue';
import { DEFAULT_SCAN_EXECUTION_POLICY, fromYaml } from './lib';
import { DEFAULT_SCAN_EXECUTION_POLICY, fromYaml, GRAPHQL_ERROR_MESSAGE, savePolicy } from './lib';
export default {
DEFAULT_EDITOR_MODE: EDITOR_MODE_YAML,
......@@ -14,8 +15,12 @@ export default {
components: {
PolicyEditorLayout,
},
inject: ['threatMonitoringPath', 'projectId'],
inject: ['disableScanExecutionUpdate', 'projectId', 'projectPath'],
props: {
assignedPolicyProject: {
type: Object,
required: true,
},
existingPolicy: {
type: Object,
required: false,
......@@ -28,6 +33,8 @@ export default {
: DEFAULT_SCAN_EXECUTION_POLICY;
return {
error: '',
isCreatingMR: false,
policy: fromYaml(yamlEditorValue),
yamlEditorValue,
};
......@@ -38,6 +45,34 @@ export default {
},
},
methods: {
async handleSavePolicy() {
this.$emit('error', '');
this.isCreatingMR = true;
try {
const { currentAssignedPolicyProject, mergeRequest } = await savePolicy({
assignedPolicyProject: this.assignedPolicyProject,
projectPath: this.projectPath,
yamlEditorValue: this.yamlEditorValue,
});
visitUrl(
joinPaths(
gon.relative_url_root || '/',
currentAssignedPolicyProject.fullPath,
'/-/merge_requests',
mergeRequest.id,
),
);
} catch (e) {
if (e.message.toLowerCase().includes('graphql')) {
this.$emit('error', GRAPHQL_ERROR_MESSAGE);
} else {
this.$emit('error', e.message);
}
this.isCreatingMR = false;
}
},
updateYaml(manifest) {
this.yamlEditorValue = manifest;
},
......@@ -48,10 +83,13 @@ export default {
<template>
<policy-editor-layout
:default-editor-mode="$options.DEFAULT_EDITOR_MODE"
:disable-update="disableScanExecutionUpdate"
:editor-modes="$options.EDITOR_MODES"
:is-editing="isEditing"
:is-updating-policy="isCreatingMR"
:policy-name="policy.name"
:yaml-editor-value="yamlEditorValue"
@save-policy="handleSavePolicy"
@update-yaml="updateYaml"
>
<template #save-button-text>
......
import { s__ } from '~/locale';
export const DEFAULT_ASSIGNED_POLICY_PROJECT = { fullPath: '', branch: '' };
export const INVALID_CURRENT_ENVIRONMENT_NAME = '';
export const PREDEFINED_NETWORK_POLICIES = [
......
mutation assignPolicyProject($projectPath: ID!, $id: ProjectID!) {
securityPolicyProjectAssign(input: { projectPath: $projectPath, securityPolicyProjectId: $id }) {
errors
}
}
mutation createPolicyProject($projectPath: ID!) {
securityPolicyProjectCreate(input: { projectPath: $projectPath }) {
project {
fullPath
id
}
errors
}
}
mutation updatePolicy(
$projectPath: ID!
$mode: MutationOperationMode = APPEND
$policyYaml: String!
) {
scanExecutionPolicyCommit(
input: { projectPath: $projectPath, operationMode: $mode, policyYaml: $policyYaml }
) {
branch
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import PolicyEditorApp from './components/policy_editor/policy_editor.vue';
import { DEFAULT_ASSIGNED_POLICY_PROJECT } from './constants';
import createStore from './store';
import { gqClient } from './utils';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: gqClient,
});
export default () => {
const el = document.querySelector('#js-policy-builder-app');
const {
assignedPolicyProject,
disableScanExecutionUpdate,
environmentsEndpoint,
configureAgentHelpPath,
createAgentHelpPath,
......@@ -36,7 +40,16 @@ export default () => {
store.dispatch('threatMonitoring/setCurrentEnvironmentId', parseInt(environmentId, 10));
}
const props = policy ? { existingPolicy: JSON.parse(policy) } : {};
const policyProject = JSON.parse(assignedPolicyProject);
const props = {
assignedPolicyProject: policyProject
? convertObjectPropsToCamelCase(policyProject)
: DEFAULT_ASSIGNED_POLICY_PROJECT,
};
if (policy) {
props.existingPolicy = JSON.parse(policy);
}
return new Vue({
el,
......@@ -44,6 +57,7 @@ export default () => {
provide: {
configureAgentHelpPath,
createAgentHelpPath,
disableScanExecutionUpdate: parseBoolean(disableScanExecutionUpdate),
projectId,
projectPath,
threatMonitoringPath,
......
import createGqClient from '~/lib/graphql';
import { POLICY_KINDS } from './components/constants';
/**
......@@ -36,3 +37,8 @@ export const getPolicyKind = (yaml = '') => {
export const removeUnnecessaryDashes = (manifest) => {
return manifest.replace('---\n', '');
};
/**
* Create GraphQL Client for threat monitoring
*/
export const gqClient = createGqClient();
......@@ -27,7 +27,11 @@ module PolicyHelper
private
def details(project)
disable_scan_execution_update = !can_update_security_orchestration_policy_project?(project)
{
assigned_policy_project: assigned_policy_project(project).to_json,
disable_scan_execution_update: disable_scan_execution_update.to_s,
network_policies_endpoint: project_security_network_policies_path(project),
configure_agent_help_path: help_page_url('user/clusters/agent/repository.html'),
create_agent_help_path: help_page_url('user/clusters/agent/index.md', anchor: 'create-an-agent-record-in-gitlab'),
......
......@@ -7,6 +7,11 @@ module Projects::Security::PoliciesHelper
orchestration_policy_configuration = project.security_orchestration_policy_configuration
security_policy_management_project = orchestration_policy_configuration.security_policy_management_project
{ id: security_policy_management_project.to_global_id.to_s, name: security_policy_management_project.name }
{
id: security_policy_management_project.to_global_id.to_s,
name: security_policy_management_project.name,
full_path: security_policy_management_project.full_path,
branch: security_policy_management_project.default_branch_or_main
}
end
end
......@@ -67,6 +67,7 @@ describe('PolicyEditorLayout component', () => {
it('does display custom save button text', () => {
const saveButton = findSavePolicyButton();
expect(saveButton.exists()).toBe(true);
expect(saveButton.attributes('disabled')).toBe(undefined);
expect(saveButton.text()).toBe('Create policy');
});
});
......@@ -122,4 +123,12 @@ describe('PolicyEditorLayout component', () => {
expect(wrapper.emitted('update-yaml')).toStrictEqual([[newManifest]]);
});
});
describe('disabled actions', () => {
it('disables the save button', async () => {
factory({ propsData: { disableUpdate: true } });
expect(findSavePolicyButton().exists()).toBe(true);
expect(findSavePolicyButton().attributes('disabled')).toBe('true');
});
});
});
import { GlFormSelect } from '@gitlab/ui';
import { GlAlert, GlFormSelect } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EnvironmentPicker from 'ee/threat_monitoring/components/environment_picker.vue';
import { POLICY_KIND_OPTIONS } from 'ee/threat_monitoring/components/policy_editor/constants';
import NetworkPolicyEditor from 'ee/threat_monitoring/components/policy_editor/network_policy/network_policy_editor.vue';
import PolicyEditor from 'ee/threat_monitoring/components/policy_editor/policy_editor.vue';
import { DEFAULT_ASSIGNED_POLICY_PROJECT } from 'ee/threat_monitoring/constants';
import createStore from 'ee/threat_monitoring/store';
import { mockL3Manifest } from '../../mocks/mock_data';
......@@ -11,6 +12,7 @@ describe('PolicyEditor component', () => {
let store;
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
const findEnvironmentPicker = () => wrapper.findComponent(EnvironmentPicker);
const findFormSelect = () => wrapper.findComponent(GlFormSelect);
const findNeworkPolicyEditor = () => wrapper.findComponent(NetworkPolicyEditor);
......@@ -21,7 +23,10 @@ describe('PolicyEditor component', () => {
jest.spyOn(store, 'dispatch').mockImplementation(() => Promise.resolve());
wrapper = shallowMount(PolicyEditor, {
propsData,
propsData: {
assignedPolicyProject: DEFAULT_ASSIGNED_POLICY_PROJECT,
...propsData,
},
provide,
store,
stubs: { GlFormSelect },
......@@ -35,8 +40,13 @@ describe('PolicyEditor component', () => {
describe('default', () => {
beforeEach(factory);
it('renders the environment picker', () => {
expect(findEnvironmentPicker().exists()).toBe(true);
it.each`
component | status | findComponent | state
${'environment picker'} | ${'does display'} | ${findEnvironmentPicker} | ${true}
${'NetworkPolicyEditor component'} | ${'does display'} | ${findNeworkPolicyEditor} | ${true}
${'alert'} | ${'does not display'} | ${findAlert} | ${false}
`('$status the $component', ({ findComponent, state }) => {
expect(findComponent().exists()).toBe(state);
});
it('renders the disabled form select', () => {
......@@ -46,8 +56,13 @@ describe('PolicyEditor component', () => {
expect(formSelect.attributes('disabled')).toBe('true');
});
it('renders the "NetworkPolicyEditor" component', () => {
expect(findNeworkPolicyEditor().exists()).toBe(true);
it('shows an alert when "error" is emitted from the component', async () => {
const errorMessage = 'test';
findNeworkPolicyEditor().vm.$emit('error', errorMessage);
await wrapper.vm.$nextTick();
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(errorMessage);
});
});
......
import { savePolicy } from 'ee/threat_monitoring/components/policy_editor/scan_execution_policy/lib/utils';
import { DEFAULT_ASSIGNED_POLICY_PROJECT } from 'ee/threat_monitoring/constants';
import assignPolicyProject from 'ee/threat_monitoring/graphql/mutations/assign_policy_project.mutation.graphql';
import createPolicyProject from 'ee/threat_monitoring/graphql/mutations/create_policy_project.mutation.graphql';
import createScanExecutionPolicy from 'ee/threat_monitoring/graphql/mutations/create_scan_execution_policy.mutation.graphql';
import { gqClient } from 'ee/threat_monitoring/utils';
import createMergeRequestMutation from '~/graphql_shared/mutations/create_merge_request.mutation.graphql';
jest.mock('ee/threat_monitoring/utils');
const defaultAssignedPolicyProject = { fullPath: 'path/to/policy-project', branch: 'main' };
const newAssignedPolicyProject = { fullPath: 'path/to/new-project', branch: 'main' };
const projectPath = 'path/to/current-project';
const yamlEditorValue = 'some yaml';
const createSavePolicyInput = (assignedPolicyProject = defaultAssignedPolicyProject) => ({
assignedPolicyProject,
projectPath,
yamlEditorValue,
});
const error = 'There was an error';
const mockApolloResponses = (shouldReject) => {
return ({ mutation }) => {
if (mutation === createPolicyProject) {
return Promise.resolve({
data: {
securityPolicyProjectCreate: {
project: newAssignedPolicyProject,
errors: [],
},
},
});
} else if (mutation === assignPolicyProject) {
return Promise.resolve({
data: {
securityPolicyProjectAssign: { errors: [] },
},
});
} else if (mutation === createScanExecutionPolicy) {
return Promise.resolve({
data: {
scanExecutionPolicyCommit: {
branch: 'new-branch',
errors: shouldReject ? [error] : [],
},
},
});
} else if (mutation === createMergeRequestMutation) {
return Promise.resolve({
data: { mergeRequestCreate: { mergeRequest: { iid: '01' }, errors: [] } },
});
}
return Promise.resolve();
};
};
describe('savePolicy', () => {
it('returns the policy project and merge request on success when a policy project does not exist', async () => {
gqClient.mutate.mockImplementation(mockApolloResponses());
const { currentAssignedPolicyProject, mergeRequest } = await savePolicy(
createSavePolicyInput(DEFAULT_ASSIGNED_POLICY_PROJECT),
);
expect(currentAssignedPolicyProject).toStrictEqual({ ...newAssignedPolicyProject, errors: [] });
expect(mergeRequest).toStrictEqual({ id: '01', errors: [] });
});
it('returns the policy project and merge request on success when a policy project does exist', async () => {
gqClient.mutate.mockImplementation(mockApolloResponses());
const { currentAssignedPolicyProject, mergeRequest } = await savePolicy(
createSavePolicyInput(),
);
expect(currentAssignedPolicyProject).toStrictEqual(defaultAssignedPolicyProject);
expect(mergeRequest).toStrictEqual({ id: '01', errors: [] });
});
it('throws when an error is detected', async () => {
gqClient.mutate.mockImplementation(mockApolloResponses(true));
await expect(savePolicy(createSavePolicyInput())).rejects.toThrowError(error);
});
});
import { shallowMount } from '@vue/test-utils';
import PolicyEditorLayout from 'ee/threat_monitoring/components/policy_editor/policy_editor_layout.vue';
import { DEFAULT_SCAN_EXECUTION_POLICY } from 'ee/threat_monitoring/components/policy_editor/scan_execution_policy/lib';
import {
DEFAULT_SCAN_EXECUTION_POLICY,
savePolicy,
} from 'ee/threat_monitoring/components/policy_editor/scan_execution_policy/lib';
import ScanExecutionPolicyEditor from 'ee/threat_monitoring/components/policy_editor/scan_execution_policy/scan_execution_policy_editor.vue';
import { DEFAULT_ASSIGNED_POLICY_PROJECT } from 'ee/threat_monitoring/constants';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
visitUrl: jest.fn().mockName('visitUrlMock'),
}));
jest.mock('ee/threat_monitoring/components/policy_editor/scan_execution_policy/lib', () => ({
DEFAULT_SCAN_EXECUTION_POLICY: jest.requireActual(
'ee/threat_monitoring/components/policy_editor/scan_execution_policy/lib',
).DEFAULT_SCAN_EXECUTION_POLICY,
fromYaml: jest.requireActual(
'ee/threat_monitoring/components/policy_editor/scan_execution_policy/lib',
).fromYaml,
savePolicy: jest.fn().mockResolvedValue({
currentAssignedPolicyProject: { fullPath: 'tests' },
mergeRequest: { id: '2' },
}),
}));
describe('ScanExecutionPolicyEditor', () => {
let wrapper;
const defaultProjectPath = 'path/to/project';
const factory = ({ propsData = {} } = {}) => {
wrapper = shallowMount(ScanExecutionPolicyEditor, {
propsData,
propsData: {
assignedPolicyProject: DEFAULT_ASSIGNED_POLICY_PROJECT,
...propsData,
},
provide: {
threatMonitoringPath: '',
disableScanExecutionUpdate: false,
projectId: 1,
projectPath: defaultProjectPath,
},
});
};
......@@ -34,4 +62,18 @@ describe('ScanExecutionPolicyEditor', () => {
await findPolicyEditorLayout().vm.$emit('update-yaml', newManifest);
expect(findPolicyEditorLayout().attributes('yamleditorvalue')).toBe(newManifest);
});
it('saves the policy when "savePolicy" is emitted', async () => {
findPolicyEditorLayout().vm.$emit('save-policy');
await wrapper.vm.$nextTick();
expect(savePolicy).toHaveBeenCalledTimes(1);
expect(savePolicy).toHaveBeenCalledWith({
assignedPolicyProject: DEFAULT_ASSIGNED_POLICY_PROJECT,
projectPath: defaultProjectPath,
yamlEditorValue: DEFAULT_SCAN_EXECUTION_POLICY,
});
await wrapper.vm.$nextTick();
expect(visitUrl).toHaveBeenCalled();
expect(visitUrl).toHaveBeenCalledWith('/tests/-/merge_requests/2');
});
});
......@@ -17,6 +17,8 @@ RSpec.describe PolicyHelper do
let(:base_data) do
{
assigned_policy_project: "null",
disable_scan_execution_update: "false",
network_policies_endpoint: kind_of(String),
configure_agent_help_path: kind_of(String),
create_agent_help_path: kind_of(String),
......@@ -28,6 +30,13 @@ RSpec.describe PolicyHelper do
end
describe '#policy_details' do
let(:owner) { project.owner }
before do
allow(helper).to receive(:current_user) { owner }
allow(helper).to receive(:can?).with(owner, :update_security_orchestration_policy_project, project) { true }
end
context 'when a new policy is being created' do
subject { helper.policy_details(project) }
......
......@@ -29442,6 +29442,12 @@ msgstr ""
msgid "SecurityOrchestration|Select security project"
msgstr ""
msgid "SecurityOrchestration|There was a problem creating the new security policy"
msgstr ""
msgid "SecurityOrchestration|Update scan execution policies"
msgstr ""
msgid "SecurityPolicies|+%{count} more"
msgstr ""
......
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