From c74e85c28d1ea10df3582e4f936e1edb675ab8a9 Mon Sep 17 00:00:00 2001
From: Lorenz van Herwaarden <lvanherwaarden@gitlab.com>
Date: Tue, 17 Dec 2024 04:06:56 +0000
Subject: [PATCH] Support group-level vulnerability management policy type in
 frontend

This adds checks for the vulnerability management policy type group
feature flag in the policy list, app, and type selector components.

It also guards the resolver for group vulnerability management policies
behind the same feature flag.
---
 .../components/policies/app.vue               |  7 +-
 .../policies/filters/type_filter.vue          |  3 +-
 .../components/policies/list_component.vue    |  3 +-
 .../components/policies/utils.js              | 11 ++-
 ...rability_management_policies.query.graphql | 26 ++++++
 ...ulnerability_management_policy_resolver.rb |  8 +-
 ...erability_management_policy_type_group.yml |  2 +-
 .../components/policies/app_spec.js           | 87 +++++++++++++++----
 .../policies/filters/type_filter_spec.js      | 20 +++--
 .../components/policies/utils_spec.js         |  2 +
 .../mocks/mock_apollo.js                      |  6 ++
 ...ability_management_policy_resolver_spec.rb | 14 ++-
 12 files changed, 159 insertions(+), 30 deletions(-)
 create mode 100644 ee/app/assets/javascripts/security_orchestration/graphql/queries/group_vulnerability_management_policies.query.graphql
 rename ee/config/feature_flags/{wip => beta}/vulnerability_management_policy_type_group.yml (96%)

diff --git a/ee/app/assets/javascripts/security_orchestration/components/policies/app.vue b/ee/app/assets/javascripts/security_orchestration/components/policies/app.vue
index 808ee699c1b14..a445dd78f3da7 100644
--- a/ee/app/assets/javascripts/security_orchestration/components/policies/app.vue
+++ b/ee/app/assets/javascripts/security_orchestration/components/policies/app.vue
@@ -18,6 +18,7 @@ import groupScanResultPoliciesQuery from '../../graphql/queries/group_scan_resul
 import projectPipelineExecutionPoliciesQuery from '../../graphql/queries/project_pipeline_execution_policies.query.graphql';
 import groupPipelineExecutionPoliciesQuery from '../../graphql/queries/group_pipeline_execution_policies.query.graphql';
 import projectVulnerabilityManagementPoliciesQuery from '../../graphql/queries/project_vulnerability_management_policies.query.graphql';
+import groupVulnerabilityManagementPoliciesQuery from '../../graphql/queries/group_vulnerability_management_policies.query.graphql';
 import ListHeader from './list_header.vue';
 import ListComponent from './list_component.vue';
 import {
@@ -41,6 +42,7 @@ const NAMESPACE_QUERY_DICT = {
   },
   vulnerabilityManagement: {
     [NAMESPACE_TYPES.PROJECT]: projectVulnerabilityManagementPoliciesQuery,
+    [NAMESPACE_TYPES.GROUP]: groupVulnerabilityManagementPoliciesQuery,
   },
 };
 
