]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22592 Make GitLab's project visibility read only (#11415)
authorSarath Nair <91882341+sarath-nair-sonarsource@users.noreply.github.com>
Thu, 25 Jul 2024 07:25:53 +0000 (09:25 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 25 Jul 2024 20:02:50 +0000 (20:02 +0000)
server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectVisibility.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx
server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx
server/sonar-web/src/main/js/queries/devops-integration.ts
server/sonar-web/src/main/js/queries/identity-provider/github.ts
server/sonar-web/src/main/js/queries/identity-provider/gitlab.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index d86d5c8fed52f7fd7b2b695a229d51e1d05b7624..a60cd6f400aa6f78cda68f0d27f7f58e890a4fca 100644 (file)
@@ -24,28 +24,36 @@ import * as React from 'react';
 import { isPortfolioLike } from '~sonar-aligned/helpers/component';
 import GitHubSynchronisationWarning from '../../../../app/components/GitHubSynchronisationWarning';
 import { Image } from '../../../../components/common/Image';
-import { translate } from '../../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
 import { isDefined } from '../../../../helpers/types';
+import {
+  useIsGitHubProjectQuery,
+  useIsGitLabProjectQuery,
+} from '../../../../queries/devops-integration';
 import { useGithubProvisioningEnabledQuery } from '../../../../queries/identity-provider/github';
+import { useGilabProvisioningEnabledQuery } from '../../../../queries/identity-provider/gitlab';
 import { isApplication, isProject } from '../../../../types/component';
 import { Component } from '../../../../types/types';
 import ApplyTemplate from './ApplyTemplate';
 
 interface Props {
   component: Component;
-  isGitHubProject?: boolean;
   loadHolders: () => void;
 }
 
-export default function PageHeader(props: Props) {
+export default function PageHeader(props: Readonly<Props>) {
+  const { component, loadHolders } = props;
+  const { configuration, key, qualifier, visibility } = component;
   const [applyTemplateModal, setApplyTemplateModal] = React.useState(false);
+  const { data: isGitHubProject } = useIsGitHubProjectQuery(key);
+  const { data: isGitLabProject } = useIsGitLabProjectQuery(key);
   const { data: githubProvisioningStatus } = useGithubProvisioningEnabledQuery();
+  const { data: gitlabProvisioningStatus } = useGilabProvisioningEnabledQuery();
 
-  const { component, isGitHubProject } = props;
-  const { configuration } = component;
   const provisionedByGitHub = isGitHubProject && !!githubProvisioningStatus;
-  const canApplyPermissionTemplate =
-    configuration?.canApplyPermissionTemplate && !provisionedByGitHub;
+  const provisionedByGitLab = isGitLabProject && !!gitlabProvisioningStatus;
+  const provisioned = provisionedByGitHub || provisionedByGitLab;
+  const canApplyPermissionTemplate = configuration?.canApplyPermissionTemplate && !provisioned;
 
   const handleApplyTemplate = () => {
     setApplyTemplateModal(true);
@@ -56,15 +64,15 @@ export default function PageHeader(props: Props) {
   };
 
   let description = translate('roles.page.description2');
-  if (isPortfolioLike(component.qualifier)) {
+  if (isPortfolioLike(qualifier)) {
     description = translate('roles.page.description_portfolio');
-  } else if (isApplication(component.qualifier)) {
+  } else if (isApplication(qualifier)) {
     description = translate('roles.page.description_application');
   }
 
   const visibilityDescription =
-    isProject(component.qualifier) && component.visibility
-      ? translate('visibility', component.visibility, 'description', component.qualifier)
+    isProject(qualifier) && visibility
+      ? translate('visibility', visibility, 'description', qualifier)
       : undefined;
 
   return (
@@ -72,13 +80,16 @@ export default function PageHeader(props: Props) {
       <div>
         <Title>
           {translate('permissions.page')}
-          {provisionedByGitHub && (
+          {provisioned && (
             <Image
-              alt="github"
+              alt={provisionedByGitHub ? 'github' : 'gitlab'}
               className="sw-mx-2 sw-align-baseline"
-              aria-label={translate('project_permission.github_managed')}
+              aria-label={translateWithParameters(
+                'project_permission.managed',
+                provisionedByGitHub ? translate('alm.github') : translate('alm.gitlab'),
+              )}
               height={16}
-              src="/images/alm/github.svg"
+              src={`/images/alm/${provisionedByGitHub ? 'github' : 'gitlab'}.svg`}
             />
           )}
         </Title>
@@ -86,11 +97,15 @@ export default function PageHeader(props: Props) {
         <div>
           <p>{description}</p>
           {isDefined(visibilityDescription) && <p>{visibilityDescription}</p>}
-          {provisionedByGitHub && (
+          {provisioned && (
             <>
-              <p>{translate('roles.page.description.github')}</p>
+              <p>
+                {provisionedByGitHub
+                  ? translate('roles.page.description.github')
+                  : translate('roles.page.description.gitlab')}
+              </p>
               <div className="sw-mt-2">
-                <GitHubSynchronisationWarning short />
+                {provisionedByGitHub && <GitHubSynchronisationWarning short />}
               </div>
             </>
           )}
@@ -99,6 +114,11 @@ export default function PageHeader(props: Props) {
               {translate('project_permission.local_project_with_github_provisioning')}
             </FlagMessage>
           )}
+          {gitlabProvisioningStatus && !isGitLabProject && (
+            <FlagMessage variant="warning" className="sw-mt-2">
+              {translate('project_permission.local_project_with_gitlab_provisioning')}
+            </FlagMessage>
+          )}
         </div>
       </div>
       {canApplyPermissionTemplate && (
@@ -113,7 +133,7 @@ export default function PageHeader(props: Props) {
 
           {applyTemplateModal && (
             <ApplyTemplate
-              onApply={props.loadHolders}
+              onApply={loadHolders}
               onClose={handleApplyTemplateClose}
               project={component}
             />
index dcfc3dbece6a98a279692d9dcce95ba265285adc..c1e5708966aca829a86821a66c67478454873899 100644 (file)
@@ -24,22 +24,19 @@ import { Helmet } from 'react-helmet-async';
 import { Visibility } from '~sonar-aligned/types/component';
 import * as api from '../../../../api/permissions';
 import withComponentContext from '../../../../app/components/componentContext/withComponentContext';
-import VisibilitySelector from '../../../../components/common/VisibilitySelector';
 import AllHoldersList from '../../../../components/permissions/AllHoldersList';
 import { FilterOption } from '../../../../components/permissions/SearchForm';
-import UseQuery from '../../../../helpers/UseQuery';
 import { translate } from '../../../../helpers/l10n';
 import {
   PERMISSIONS_ORDER_BY_QUALIFIER,
   convertToPermissionDefinitions,
 } from '../../../../helpers/permissions';
-import { useIsGitHubProjectQuery } from '../../../../queries/devops-integration';
-import { useGithubProvisioningEnabledQuery } from '../../../../queries/identity-provider/github';
 import { ComponentContextShape } from '../../../../types/component';
 import { Permissions } from '../../../../types/permissions';
 import { Component, Paging, PermissionGroup, PermissionUser } from '../../../../types/types';
 import '../../styles.css';
 import PageHeader from './PageHeader';
+import PermissionsProjectVisibility from './PermissionsProjectVisibility';
 import PublicProjectDisclaimer from './PublicProjectDisclaimer';
 
 interface Props extends ComponentContextShape {
@@ -336,8 +333,6 @@ class PermissionsProjectApp extends React.PureComponent<Props, State> {
       usersPaging,
       groupsPaging,
     } = this.state;
-    const canTurnToPrivate =
-      component.configuration && component.configuration.canUpdateProjectVisibilityToPrivate;
 
     let order = PERMISSIONS_ORDER_BY_QUALIFIER[component.qualifier];
     if (component.visibility === Visibility.Public) {
@@ -350,39 +345,22 @@ class PermissionsProjectApp extends React.PureComponent<Props, State> {
         <PageContentFontWrapper className="sw-my-8 sw-body-sm">
           <Helmet defer={false} title={translate('permissions.page')} />
 
-          <UseQuery query={useIsGitHubProjectQuery} args={[component.key]}>
-            {({ data: isGitHubProject }) => (
-              <>
-                <PageHeader
-                  component={component}
-                  isGitHubProject={isGitHubProject}
-                  loadHolders={this.loadHolders}
-                />
-                <div>
-                  <UseQuery query={useGithubProvisioningEnabledQuery}>
-                    {({ data: githubProvisioningStatus, isFetching }) => (
-                      <VisibilitySelector
-                        canTurnToPrivate={canTurnToPrivate}
-                        className="sw-flex sw-my-4"
-                        onChange={this.handleVisibilityChange}
-                        loading={loading || isFetching}
-                        disabled={isGitHubProject && !!githubProvisioningStatus}
-                        visibility={component.visibility}
-                      />
-                    )}
-                  </UseQuery>
-
-                  {disclaimer && (
-                    <PublicProjectDisclaimer
-                      component={component}
-                      onClose={this.handleCloseDisclaimer}
-                      onConfirm={this.handleTurnProjectToPublic}
-                    />
-                  )}
-                </div>
-              </>
+          <PageHeader component={component} loadHolders={this.loadHolders} />
+          <div>
+            <PermissionsProjectVisibility
+              component={component}
+              handleVisibilityChange={this.handleVisibilityChange}
+              isLoading={loading}
+            />
+
+            {disclaimer && (
+              <PublicProjectDisclaimer
+                component={component}
+                onClose={this.handleCloseDisclaimer}
+                onConfirm={this.handleTurnProjectToPublic}
+              />
             )}
-          </UseQuery>
+          </div>
 
           <AllHoldersList
             loading={loading}
diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectVisibility.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectVisibility.tsx
new file mode 100644 (file)
index 0000000..8cde3d1
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import * as React from 'react';
+import VisibilitySelector from '../../../../components/common/VisibilitySelector';
+import {
+  useIsGitHubProjectQuery,
+  useIsGitLabProjectQuery,
+} from '../../../../queries/devops-integration';
+import { useGithubProvisioningEnabledQuery } from '../../../../queries/identity-provider/github';
+import { useGilabProvisioningEnabledQuery } from '../../../../queries/identity-provider/gitlab';
+import { Component } from '../../../../types/types';
+
+interface Props {
+  component: Component;
+  handleVisibilityChange: (visibility: string) => void;
+  isLoading: boolean;
+}
+
+export default function PermissionsProjectVisibility(props: Readonly<Props>) {
+  const { component, handleVisibilityChange, isLoading } = props;
+  const canTurnToPrivate = component.configuration?.canUpdateProjectVisibilityToPrivate;
+
+  const { data: isGitHubProject } = useIsGitHubProjectQuery(component.key);
+  const { data: isGitLabProject } = useIsGitLabProjectQuery(component.key);
+  const { data: gitHubProvisioningStatus, isFetching: isFetchingGitHubProvisioningStatus } =
+    useGithubProvisioningEnabledQuery();
+  const { data: gitLabProvisioningStatus, isFetching: isFetchingGitLabProvisioningStatus } =
+    useGilabProvisioningEnabledQuery();
+  const isFetching = isFetchingGitHubProvisioningStatus || isFetchingGitLabProvisioningStatus;
+  const isDisabled =
+    (isGitHubProject && !!gitHubProvisioningStatus) ||
+    (isGitLabProject && !!gitLabProvisioningStatus);
+
+  return (
+    <VisibilitySelector
+      canTurnToPrivate={canTurnToPrivate}
+      className="sw-flex sw-my-4"
+      onChange={handleVisibilityChange}
+      loading={isLoading || isFetching}
+      disabled={isDisabled}
+      visibility={component.visibility}
+    />
+  );
+}
index 24bad3963fbf5b283fa0fa9578d99af42a6d614a..8a190f4aa906f9667b4bd93146fdf02b2d88528c 100644 (file)
@@ -23,8 +23,10 @@ import { ComponentQualifier, Visibility } from '~sonar-aligned/types/component';
 import AlmSettingsServiceMock from '../../../../../api/mocks/AlmSettingsServiceMock';
 import DopTranslationServiceMock from '../../../../../api/mocks/DopTranslationServiceMock';
 import GithubProvisioningServiceMock from '../../../../../api/mocks/GithubProvisioningServiceMock';
+import GitlabProvisioningServiceMock from '../../../../../api/mocks/GitlabProvisioningServiceMock';
 import PermissionsServiceMock from '../../../../../api/mocks/PermissionsServiceMock';
 import SystemServiceMock from '../../../../../api/mocks/SystemServiceMock';
+import { mockGitlabConfiguration } from '../../../../../helpers/mocks/alm-integrations';
 import { mockComponent } from '../../../../../helpers/mocks/component';
 import { mockGitHubConfiguration } from '../../../../../helpers/mocks/dop-translation';
 import { mockPermissionGroup, mockPermissionUser } from '../../../../../helpers/mocks/permissions';
@@ -48,12 +50,14 @@ import { getPageObject } from '../../../test-utils';
 let serviceMock: PermissionsServiceMock;
 let dopTranslationHandler: DopTranslationServiceMock;
 let githubHandler: GithubProvisioningServiceMock;
+let gitlabHandler: GitlabProvisioningServiceMock;
 let almHandler: AlmSettingsServiceMock;
 let systemHandler: SystemServiceMock;
 beforeAll(() => {
   serviceMock = new PermissionsServiceMock();
   dopTranslationHandler = new DopTranslationServiceMock();
   githubHandler = new GithubProvisioningServiceMock(dopTranslationHandler);
+  gitlabHandler = new GitlabProvisioningServiceMock();
   almHandler = new AlmSettingsServiceMock();
   systemHandler = new SystemServiceMock();
 });
@@ -62,6 +66,7 @@ afterEach(() => {
   serviceMock.reset();
   dopTranslationHandler.reset();
   githubHandler.reset();
+  gitlabHandler.reset();
   almHandler.reset();
 });
 
@@ -330,7 +335,7 @@ describe('GH provisioning', () => {
 
     expect(ui.pageTitle.get()).toBeInTheDocument();
     await waitFor(() =>
-      expect(ui.pageTitle.get()).toHaveAccessibleName(/project_permission.github_managed/),
+      expect(ui.pageTitle.get()).toHaveAccessibleName(/project_permission.managed/),
     );
     expect(ui.pageTitle.byRole('img').get()).toBeInTheDocument();
     expect(ui.githubExplanations.get()).toBeInTheDocument();
@@ -437,6 +442,82 @@ describe('GH provisioning', () => {
   });
 });
 
+describe('GL provisioning', () => {
+  beforeEach(() => {
+    systemHandler.setProvider(Provider.Gitlab);
+  });
+
+  it('should not allow to change visibility for GitLab Project with auto-provisioning', async () => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    dopTranslationHandler.gitHubConfigurations.push(
+      mockGitHubConfiguration({ provisioningType: ProvisioningType.jit }),
+    );
+    gitlabHandler.setGitlabConfigurations([
+      mockGitlabConfiguration({ id: '1', enabled: true, provisioningType: ProvisioningType.auto }),
+    ]);
+    almHandler.handleSetProjectBinding(AlmKeys.GitLab, {
+      almSetting: 'test',
+      repository: 'test',
+      monorepo: false,
+      project: 'my-project',
+    });
+
+    renderPermissionsProjectApp({}, { featureList: [Feature.GitlabProvisioning] });
+    await ui.appLoaded();
+
+    expect(ui.visibilityRadio(Visibility.Public).get()).toBeDisabled();
+    expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked();
+    expect(ui.visibilityRadio(Visibility.Private).get()).toBeDisabled();
+    await ui.turnProjectPrivate();
+    expect(ui.visibilityRadio(Visibility.Private).get()).not.toBeChecked();
+  });
+
+  it('should allow to change visibility for non-GitLab Project', async () => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    gitlabHandler.setGitlabConfigurations([
+      mockGitlabConfiguration({ id: '1', enabled: true, provisioningType: ProvisioningType.auto }),
+    ]);
+    almHandler.handleSetProjectBinding(AlmKeys.GitHub, {
+      almSetting: 'test',
+      repository: 'test',
+      monorepo: false,
+      project: 'my-project',
+    });
+    renderPermissionsProjectApp({}, { featureList: [Feature.GitlabProvisioning] });
+    await ui.appLoaded();
+
+    expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled');
+    expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked();
+    expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled');
+    await ui.turnProjectPrivate();
+    expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked();
+  });
+
+  it('should allow to change visibility for GitLab Project with disabled auto-provisioning', async () => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    gitlabHandler.setGitlabConfigurations([
+      mockGitlabConfiguration({ id: '1', enabled: true, provisioningType: ProvisioningType.jit }),
+    ]);
+    almHandler.handleSetProjectBinding(AlmKeys.GitLab, {
+      almSetting: 'test',
+      repository: 'test',
+      monorepo: false,
+      project: 'my-project',
+    });
+    renderPermissionsProjectApp({}, { featureList: [Feature.GitlabProvisioning] });
+    await ui.appLoaded();
+
+    expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled');
+    expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked();
+    expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled');
+    await ui.turnProjectPrivate();
+    expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked();
+  });
+});
+
 function renderPermissionsProjectApp(
   override: Partial<Component> = {},
   contextOverride: Partial<RenderContext> = {},
index 9af36c10bb943aff14e8d0fdf6d1e8c7c68353c9..4aa0bb6a64f1a2c8388c913ecdbc117d7673db48 100644 (file)
@@ -33,7 +33,7 @@ export interface VisibilitySelectorProps {
 }
 
 export default function VisibilitySelector(props: Readonly<VisibilitySelectorProps>) {
-  const { className, canTurnToPrivate, visibility, disabled, loading = false } = props;
+  const { className, canTurnToPrivate, visibility, disabled, loading = false, onChange } = props;
   return (
     <div className={classNames(className)}>
       <RadioButtonGroup
@@ -46,7 +46,7 @@ export default function VisibilitySelector(props: Readonly<VisibilitySelectorPro
           isDisabled: (v === Visibility.Private && !canTurnToPrivate) || loading,
         }))}
         value={visibility}
-        onChange={props.onChange}
+        onChange={onChange}
       />
     </div>
   );
index 344ba6c029ad62e339175b9f99e03ed9f1a8dc9e..ff7a6478ea2654751146f58eb4559f7980c29441 100644 (file)
@@ -79,6 +79,12 @@ export function useIsGitHubProjectQuery(project?: string) {
   });
 }
 
+export function useIsGitLabProjectQuery(project?: string) {
+  return useProjectBindingQuery<boolean>(project, {
+    select: (data) => data?.alm === AlmKeys.GitLab,
+  });
+}
+
 export function useDeleteProjectAlmBindingMutation(project?: string) {
   const keyFromUrl = useProjectKeyFromLocation();
   const client = useQueryClient();
index 556ad798e3e9a2eb4207722ff5e38a256d49a435..dc09ffb9ea894bdc2c3c3d87c6e2b1ca57d2a9db 100644 (file)
@@ -65,7 +65,9 @@ export function useGitHubSyncStatusQuery(options: GithubSyncStatusOptions = {})
 export function useGithubProvisioningEnabledQuery() {
   const res = useGitHubSyncStatusQuery({ noRefetch: true });
 
-  return mapReactQueryResult(res, (data) => data.enabled);
+  return mapReactQueryResult(res, (data) => {
+    return data.enabled;
+  });
 }
 
 export function useSyncWithGitHubNow() {
index 02ed747445c749182145e5789308f5f7f1db7daf..eec78f438f42f8c43687e293b3b7f8056c691bc5 100644 (file)
@@ -28,6 +28,7 @@ import {
   updateGitLabConfiguration,
 } from '../../api/gitlab-provisioning';
 import { translate } from '../../helpers/l10n';
+import { mapReactQueryResult } from '../../helpers/react-query';
 import { AlmSyncStatus, ProvisioningType } from '../../types/provisioning';
 import { TaskStatuses, TaskTypes } from '../../types/tasks';
 
@@ -99,6 +100,17 @@ export function useDeleteGitLabConfigurationMutation() {
   });
 }
 
+export function useGilabProvisioningEnabledQuery() {
+  const res = useGitLabConfigurationsQuery();
+
+  return mapReactQueryResult(res, (data) =>
+    data.gitlabConfigurations?.some(
+      (configuration) =>
+        configuration.enabled && configuration.provisioningType === ProvisioningType.auto,
+    ),
+  );
+}
+
 export function useGitLabSyncStatusQuery() {
   const getLastSync = async () => {
     const lastSyncTasks = await getActivity({
index 75e52e5661838165649a2abd20c32acdcfb2888e..f0b6c46f7cb3cc43259b8cb535ebec27dd4a82b1 100644 (file)
@@ -628,9 +628,11 @@ roles.page.description2=Grant and revoke project-level permissions. Permissions
 roles.page.description_portfolio=Grant and revoke portfolio-level permissions. Permissions can be granted to groups or individual users.
 roles.page.description_application=Grant and revoke application-level permissions. Permissions can be granted to groups or individual users.
 roles.page.description.github=Project permissions are read-only for users provisioned from GitHub. For non-GitHub users, permissions can only be removed.
+roles.page.description.gitlab=Project permissions are read-only for users provisioned from GitLab. For non-GitLab users, permissions can only be removed.
 roles.page.change_visibility=Change project visibility
-project_permission.github_managed=Provisioned from GitHub
+project_permission.managed=Provisioned from {0}
 project_permission.local_project_with_github_provisioning=Please note that this project is not linked to GitHub. Bind it to GitHub to benefit from permission provisioning.
+project_permission.local_project_with_gitlab_provisioning=Please note that this project is not linked to GitLab. Bind it to GitLab to benefit from permission provisioning.
 project_permission.remove_only_confirmation=Are you sure you want to remove the permission {permission} from {holder}? The permission can not be added back.
 project_permission.remove_only_confirmation_title=Remove permission
 project_settings.page=General Settings