]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19790 Disallow applying permission template on managed project
authorViktor Vorona <viktor.vorona@sonarsource.com>
Fri, 14 Jul 2023 11:53:58 +0000 (13:53 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 18 Jul 2023 20:03:22 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts
server/sonar-web/src/main/js/api/project-management.ts
server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx
server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx
server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx
server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/AddUserActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/BasePermissionWsIT.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/AddUserAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/RemoveUserAction.java
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 29e2e0e515f20f817ba737bae8774f647c9bd89a..b1e89751259939e4e1c32d3870ad6c012037888c 100644 (file)
@@ -38,7 +38,7 @@ const defaultProject = [
   mockProject({ key: 'project1', name: 'Project 1' }),
   mockProject({ key: 'project2', name: 'Project 2', visibility: Visibility.Private }),
   mockProject({ key: 'project3', name: 'Project 3', lastAnalysisDate: undefined }),
-  mockProject({ key: 'projectProvisioned', name: 'Project 4' }),
+  mockProject({ key: 'projectProvisioned', name: 'Project 4', managed: true }),
   mockProject({ key: 'portfolio1', name: 'Portfolio 1', qualifier: ComponentQualifier.Portfolio }),
   mockProject({
     key: 'portfolio2',
index c560ff1e8c3d54f1868d5363f6a7c90b602c4f39..c12831d1ace20dfb0a28450d2d27d3f566c49f28 100644 (file)
@@ -43,6 +43,7 @@ export interface ProjectBase {
 
 export interface Project extends ProjectBase {
   lastAnalysisDate?: string;
+  managed?: boolean;
 }
 
 export interface SearchProjectsParameters extends BaseSearchProjectsParameters {
index 780a6a167750d29572945204cee3c5958f2a0499..73b876c80d708ff008a82b235db3fe0c3bd3fe56 100644 (file)
@@ -188,7 +188,9 @@ class ProjectManagementApp extends React.PureComponent<Props, State> {
   };
 
   onAllSelected = () => {
-    this.setState(({ projects }) => ({ selection: projects.map((project) => project.key) }));
+    this.setState(({ projects }) => ({
+      selection: projects.filter((p) => !p.managed).map((project) => project.key),
+    }));
   };
 
   onAllDeselected = () => {
index 026f6cac3cbfdf6cd056a183f900e5b17fc2697b..9a4c207e4656523644394a16483dca77fba5c4fb 100644 (file)
@@ -25,8 +25,9 @@ import Checkbox from '../../components/controls/Checkbox';
 import Tooltip from '../../components/controls/Tooltip';
 import QualifierIcon from '../../components/icons/QualifierIcon';
 import DateFormatter from '../../components/intl/DateFormatter';
-import { translateWithParameters } from '../../helpers/l10n';
+import { translate, translateWithParameters } from '../../helpers/l10n';
 import { getComponentOverviewUrl } from '../../helpers/urls';
+import { ComponentQualifier } from '../../types/component';
 import { LoggedInUser } from '../../types/users';
 import './ProjectRow.css';
 import ProjectRowActions from './ProjectRowActions';
@@ -38,59 +39,61 @@ interface Props {
   selected: boolean;
 }
 
-export default class ProjectRow extends React.PureComponent<Props> {
-  handleProjectCheck = (checked: boolean) => {
-    this.props.onProjectCheck(this.props.project, checked);
-  };
+export default function ProjectRow(props: Props) {
+  const { currentUser, project, selected } = props;
 
-  render() {
-    const { project, selected } = this.props;
+  const handleProjectCheck = (checked: boolean) => {
+    props.onProjectCheck(project, checked);
+  };
 
-    return (
-      <tr data-project-key={project.key}>
-        <td className="thin">
-          <Checkbox
-            label={translateWithParameters('projects_management.select_project', project.name)}
-            checked={selected}
-            onCheck={this.handleProjectCheck}
-          />
-        </td>
+  return (
+    <tr data-project-key={project.key}>
+      <td className="thin">
+        <Checkbox
+          label={translateWithParameters('projects_management.select_project', project.name)}
+          checked={selected}
+          disabled={project.managed}
+          onCheck={handleProjectCheck}
+        />
+      </td>
 
-        <td className="nowrap hide-overflow project-row-text-cell">
-          <Link
-            className="link-no-underline"
-            to={getComponentOverviewUrl(project.key, project.qualifier)}
-          >
-            <QualifierIcon className="little-spacer-right" qualifier={project.qualifier} />
+      <td className="nowrap hide-overflow project-row-text-cell">
+        <Link
+          className="link-no-underline"
+          to={getComponentOverviewUrl(project.key, project.qualifier)}
+        >
+          <QualifierIcon className="little-spacer-right" qualifier={project.qualifier} />
 
-            <Tooltip overlay={project.name} placement="left">
-              <span>{project.name}</span>
-            </Tooltip>
-          </Link>
-        </td>
+          <Tooltip overlay={project.name} placement="left">
+            <span>{project.name}</span>
+          </Tooltip>
+        </Link>
+        {project.qualifier === ComponentQualifier.Project && !project.managed && (
+          <span className="badge sw-ml-1">{translate('local')}</span>
+        )}
+      </td>
 
-        <td className="thin nowrap">
-          <PrivacyBadgeContainer qualifier={project.qualifier} visibility={project.visibility} />
-        </td>
+      <td className="thin nowrap">
+        <PrivacyBadgeContainer qualifier={project.qualifier} visibility={project.visibility} />
+      </td>
 
-        <td className="nowrap hide-overflow project-row-text-cell">
-          <Tooltip overlay={project.key} placement="left">
-            <span className="note">{project.key}</span>
-          </Tooltip>
-        </td>
+      <td className="nowrap hide-overflow project-row-text-cell">
+        <Tooltip overlay={project.key} placement="left">
+          <span className="note">{project.key}</span>
+        </Tooltip>
+      </td>
 
-        <td className="thin nowrap text-right">
-          {project.lastAnalysisDate ? (
-            <DateFormatter date={project.lastAnalysisDate} />
-          ) : (
-            <span className="note">—</span>
-          )}
-        </td>
+      <td className="thin nowrap text-right">
+        {project.lastAnalysisDate ? (
+          <DateFormatter date={project.lastAnalysisDate} />
+        ) : (
+          <span className="note">—</span>
+        )}
+      </td>
 
-        <td className="thin nowrap">
-          <ProjectRowActions currentUser={this.props.currentUser} project={project} />
-        </td>
-      </tr>
-    );
-  }
+      <td className="thin nowrap">
+        <ProjectRowActions currentUser={currentUser} project={project} />
+      </td>
+    </tr>
+  );
 }
index b41ac29b0b369aafb455a09e71fa02ce8fa8c6e3..fa9064adf5497988f82a710fa7a7431119603209 100644 (file)
@@ -17,7 +17,7 @@
  * 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 React, { useState } from 'react';
 import { getComponentNavigation } from '../../api/navigation';
 import { Project } from '../../api/project-management';
 import ActionsDropdown, { ActionsDropdownItem } from '../../components/controls/ActionsDropdown';
@@ -33,129 +33,93 @@ export interface Props {
   project: Project;
 }
 
-interface State {
-  applyTemplateModal: boolean;
-  hasAccess?: boolean;
-  loading: boolean;
-  restoreAccessModal: boolean;
-}
-
-export default class ProjectRowActions extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { applyTemplateModal: false, loading: false, restoreAccessModal: false };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
+export default function ProjectRowActions({ currentUser, project }: Props) {
+  const [applyTemplateModal, setApplyTemplateModal] = useState(false);
+  const [hasAccess, setHasAccess] = useState<boolean | undefined>(undefined);
+  const [loading, setLoading] = useState(false);
+  const [restoreAccessModal, setRestoreAccessModal] = useState(false);
 
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  fetchPermissions = () => {
-    this.setState({ loading: true });
-    getComponentNavigation({ component: this.props.project.key }).then(
+  const fetchPermissions = () => {
+    setLoading(true);
+    getComponentNavigation({ component: project.key }).then(
       ({ configuration }) => {
-        if (this.mounted) {
-          const hasAccess = Boolean(
-            configuration && configuration.showPermissions && configuration.canBrowseProject
-          );
-          this.setState({ hasAccess, loading: false });
-        }
+        const hasAccess = Boolean(
+          configuration && configuration.showPermissions && configuration.canBrowseProject
+        );
+        setHasAccess(hasAccess);
+        setLoading(false);
       },
       () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
+        setLoading(false);
       }
     );
   };
 
-  handleDropdownOpen = () => {
-    if (this.state.hasAccess === undefined && !this.state.loading) {
-      this.fetchPermissions();
-    }
-  };
-
-  handleApplyTemplateClick = () => {
-    this.setState({ applyTemplateModal: true });
-  };
-
-  handleApplyTemplateClose = () => {
-    if (this.mounted) {
-      this.setState({ applyTemplateModal: false });
+  const handleDropdownOpen = () => {
+    if (hasAccess === undefined && !loading) {
+      fetchPermissions();
     }
   };
 
-  handleRestoreAccessClick = () => {
-    this.setState({ restoreAccessModal: true });
-  };
-
-  handleRestoreAccessClose = () => this.setState({ restoreAccessModal: false });
-
-  handleRestoreAccessDone = () => {
-    this.setState({ hasAccess: true, restoreAccessModal: false });
+  const handleRestoreAccessDone = () => {
+    setRestoreAccessModal(false);
+    setHasAccess(true);
   };
 
-  render() {
-    const { hasAccess, loading } = this.state;
-
-    return (
-      <>
-        <ActionsDropdown
-          label={translateWithParameters(
-            'projects_management.show_actions_for_x',
-            this.props.project.name
-          )}
-          onOpen={this.handleDropdownOpen}
-        >
-          {loading ? (
-            <ActionsDropdownItem>
-              <DeferredSpinner />
-            </ActionsDropdownItem>
-          ) : (
-            <>
-              {hasAccess === true && (
-                <ActionsDropdownItem
-                  className="js-edit-permissions"
-                  to={getComponentPermissionsUrl(this.props.project.key)}
-                >
-                  {translate('edit_permissions')}
-                </ActionsDropdownItem>
-              )}
-
-              {hasAccess === false && (
-                <ActionsDropdownItem
-                  className="js-restore-access"
-                  onClick={this.handleRestoreAccessClick}
-                >
-                  {translate('global_permissions.restore_access')}
-                </ActionsDropdownItem>
-              )}
-            </>
-          )}
+  return (
+    <>
+      <ActionsDropdown
+        label={translateWithParameters('projects_management.show_actions_for_x', project.name)}
+        onOpen={handleDropdownOpen}
+      >
+        {loading ? (
+          <ActionsDropdownItem>
+            <DeferredSpinner />
+          </ActionsDropdownItem>
+        ) : (
+          <>
+            {hasAccess === true && (
+              <ActionsDropdownItem
+                className="js-edit-permissions"
+                to={getComponentPermissionsUrl(project.key)}
+              >
+                {translate(project.managed ? 'show_permissions' : 'edit_permissions')}
+              </ActionsDropdownItem>
+            )}
+
+            {hasAccess === false && !project.managed && (
+              <ActionsDropdownItem
+                className="js-restore-access"
+                onClick={() => setRestoreAccessModal(true)}
+              >
+                {translate('global_permissions.restore_access')}
+              </ActionsDropdownItem>
+            )}
+          </>
+        )}
 
+        {!project.managed && (
           <ActionsDropdownItem
             className="js-apply-template"
-            onClick={this.handleApplyTemplateClick}
+            onClick={() => setApplyTemplateModal(true)}
           >
             {translate('projects_role.apply_template')}
           </ActionsDropdownItem>
-        </ActionsDropdown>
-
-        {this.state.restoreAccessModal && (
-          <RestoreAccessModal
-            currentUser={this.props.currentUser}
-            onClose={this.handleRestoreAccessClose}
-            onRestoreAccess={this.handleRestoreAccessDone}
-            project={this.props.project}
-          />
         )}
-
-        {this.state.applyTemplateModal && (
-          <ApplyTemplate onClose={this.handleApplyTemplateClose} project={this.props.project} />
-        )}
-      </>
-    );
-  }
+      </ActionsDropdown>
+
+      {restoreAccessModal && (
+        <RestoreAccessModal
+          currentUser={currentUser}
+          onClose={() => setRestoreAccessModal(false)}
+          onRestoreAccess={handleRestoreAccessDone}
+          project={project}
+        />
+      )}
+
+      {applyTemplateModal && (
+        <ApplyTemplate onClose={() => setApplyTemplateModal(false)} project={project} />
+      )}
+    </>
+  );
 }
index 195c37e350dbd7685d004c5a352ea0578024f608..f4631ddc7db3028802433c06d64d713e2a3e7395 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { screen, waitFor, within } from '@testing-library/react';
+import { act, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import selectEvent from 'react-select-event';
 import AuthenticationServiceMock from '../../../api/mocks/AuthenticationServiceMock';
@@ -73,7 +73,9 @@ const ui = {
   firstProjectActions: byRole('button', {
     name: 'projects_management.show_actions_for_x.Project 1',
   }),
+  projectActions: byRole('button', { name: /projects_management.show_actions_for_x/ }),
   editPermissions: byRole('link', { name: 'edit_permissions' }),
+  showPermissions: byRole('link', { name: 'show_permissions' }),
   applyPermissionTemplate: byRole('button', { name: 'projects_role.apply_template' }),
   restoreAccess: byRole('button', { name: 'global_permissions.restore_access' }),
   editPermissionsPage: byText('/project_roles?id=project1'),
@@ -365,13 +367,13 @@ it('should create project', async () => {
   expect(ui.successMsg.get(dialog)).toBeInTheDocument();
   await user.click(ui.close.get(dialog));
   expect(ui.row.getAll()).toHaveLength(6);
-  expect(ui.row.getAll()[1]).toHaveTextContent('qualifier.TRKa Testvisibility.privatetest—');
+  expect(ui.row.getAll()[1]).toHaveTextContent('qualifier.TRKa Testlocalvisibility.privatetest—');
 });
 
 it('should edit permissions of single project', async () => {
   const user = userEvent.setup();
   renderProjectManagementApp();
-  await user.click(await ui.firstProjectActions.find());
+  await act(async () => user.click(await ui.firstProjectActions.find()));
   expect(ui.restoreAccess.query()).not.toBeInTheDocument();
   expect(ui.editPermissions.get()).toBeInTheDocument();
   await user.click(ui.editPermissions.get());
@@ -382,7 +384,7 @@ it('should edit permissions of single project', async () => {
 it('should apply template for single object', async () => {
   const user = userEvent.setup();
   renderProjectManagementApp();
-  await user.click(await ui.firstProjectActions.find());
+  await act(async () => user.click(await ui.firstProjectActions.find()));
   await user.click(ui.applyPermissionTemplate.get());
 
   expect(ui.applyTemplateDialog.get()).toBeInTheDocument();
@@ -400,14 +402,14 @@ it('should apply template for single object', async () => {
 it('should restore access to admin', async () => {
   const user = userEvent.setup();
   renderProjectManagementApp({}, { login: 'gooduser2' });
-  await user.click(await ui.firstProjectActions.find());
+  await act(async () => user.click(await ui.firstProjectActions.find()));
   expect(await ui.restoreAccess.find()).toBeInTheDocument();
   expect(ui.editPermissions.query()).not.toBeInTheDocument();
   await user.click(ui.restoreAccess.get());
   expect(ui.restoreAccessDialog.get()).toBeInTheDocument();
-  await user.click(ui.restore.get(ui.restoreAccessDialog.get()));
+  await act(() => user.click(ui.restore.get(ui.restoreAccessDialog.get())));
   expect(ui.restoreAccessDialog.query()).not.toBeInTheDocument();
-  await user.click(await ui.firstProjectActions.find());
+  await act(async () => user.click(await ui.firstProjectActions.find()));
   expect(ui.restoreAccess.query()).not.toBeInTheDocument();
   expect(ui.editPermissions.get()).toBeInTheDocument();
 });
@@ -421,6 +423,42 @@ it('should show github warning on changing default visibility to admin', async (
   expect(ui.defaultVisibilityWarning.get()).toHaveTextContent('.github');
 });
 
+it('should not allow apply permissions for managed projects', async () => {
+  const user = userEvent.setup();
+  renderProjectManagementApp();
+  await waitFor(() => expect(ui.row.getAll()).toHaveLength(5));
+  const rows = ui.row.getAll();
+  expect(rows[1]).toHaveTextContent('local');
+  expect(rows[2]).toHaveTextContent('local');
+  expect(rows[3]).toHaveTextContent('local');
+  expect(rows[4]).not.toHaveTextContent('local');
+  expect(ui.checkbox.get(rows[4])).toHaveAttribute('aria-disabled', 'true');
+  expect(ui.checkbox.get(rows[1])).not.toHaveAttribute('aria-disabled');
+  await user.click(ui.checkAll.get());
+  expect(ui.checkbox.get(rows[4])).not.toBeChecked();
+  expect(ui.checkbox.get(rows[1])).toBeChecked();
+  await act(() => user.click(ui.projectActions.get(rows[4])));
+  expect(ui.applyPermissionTemplate.query()).not.toBeInTheDocument();
+  expect(ui.restoreAccess.query()).not.toBeInTheDocument();
+  expect(ui.editPermissions.query()).not.toBeInTheDocument();
+  expect(ui.showPermissions.get()).toBeInTheDocument();
+  await act(() => user.click(ui.projectActions.get(rows[1])));
+  expect(ui.applyPermissionTemplate.get()).toBeInTheDocument();
+  expect(ui.editPermissions.get()).toBeInTheDocument();
+  expect(ui.showPermissions.query()).not.toBeInTheDocument();
+});
+
+it('should not show local badge for applications and portfolios', async () => {
+  renderProjectManagementApp();
+  await waitFor(() => expect(screen.getAllByText('local')).toHaveLength(3));
+
+  await selectEvent.select(ui.qualifierFilter.get(), 'qualifiers.VW');
+  expect(screen.queryByText('local')).not.toBeInTheDocument();
+
+  await selectEvent.select(ui.qualifierFilter.get(), 'qualifiers.APP');
+  expect(screen.queryByText('local')).not.toBeInTheDocument();
+});
+
 function renderProjectManagementApp(
   overrides: Partial<AppState> = {},
   user: Partial<LoggedInUser> = {},
index e61488348d5c7e4d7440bffaf87dbb807e706cb2..6e8cb3c838f8261cba8d2017f09d9dae80e0116e 100644 (file)
@@ -213,11 +213,29 @@ public class AddUserActionIT extends BasePermissionWsIT<AddUserAction> {
   }
 
   @Test
-  public void fail_when_project_is_managed_and_no_permissions_update() {
+  public void succeed_when_project_is_managed_and_user_is_sysadmin() {
+    loginAsAdmin();
     ProjectDto project = db.components().insertPrivateProject().getProjectDto();
 
     doThrow(new IllegalStateException("Managed project")).when(managedInstanceChecker).throwIfProjectIsManaged(any(), eq(project.getUuid()));
 
+    newRequest()
+      .setParam(PARAM_USER_LOGIN, user.getLogin())
+      .setParam(PARAM_PROJECT_ID, project.getUuid())
+      .setParam(PARAM_PERMISSION, UserRole.SCAN)
+      .execute();
+
+    assertThat(db.users().selectPermissionsOfUser(user)).isEmpty();
+    assertThat(db.users().selectEntityPermissionOfUser(user, project.getUuid())).containsOnly(UserRole.SCAN);
+  }
+
+  @Test
+  public void fail_when_project_is_managed_and_user_not_sysadmin() {
+    ProjectDto project = db.components().insertPrivateProject().getProjectDto();
+    userSession.logIn().addProjectPermission(UserRole.ADMIN, project);
+
+    doThrow(new IllegalStateException("Managed project")).when(managedInstanceChecker).throwIfProjectIsManaged(any(), eq(project.getUuid()));
+
     TestRequest request = newRequest()
       .setParam(PARAM_USER_LOGIN, user.getLogin())
       .setParam(PARAM_PROJECT_KEY, project.getKey())
index 91e1f855f3ddff4449784e9c5d36fdca9e328565..cf2034af5f3118140812c3c7b04354057581e8c3 100644 (file)
@@ -91,7 +91,7 @@ public abstract class BasePermissionWsIT<A extends PermissionsWsAction> {
   }
 
   protected void loginAsAdmin() {
-    userSession.logIn().addPermission(ADMINISTER);
+    userSession.logIn().addPermission(ADMINISTER).setSystemAdministrator();
   }
 
   protected PermissionTemplateDto selectPermissionTemplate(String name) {
index df0a68a3866dec2a16a0a3fab59a981e3a0fe220..a99a554611d935df68e490383a3f11da5dc67c44 100644 (file)
@@ -90,10 +90,10 @@ public class AddUserAction implements PermissionsWsAction {
     try (DbSession dbSession = dbClient.openSession(false)) {
       String userLogin = request.mandatoryParam(PARAM_USER_LOGIN);
       EntityDto entityDto = wsSupport.findEntity(dbSession, request);
-      if (entityDto != null && entityDto.isProject()) {
+      checkProjectAdmin(userSession, configuration, entityDto);
+      if (!userSession.isSystemAdministrator() && entityDto != null && entityDto.isProject()) {
         managedInstanceChecker.throwIfProjectIsManaged(dbSession, entityDto.getUuid());
       }
-      checkProjectAdmin(userSession, configuration, entityDto);
       UserId user = wsSupport.findUser(dbSession, userLogin);
 
       UserPermissionChange change = new UserPermissionChange(
index 76a26d23e4177dd10c2172fa04d9c13f8c9453a1..a2e83772ca95c34164a4d1b739a949cf1f21cfe2 100644 (file)
@@ -89,11 +89,11 @@ public class RemoveUserAction implements PermissionsWsAction {
       wsSupport.checkRemovingOwnAdminRight(userSession, userIdDto, permission);
 
       EntityDto entityDto = wsSupport.findEntity(dbSession, request);
-      if (entityDto != null) {
-        managedInstanceChecker.throwIfUserAndProjectAreManaged(dbSession, userIdDto.getUuid(), entityDto.getUuid());
-      }
       wsSupport.checkRemovingOwnBrowsePermissionOnPrivateProject(userSession, entityDto, permission, userIdDto);
       wsSupport.checkPermissionManagementAccess(userSession, entityDto);
+      if (entityDto != null && entityDto.isProject()) {
+        managedInstanceChecker.throwIfUserAndProjectAreManaged(dbSession, userIdDto.getUuid(), entityDto.getUuid());
+      }
       UserPermissionChange change = new UserPermissionChange(
         PermissionChange.Operation.REMOVE,
         permission,
index 3392c1cb4387e051869462d442045e1f057d4a8c..4801252f1994717713326b4e2c367eb7ffe9ef08 100644 (file)
@@ -272,6 +272,7 @@ default_error_message=The request cannot be processed. Try again later.
 default_save_field_error_message=This field cannot be saved. Try again later.
 default_severity=Default severity
 edit_permissions=Edit Permissions
+show_permissions=Show Permissions
 facet_might_have_more_results=There might be more results, try another set of filters to see them.
 false_positive=False positive
 go_back_to_homepage=Go back to the homepage
@@ -594,7 +595,7 @@ roles.page.description_portfolio=Grant and revoke portfolio-level permissions. P
 roles.page.description_application=Grant and revoke application-level permissions. Permissions can be granted to groups or individual users.
 roles.page.description.github=GitHub-provisioned project permissions are read-only for users provisioned from GitHub. For non-GitHub users, the permissions can only be removed.
 project_permission.github_managed=Provisioned from GitHub
-project_permission.local_project_with_github_provisioning=GitHub-provisioned project permissions are read-only for GitHub users. For local users, the permissions can only be removed.
+project_permission.local_project_with_github_provisioning=Please note that this project is not linked to GitHub. Bind it to GitHub to take advantage of the provisioning of permissions.
 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