@@ -215,7 +217,10 @@ export default {
       );
     },
     vulnerabilityManagementPolicyEnabled() {
-      return this.glFeatures.vulnerabilityManagementPolicyType;
+      return (
+        this.glFeatures.vulnerabilityManagementPolicyType ||
+        this.glFeatures.vulnerabilityManagementPolicyTypeGroup
+      );
     },
   },
   methods: {
diff --git a/ee/app/assets/javascripts/security_orchestration/components/policies/filters/type_filter.vue b/ee/app/assets/javascripts/security_orchestration/components/policies/filters/type_filter.vue
index e831e21e22564..622172a528c30 100644
--- a/ee/app/assets/javascripts/security_orchestration/components/policies/filters/type_filter.vue
+++ b/ee/app/assets/javascripts/security_orchestration/components/policies/filters/type_filter.vue
@@ -21,7 +21,8 @@ export default {
   },
   computed: {
     options() {
-      return this.glFeatures.vulnerabilityManagementPolicyType
+      return this.glFeatures.vulnerabilityManagementPolicyType ||
+        this.glFeatures.vulnerabilityManagementPolicyTypeGroup
         ? {
             ...POLICY_TYPE_FILTER_OPTIONS,
             ...VULNERABILITY_MANAGEMENT_FILTER_OPTION,
diff --git a/ee/app/assets/javascripts/security_orchestration/components/policies/list_component.vue b/ee/app/assets/javascripts/security_orchestration/components/policies/list_component.vue
index 6dec884a2b695..4333a678e8acf 100644
--- a/ee/app/assets/javascripts/security_orchestration/components/policies/list_component.vue
+++ b/ee/app/assets/javascripts/security_orchestration/components/policies/list_component.vue
@@ -144,7 +144,8 @@ export default {
   },
   computed: {
     policyTypeFilterOptions() {
-      return this.glFeatures.vulnerabilityManagementPolicyType
+      return this.glFeatures.vulnerabilityManagementPolicyType ||
+        this.glFeatures.vulnerabilityManagementPolicyTypeGroup
         ? {
             ...POLICY_TYPE_FILTER_OPTIONS,
             ...VULNERABILITY_MANAGEMENT_FILTER_OPTION,
diff --git a/ee/app/assets/javascripts/security_orchestration/components/policies/utils.js b/ee/app/assets/javascripts/security_orchestration/components/policies/utils.js
index a911edbeadb41..2b958f5179c33 100644
--- a/ee/app/assets/javascripts/security_orchestration/components/policies/utils.js
+++ b/ee/app/assets/javascripts/security_orchestration/components/policies/utils.js
@@ -28,9 +28,14 @@ const validateFilter = (allowedValues, value, lowerCase = false) => {
  * @returns {boolean}
  */
 export const validateTypeFilter = (value) => {
-  const options = gon.features?.vulnerabilityManagementPolicyType
-    ? { ...POLICY_TYPE_FILTER_OPTIONS, ...VULNERABILITY_MANAGEMENT_FILTER_OPTION }
-    : POLICY_TYPE_FILTER_OPTIONS;
+  const { vulnerabilityManagementPolicyType, vulnerabilityManagementPolicyTypeGroup } =
+    window.gon.features || {};
+
+  let options = POLICY_TYPE_FILTER_OPTIONS;
+  if (vulnerabilityManagementPolicyType || vulnerabilityManagementPolicyTypeGroup) {
+    options = { ...options, ...VULNERABILITY_MANAGEMENT_FILTER_OPTION };
+  }
+
   return validateFilter(options, value, true);
 };
 
diff --git a/ee/app/assets/javascripts/security_orchestration/graphql/queries/group_vulnerability_management_policies.query.graphql b/ee/app/assets/javascripts/security_orchestration/graphql/queries/group_vulnerability_management_policies.query.graphql
new file mode 100644
index 0000000000000..53763e84c637a
--- /dev/null
+++ b/ee/app/assets/javascripts/security_orchestration/graphql/queries/group_vulnerability_management_policies.query.graphql
@@ -0,0 +1,26 @@
+#import "../fragments/scan_policy_source.fragment.graphql"
+#import "../fragments/policy_scope.fragment.graphql"
+
+query groupVulnerabilityManagementPolicies(
+  $fullPath: ID!
+  $relationship: SecurityPolicyRelationType = INHERITED
+) {
+  namespace: group(fullPath: $fullPath) {
+    id
+    vulnerabilityManagementPolicies(relationship: $relationship) {
+      nodes {
+        name
+        yaml
+        editPath
+        enabled
+        policyScope {
+          ...PolicyScope
+        }
+        source {
+          ...SecurityPolicySource
+        }
+        updatedAt
+      }
+    }
+  }
+}
diff --git a/ee/app/graphql/resolvers/security/vulnerability_management_policy_resolver.rb b/ee/app/graphql/resolvers/security/vulnerability_management_policy_resolver.rb
index 49ea5683bba19..584b6f963742a 100644
--- a/ee/app/graphql/resolvers/security/vulnerability_management_policy_resolver.rb
+++ b/ee/app/graphql/resolvers/security/vulnerability_management_policy_resolver.rb
@@ -18,11 +18,15 @@ class VulnerabilityManagementPolicyResolver < BaseResolver
         default_value: true
 
       def resolve(**args)
-        if Feature.disabled?(:vulnerability_management_policy_type, project)
+        if object.is_a?(Group) && Feature.disabled?(:vulnerability_management_policy_type_group, object)
+          raise_resource_not_available_error! '`vulnerability_management_policy_type_group` feature flag is disabled.'
+        end
+
+        if object.is_a?(Project) && Feature.disabled?(:vulnerability_management_policy_type, object)
           raise_resource_not_available_error! '`vulnerability_management_policy_type` feature flag is disabled.'
         end
 
-        policies = ::Security::VulnerabilityManagementPoliciesFinder.new(context[:current_user], project, args).execute
+        policies = ::Security::VulnerabilityManagementPoliciesFinder.new(context[:current_user], object, args).execute
         construct_vulnerability_management_policies(policies)
       end
     end
diff --git a/ee/config/feature_flags/wip/vulnerability_management_policy_type_group.yml b/ee/config/feature_flags/beta/vulnerability_management_policy_type_group.yml
similarity index 96%
rename from ee/config/feature_flags/wip/vulnerability_management_policy_type_group.yml
rename to ee/config/feature_flags/beta/vulnerability_management_policy_type_group.yml
index 415b16888ca44..79fa2ea0128df 100644
--- a/ee/config/feature_flags/wip/vulnerability_management_policy_type_group.yml
+++ b/ee/config/feature_flags/beta/vulnerability_management_policy_type_group.yml
@@ -5,5 +5,5 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/155858
 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/467370
 milestone: '17.2'
 group: group::security insights
-type: wip
+type: beta
 default_enabled: false
diff --git a/ee/spec/frontend/security_orchestration/components/policies/app_spec.js b/ee/spec/frontend/security_orchestration/components/policies/app_spec.js
index 5ab5962f4fb0f..cb3ac0c8e53ac 100644
--- a/ee/spec/frontend/security_orchestration/components/policies/app_spec.js
+++ b/ee/spec/frontend/security_orchestration/components/policies/app_spec.js
@@ -23,6 +23,7 @@ import groupScanResultPoliciesQuery from 'ee/security_orchestration/graphql/quer
 import projectPipelineExecutionPoliciesQuery from 'ee/security_orchestration/graphql/queries/project_pipeline_execution_policies.query.graphql';
 import groupPipelineExecutionPoliciesQuery from 'ee/security_orchestration/graphql/queries/group_pipeline_execution_policies.query.graphql';
 import projectVulnerabilityManagementPoliciesQuery from 'ee/security_orchestration/graphql/queries/project_vulnerability_management_policies.query.graphql';
+import groupVulnerabilityManagementPoliciesQuery from 'ee/security_orchestration/graphql/queries/group_vulnerability_management_policies.query.graphql';
 import { mockPipelineExecutionPoliciesResponse } from '../../mocks/mock_pipeline_execution_policy_data';
 import { mockVulnerabilityManagementPoliciesResponse } from '../../mocks/mock_vulnerability_management_policy_data';
 import {
@@ -30,9 +31,10 @@ import {
   groupScanExecutionPolicies,
   projectScanResultPolicies,
   groupScanResultPolicies,
-  groupPipelineResultPolicies,
   projectPipelineResultPolicies,
+  groupPipelineResultPolicies,
   projectVulnerabilityManagementPolicies,
+  groupVulnerabilityManagementPolicies,
   mockLinkedSppItemsResponse,
 } from '../../mocks/mock_apollo';
 import {
@@ -61,6 +63,9 @@ const groupPipelineExecutionPoliciesSpy = groupPipelineResultPolicies(
 const projectVulnerabilityManagementPoliciesSpy = projectVulnerabilityManagementPolicies(
   mockVulnerabilityManagementPoliciesResponse,
 );
+const groupVulnerabilityManagementPoliciesSpy = groupVulnerabilityManagementPolicies(
+  mockVulnerabilityManagementPoliciesResponse,
+);
 
 const linkedSppItemsResponseSpy = mockLinkedSppItemsResponse();
 const defaultRequestHandlers = {
@@ -71,6 +76,7 @@ const defaultRequestHandlers = {
   projectPipelineExecutionPolicies: projectPipelineExecutionPoliciesSpy,
   groupPipelineExecutionPolicies: groupPipelineExecutionPoliciesSpy,
   projectVulnerabilityManagementPolicies: projectVulnerabilityManagementPoliciesSpy,
+  groupVulnerabilityManagementPolicies: groupVulnerabilityManagementPoliciesSpy,
   linkedSppItemsResponse: linkedSppItemsResponseSpy,
 };
 
@@ -105,6 +111,10 @@ describe('App', () => {
           projectVulnerabilityManagementPoliciesQuery,
           requestHandlers.projectVulnerabilityManagementPolicies,
         ],
+        [
+          groupVulnerabilityManagementPoliciesQuery,
+          requestHandlers.groupVulnerabilityManagementPolicies,
+        ],
       ]),
     });
   };
@@ -126,6 +136,11 @@ describe('App', () => {
       createWrapper({ provide: { glFeatures: { vulnerabilityManagementPolicyType: true } } });
       expect(findPoliciesList().props('isLoadingPolicies')).toBe(true);
     });
+
+    it('renders the policies list correctly when vulnerabilityManagementPolicyTypeGroup is true', () => {
+      createWrapper({ provide: { glFeatures: { vulnerabilityManagementPolicyTypeGroup: true } } });
+      expect(findPoliciesList().props('isLoadingPolicies')).toBe(true);
+    });
   });
 
   describe('default', () => {
@@ -151,36 +166,39 @@ describe('App', () => {
       });
     });
 
-    describe('when vulnerabilityManagementPolicyType is false', () => {
-      it.each`
-        type                          | projectHandler
-        ${'vulnerability management'} | ${'projectVulnerabilityManagementPolicies'}
-      `('does not fetch project-level $type policies', ({ projectHandler }) => {
-        expect(requestHandlers[projectHandler]).not.toHaveBeenCalled();
-      });
+    it('does not fetch project-level vulnerability management policies', () => {
+      expect(requestHandlers.projectVulnerabilityManagementPolicies).not.toHaveBeenCalled();
     });
 
     describe('when vulnerabilityManagementPolicyType is true', () => {
       beforeEach(async () => {
-        gon.features = { vulnerabilityManagementPolicyType: true };
-
         createWrapper({
           provide: { glFeatures: { vulnerabilityManagementPolicyType: true } },
         });
         await waitForPromises();
       });
 
-      it.each`
-        type                          | projectHandler
-        ${'vulnerability management'} | ${'projectVulnerabilityManagementPolicies'}
-      `('fetches project-level $type policies', ({ projectHandler }) => {
-        expect(requestHandlers[projectHandler]).toHaveBeenCalledWith({
+      it('fetches project-level vulnerability management policies', () => {
+        expect(requestHandlers.projectVulnerabilityManagementPolicies).toHaveBeenCalledWith({
           fullPath: namespacePath,
           relationship: POLICY_SOURCE_OPTIONS.ALL.value,
         });
       });
     });
 
+    describe('when vulnerabilityManagementPolicyTypeGroup is true', () => {
+      beforeEach(async () => {
+        createWrapper({
+          provide: { glFeatures: { vulnerabilityManagementPolicyTypeGroup: true } },
+        });
+        await waitForPromises();
+      });
+
+      it('does not fetch group-level vulnerability management policies', () => {
+        expect(requestHandlers.groupVulnerabilityManagementPolicies).not.toHaveBeenCalled();
+      });
+    });
+
     it('renders the policy header correctly', () => {
       expect(findPoliciesHeader().props('hasInvalidPolicies')).toBe(false);
     });
@@ -264,6 +282,45 @@ describe('App', () => {
       expect(linkedSppItemsResponseSpy).toHaveBeenCalledTimes(0);
     });
 
+    it('does not fetch group-level vulnerability management policies', () => {
+      expect(requestHandlers.groupVulnerabilityManagementPolicies).not.toHaveBeenCalled();
+    });
+
+    describe('when vulnerabilityManagementPolicyTypeGroup is true', () => {
+      beforeEach(async () => {
+        createWrapper({
+          provide: {
+            namespaceType: NAMESPACE_TYPES.GROUP,
+            glFeatures: { vulnerabilityManagementPolicyTypeGroup: true },
+          },
+        });
+        await waitForPromises();
+      });
+
+      it('fetches group-level vulnerability management polices', () => {
+        expect(requestHandlers.groupVulnerabilityManagementPolicies).toHaveBeenCalledWith({
+          fullPath: namespacePath,
+          relationship: POLICY_SOURCE_OPTIONS.ALL.value,
+        });
+      });
+    });
+
+    describe('when vulnerabilityManagementPolicyType is true', () => {
+      beforeEach(async () => {
+        createWrapper({
+          provide: {
+            namespaceType: NAMESPACE_TYPES.GROUP,
+            glFeatures: { vulnerabilityManagementPolicyType: true },
+          },
+        });
+        await waitForPromises();
+      });
+
+      it('does not fetch project-level vulnerability management policies', () => {
+        expect(requestHandlers.projectVulnerabilityManagementPolicies).not.toHaveBeenCalled();
+      });
+    });
+
     it.each`
       type                    | groupHandler                        | projectHandler
       ${'scan execution'}     | ${'groupScanExecutionPolicies'}     | ${'projectScanExecutionPolicies'}
diff --git a/ee/spec/frontend/security_orchestration/components/policies/filters/type_filter_spec.js b/ee/spec/frontend/security_orchestration/components/policies/filters/type_filter_spec.js
index 77182b531f9e0..09777c13ea098 100644
--- a/ee/spec/frontend/security_orchestration/components/policies/filters/type_filter_spec.js
+++ b/ee/spec/frontend/security_orchestration/components/policies/filters/type_filter_spec.js
@@ -9,15 +9,19 @@ import TypeFilter from 'ee/security_orchestration/components/policies/filters/ty
 describe('TypeFilter component', () => {
   let wrapper;
 
-  const createWrapper = ({ value = '', vulnerabilityManagementPolicyType = true } = {}) => {
+  const createWrapper = ({
+    value = '',
+    glFeatures = {
+      vulnerabilityManagementPolicyTypeGroup: true,
+      vulnerabilityManagementPolicyType: true,
+    },
+  } = {}) => {
     wrapper = shallowMount(TypeFilter, {
       propsData: {
         value,
       },
       provide: {
-        glFeatures: {
-          vulnerabilityManagementPolicyType,
-        },
+        glFeatures,
       },
       stubs: {
         GlCollapsibleListbox,
@@ -37,7 +41,13 @@ describe('TypeFilter component', () => {
     });
 
     it('does not pass vulnerability management option when feature flag is disabled', () => {
-      createWrapper({ vulnerabilityManagementPolicyType: false });
+      // Both project-level and group-level feature flags need to be disabled
+      createWrapper({
+        glFeatures: {
+          vulnerabilityManagementPolicyType: false,
+          vulnerabilityManagementPolicyTypeGroup: false,
+        },
+      });
 
       expect(findToggle().props('items')).toMatchObject(Object.values(POLICY_TYPE_FILTER_OPTIONS));
     });
diff --git a/ee/spec/frontend/security_orchestration/components/policies/utils_spec.js b/ee/spec/frontend/security_orchestration/components/policies/utils_spec.js
index ba0064556d548..0dec163db693c 100644
--- a/ee/spec/frontend/security_orchestration/components/policies/utils_spec.js
+++ b/ee/spec/frontend/security_orchestration/components/policies/utils_spec.js
@@ -63,8 +63,10 @@ describe('utils', () => {
     });
 
     it('returns false for vulnerability management filter option when feature flag is disabled', () => {
+      // Both project-level and group-level feature flags need to be disabled
       window.gon.features = {
         vulnerabilityManagementPolicyType: false,
+        vulnerabilityManagementPolicyTypeGroup: false,
       };
       expect(
         validateTypeFilter(
diff --git a/ee/spec/frontend/security_orchestration/mocks/mock_apollo.js b/ee/spec/frontend/security_orchestration/mocks/mock_apollo.js
index 04d320b7aa133..36df5239eb3b9 100644
--- a/ee/spec/frontend/security_orchestration/mocks/mock_apollo.js
+++ b/ee/spec/frontend/security_orchestration/mocks/mock_apollo.js
@@ -43,6 +43,12 @@ export const projectVulnerabilityManagementPolicies = (nodes) =>
     namespaceType: 'Project',
     policyType: 'vulnerabilityManagementPolicies',
   });
+export const groupVulnerabilityManagementPolicies = (nodes) =>
+  mockPolicyResponse({
+    nodes,
+    namespaceType: 'Group',
+    policyType: 'vulnerabilityManagementPolicies',
+  });
 
 export const mockLinkSecurityPolicyProjectResponses = {
   success: jest.fn().mockResolvedValue({
diff --git a/ee/spec/graphql/resolvers/security/vulnerability_management_policy_resolver_spec.rb b/ee/spec/graphql/resolvers/security/vulnerability_management_policy_resolver_spec.rb
index c0be3e4288ece..026e150e12a28 100644
--- a/ee/spec/graphql/resolvers/security/vulnerability_management_policy_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/security/vulnerability_management_policy_resolver_spec.rb
@@ -40,7 +40,7 @@
 
   it_behaves_like 'as an orchestration policy'
 
-  context 'when feature flag `vulnerability_management_policy_type` disabled' do
+  context 'when feature flag `vulnerability_management_policy_type` disabled and project' do
     before do
       stub_feature_flags(vulnerability_management_policy_type: false)
     end
@@ -51,4 +51,16 @@
       expect(result).to be_a(::Gitlab::Graphql::Errors::ResourceNotAvailable)
     end
   end
+
+  context 'when feature flag `vulnerability_management_policy_type_group` disabled and group' do
+    before do
+      stub_feature_flags(vulnerability_management_policy_type_group: false)
+    end
+
+    it 'returns a resource not available error' do
+      result = resolve(described_class, obj: Group.new, ctx: { current_user: user })
+
+      expect(result).to be_a(::Gitlab::Graphql::Errors::ResourceNotAvailable)
+    end
+  end
 end
-- 
GitLab