Browse Source

feat(scim): idp flag ui permissions changes (#42152)

Cathy Teng 2 years ago
parent
commit
003a76c7f7

+ 2 - 0
fixtures/js-stubs/member.js

@@ -13,6 +13,8 @@ export function Member(params = {}) {
     expired: false,
     flags: {
       'sso:linked': false,
+      'idp:provisioned': false,
+      'idp:role-restricted': false,
     },
     user: User(),
     inviteStatus: 'approved',

+ 6 - 0
fixtures/js-stubs/members.js

@@ -14,6 +14,8 @@ export function Members(params = []) {
       pending: true,
       flags: {
         'sso:linked': false,
+        'idp:provisioned': false,
+        'idp:role-restricted': false,
       },
       user: null,
     },
@@ -28,6 +30,8 @@ export function Members(params = []) {
       pending: false,
       flags: {
         'sso:linked': true,
+        'idp:provisioned': false,
+        'idp:role-restricted': false,
       },
       user: {
         id: '3',
@@ -48,6 +52,8 @@ export function Members(params = []) {
       pending: false,
       flags: {
         'sso:linked': true,
+        'idp:provisioned': false,
+        'idp:role-restricted': false,
       },
       user: {
         id: '4',

+ 3 - 0
fixtures/js-stubs/team.js

@@ -5,6 +5,9 @@ export function Team(params = {}) {
     name: 'Team Name',
     isMember: true,
     memberCount: 0,
+    flags: {
+      'idp:provisioned': false,
+    },
     ...params,
   };
 }

+ 5 - 0
static/app/types/organization.tsx

@@ -75,6 +75,9 @@ export interface Organization extends OrganizationSummary {
 export type Team = {
   avatar: Avatar;
   externalTeams: ExternalTeam[];
+  flags: {
+    'idp:provisioned': boolean;
+  };
   hasAccess: boolean;
   id: string;
   isMember: boolean;
@@ -111,6 +114,8 @@ export interface Member {
   email: string;
   expired: boolean;
   flags: {
+    'idp:provisioned': boolean;
+    'idp:role-restricted': boolean;
     'member-limit:restricted': boolean;
     'sso:invalid': boolean;
     'sso:linked': boolean;

+ 52 - 4
static/app/views/settings/components/teamSelect.tsx

@@ -11,6 +11,7 @@ import {TeamBadge} from 'sentry/components/idBadge/teamBadge';
 import Link from 'sentry/components/links/link';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
+import Tooltip from 'sentry/components/tooltip';
 import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
 import {IconSubtract} from 'sentry/icons';
 import {t} from 'sentry/locale';
@@ -23,6 +24,11 @@ type Props = {
    * Should button be disabled
    */
   disabled: boolean;
+  /**
+   * Should adding a team be disabled based
+   * on whether the team is idpProvisioned
+   */
+  enforceIdpProvisioned: boolean;
   /**
    * callback when teams are added
    */
@@ -53,6 +59,7 @@ type Props = {
 
 function TeamSelect({
   disabled,
+  enforceIdpProvisioned,
   selectedTeams,
   menuHeader,
   organization,
@@ -87,6 +94,7 @@ function TeamSelect({
         onRemove={slug => onRemoveTeam(slug)}
         disabled={disabled}
         confirmMessage={confirmMessage}
+        enforceIdpProvisioned={enforceIdpProvisioned}
       />
     ));
   };
@@ -98,7 +106,21 @@ function TeamSelect({
       index,
       value: team.slug,
       searchKey: team.slug,
-      label: <DropdownTeamBadge avatarSize={18} team={team} />,
+      label: () => {
+        if (enforceIdpProvisioned && team.flags['idp:provisioned']) {
+          return (
+            <Tooltip
+              title={t(
+                "Membership to this team is managed through your organization's identity provider."
+              )}
+            >
+              <DropdownTeamBadgeDisabled avatarSize={18} team={team} />
+            </Tooltip>
+          );
+        }
+        return <DropdownTeamBadge avatarSize={18} team={team} />;
+      },
+      disabled: enforceIdpProvisioned && team.flags['idp:provisioned'],
     }));
 
   return (
@@ -139,12 +161,20 @@ function TeamSelect({
 type TeamRowProps = {
   confirmMessage: string | null;
   disabled: boolean;
+  enforceIdpProvisioned: boolean;
   onRemove: Props['onRemoveTeam'];
   orgId: string;
   team: Team;
 };
 
-const TeamRow = ({orgId, team, onRemove, disabled, confirmMessage}: TeamRowProps) => (
+const TeamRow = ({
+  orgId,
+  team,
+  onRemove,
+  disabled,
+  confirmMessage,
+  enforceIdpProvisioned,
+}: TeamRowProps) => (
   <TeamPanelItem data-test-id="team-row">
     <StyledLink to={`/settings/${orgId}/teams/${team.slug}/`}>
       <TeamBadge team={team} />
@@ -153,9 +183,20 @@ const TeamRow = ({orgId, team, onRemove, disabled, confirmMessage}: TeamRowProps
       message={confirmMessage}
       bypass={!confirmMessage}
       onConfirm={() => onRemove(team.slug)}
-      disabled={disabled}
+      disabled={disabled || (enforceIdpProvisioned && team.flags['idp:provisioned'])}
     >
-      <Button size="xs" icon={<IconSubtract isCircled size="xs" />} disabled={disabled}>
+      <Button
+        size="xs"
+        icon={<IconSubtract isCircled size="xs" />}
+        disabled={disabled || (enforceIdpProvisioned && team.flags['idp:provisioned'])}
+        title={
+          enforceIdpProvisioned && team.flags['idp:provisioned']
+            ? t(
+                "Membership to this team is managed through your organization's identity provider."
+              )
+            : undefined
+        }
+      >
         {t('Remove')}
       </Button>
     </Confirm>
@@ -168,6 +209,13 @@ const DropdownTeamBadge = styled(TeamBadge)`
   text-transform: none;
 `;
 
+const DropdownTeamBadgeDisabled = styled(TeamBadge)`
+  font-weight: normal;
+  font-size: ${p => p.theme.fontSizeMedium};
+  text-transform: none;
+  filter: grayscale(1);
+`;
+
 const TeamPanelItem = styled(PanelItem)`
   padding: ${space(2)};
   align-items: center;

+ 28 - 4
static/app/views/settings/organizationMembers/inviteMember/orgRoleSelect.tsx

@@ -1,9 +1,15 @@
 import {Component} from 'react';
 import styled from '@emotion/styled';
 
-import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
+import {
+  Panel,
+  PanelAlert,
+  PanelBody,
+  PanelHeader,
+  PanelItem,
+} from 'sentry/components/panels';
 import Radio from 'sentry/components/radio';
-import {t} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
 import {OrgRole} from 'sentry/types';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 
@@ -17,7 +23,9 @@ const Label = styled('label')`
 type Props = {
   disabled: boolean;
   enforceAllowed: boolean;
+  enforceIdpRoleRestricted: boolean;
   enforceRetired: boolean;
+  isCurrentUser: boolean;
   roleList: OrgRole[];
   roleSelected: string;
   setSelected: (id: string) => void;
@@ -29,21 +37,37 @@ class OrganizationRoleSelect extends Component<Props> {
       disabled,
       enforceRetired,
       enforceAllowed,
+      isCurrentUser,
       roleList,
+      enforceIdpRoleRestricted,
       roleSelected,
       setSelected,
     } = this.props;
 
     return (
       <Panel>
-        <PanelHeader>{t('Organization Role')}</PanelHeader>
+        <PanelHeader>
+          <div>{t('Organization Role')}</div>
+        </PanelHeader>
+        {enforceIdpRoleRestricted && (
+          <PanelAlert>
+            {tct(
+              "[person] organization-level role is managed through your organization's identity provider.",
+              {person: isCurrentUser ? 'Your' : "This member's"}
+            )}
+          </PanelAlert>
+        )}
 
         <PanelBody>
           {roleList.map(role => {
             const {desc, name, id, allowed, isRetired: roleRetired} = role;
 
             const isRetired = enforceRetired && roleRetired;
-            const isDisabled = disabled || isRetired || (enforceAllowed && !allowed);
+            const isDisabled =
+              disabled ||
+              isRetired ||
+              (enforceAllowed && !allowed) ||
+              enforceIdpRoleRestricted;
 
             return (
               <PanelItem

+ 72 - 0
static/app/views/settings/organizationMembers/organizationMemberDetail.spec.jsx

@@ -17,6 +17,15 @@ describe('OrganizationMemberDetail', function () {
   let organization;
   let routerContext;
   const team = TestStubs.Team();
+  const idpTeam = TestStubs.Team({
+    id: '4',
+    slug: 'idp-member-team',
+    name: 'Idp Member Team',
+    isMember: true,
+    flags: {
+      'idp:provisioned': true,
+    },
+  });
   const teams = [
     team,
     TestStubs.Team({
@@ -25,6 +34,16 @@ describe('OrganizationMemberDetail', function () {
       name: 'New Team',
       isMember: false,
     }),
+    TestStubs.Team({
+      id: '3',
+      slug: 'idp-team',
+      name: 'Idp Team',
+      isMember: false,
+      flags: {
+        'idp:provisioned': true,
+      },
+    }),
+    idpTeam,
   ];
   const member = TestStubs.Member({
     roles: TestStubs.OrgRoleList(),
@@ -48,6 +67,12 @@ describe('OrganizationMemberDetail', function () {
     pending: true,
     expired: true,
   });
+  const idpTeamMember = TestStubs.Member({
+    id: 4,
+    roles: TestStubs.OrgRoleList(),
+    dateCreated: new Date(),
+    teams: [idpTeam.slug],
+  });
 
   describe('Can Edit', function () {
     beforeEach(function () {
@@ -69,6 +94,10 @@ describe('OrganizationMemberDetail', function () {
         url: `/organizations/${organization.slug}/members/${expiredMember.id}/`,
         body: expiredMember,
       });
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/members/${idpTeamMember.id}/`,
+        body: idpTeamMember,
+      });
       MockApiClient.addMockResponse({
         url: `/organizations/${organization.slug}/teams/`,
         body: teams,
@@ -122,6 +151,14 @@ describe('OrganizationMemberDetail', function () {
       );
     });
 
+    it('cannot leave idp-provisioned team', function () {
+      render(<OrganizationMemberDetail params={{memberId: idpTeamMember.id}} />, {
+        context: routerContext,
+      });
+
+      expect(screen.getByRole('button', {name: 'Remove'})).toBeDisabled();
+    });
+
     it('joins a team', function () {
       render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
         context: routerContext,
@@ -148,6 +185,41 @@ describe('OrganizationMemberDetail', function () {
         })
       );
     });
+
+    it('cannot join idp-provisioned team', async function () {
+      render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
+        context: routerContext,
+      });
+
+      userEvent.click(screen.getByText('Add Team'));
+      userEvent.hover(screen.queryByText('#idp-team'));
+      expect(
+        await screen.findByText(
+          "Membership to this team is managed through your organization's identity provider."
+        )
+      ).toBeInTheDocument();
+    });
+
+    it('cannot change roles if member is idp-provisioned', function () {
+      const roleRestrictedMember = TestStubs.Member({
+        roles: TestStubs.OrgRoleList(),
+        dateCreated: new Date(),
+        teams: [team.slug],
+        flags: {
+          'idp:role-restricted': true,
+        },
+      });
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/members/${member.id}/`,
+        body: roleRestrictedMember,
+      });
+      render(<OrganizationMemberDetail params={{memberId: roleRestrictedMember.id}} />, {
+        context: routerContext,
+      });
+
+      const radios = screen.getAllByRole('radio');
+      expect(radios.at(0)).toHaveAttribute('readonly');
+    });
   });
 
   describe('Cannot Edit', function () {

+ 6 - 0
static/app/views/settings/organizationMembers/organizationMemberDetail.tsx

@@ -21,6 +21,7 @@ import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels
 import TextCopyInput from 'sentry/components/textCopyInput';
 import Tooltip from 'sentry/components/tooltip';
 import {t, tct} from 'sentry/locale';
+import configStore from 'sentry/stores/configStore';
 import space from 'sentry/styles/space';
 import {Member, Organization, Team} from 'sentry/types';
 import isMemberDisabledFromLimit from 'sentry/utils/isMemberDisabledFromLimit';
@@ -251,6 +252,8 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
     const {email, expired, pending} = member;
     const canResend = !expired;
     const showAuth = !pending;
+    const currentUser = configStore.get('user');
+    const isCurrentUser = currentUser.email === email;
 
     return (
       <Fragment>
@@ -354,7 +357,9 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
 
         <OrganizationRoleSelect
           enforceAllowed={false}
+          enforceIdpRoleRestricted={member.flags['idp:role-restricted']}
           enforceRetired={hasTeamRoles}
+          isCurrentUser={isCurrentUser}
           disabled={!canEdit}
           roleList={member.roles}
           roleSelected={member.role}
@@ -364,6 +369,7 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
         <Teams slugs={member.teams}>
           {({teams, initiallyLoaded}) => (
             <TeamSelect
+              enforceIdpProvisioned
               organization={organization}
               selectedTeams={teams}
               disabled={!canEdit}

+ 32 - 0
static/app/views/settings/organizationMembers/organizationMemberRow.spec.jsx

@@ -225,6 +225,34 @@ describe('OrganizationMemberRow', function () {
     });
   });
 
+  describe('IDP flags permissions', function () {
+    member.flags['idp:provisioned'] = true;
+    it('current user cannot leave if idp:provisioned', function () {
+      const props = {
+        ...defaultProps,
+        member: {
+          ...member,
+          email: 'currentUser@email.com',
+        },
+      };
+
+      render(
+        <OrganizationMemberRow
+          {...props}
+          memberCanLeave={!member.flags['idp:provisioned']}
+        />
+      );
+
+      expect(leaveButton()).toBeDisabled();
+    });
+
+    it('cannot remove member if member is idp:provisioned', function () {
+      render(<OrganizationMemberRow {...defaultProps} />);
+
+      expect(removeButton()).toBeDisabled();
+    });
+  });
+
   describe('Not Current User', function () {
     const props = {
       ...defaultProps,
@@ -237,12 +265,16 @@ describe('OrganizationMemberRow', function () {
     });
 
     it('has Remove disabled button when `canRemoveMembers` is false', function () {
+      member.flags['idp:provisioned'] = false;
+
       render(<OrganizationMemberRow {...props} />);
 
       expect(removeButton()).toBeDisabled();
     });
 
     it('has Remove button when `canRemoveMembers` is true', function () {
+      member.flags['idp:provisioned'] = false;
+
       render(<OrganizationMemberRow {...props} canRemoveMembers />);
 
       expect(removeButton()).toBeEnabled();

+ 16 - 2
static/app/views/settings/organizationMembers/organizationMemberRow.tsx

@@ -106,11 +106,12 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
     const {id, flags, email, name, pending, user} = member;
 
     // if member is not the only owner, they can leave
+    const isIdpProvisioned = flags['idp:provisioned'];
     const needsSso = !flags['sso:linked'] && requireLink;
     const isCurrentUser = currentUser.email === email;
     const showRemoveButton = !isCurrentUser;
     const showLeaveButton = isCurrentUser;
-    const canRemoveMember = canRemoveMembers && !isCurrentUser;
+    const canRemoveMember = canRemoveMembers && !isCurrentUser && !isIdpProvisioned;
     // member has a `user` property if they are registered with sentry
     // i.e. has accepted an invite to join org
     const has2fa = user && user.has2fa;
@@ -210,7 +211,7 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
               </Confirm>
             )}
 
-            {showLeaveButton && !memberCanLeave && (
+            {showLeaveButton && !memberCanLeave && !isIdpProvisioned && (
               <Button
                 size="sm"
                 icon={<IconClose size="xs" />}
@@ -222,6 +223,19 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
                 {t('Leave')}
               </Button>
             )}
+
+            {showLeaveButton && !memberCanLeave && isIdpProvisioned && (
+              <Button
+                size="sm"
+                icon={<IconClose size="xs" />}
+                disabled
+                title={t(
+                  "This user is managed through your organization's identity provider."
+                )}
+              >
+                {t('Leave')}
+              </Button>
+            )}
           </RightColumn>
         ) : null}
       </StyledPanelItem>

Some files were not shown because too many files changed in this diff