Fix rbac on Add button on User Access/Team Roles lists

This commit is contained in:
mabashian
2020-08-04 08:49:51 -04:00
parent b11908ed1f
commit 4ce2235f68
7 changed files with 186 additions and 90 deletions

View File

@@ -54,6 +54,12 @@ class Users extends Base {
params, params,
}); });
} }
readAdminOfOrganizations(userId, params) {
return this.http.get(`${this.baseUrl}${userId}/admin_of_organizations/`, {
params,
});
}
} }
export default Users; export default Users;

View File

@@ -11,6 +11,7 @@ import {
} from 'react-router-dom'; } from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons'; import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { Config } from '../../contexts/Config';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import TeamDetail from './TeamDetail'; import TeamDetail from './TeamDetail';
@@ -102,7 +103,11 @@ function Team({ i18n, setBreadcrumb }) {
)} )}
{team && ( {team && (
<Route path="/teams/:id/roles"> <Route path="/teams/:id/roles">
<TeamAccessList /> <Config>
{({ me }) => (
<>{me && <TeamAccessList me={me} team={team} />}</>
)}
</Config>
</Route> </Route>
)} )}
<Route key="not-found" path="*"> <Route key="not-found" path="*">

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -12,7 +12,7 @@ import {
Title, Title,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons'; import { CubesIcon } from '@patternfly/react-icons';
import { TeamsAPI, RolesAPI } from '../../../api'; import { TeamsAPI, RolesAPI, UsersAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest'; import useRequest, { useDeleteItems } from '../../../util/useRequest';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList from '../../../components/PaginatedDataList';
@@ -28,17 +28,16 @@ const QS_CONFIG = getQSConfig('roles', {
order_by: 'id', order_by: 'id',
}); });
function TeamRolesList({ i18n }) { function TeamRolesList({ i18n, me, team }) {
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const { search } = useLocation(); const { search } = useLocation();
const { id } = useParams();
const [roleToDisassociate, setRoleToDisassociate] = useState(null); const [roleToDisassociate, setRoleToDisassociate] = useState(null);
const { const {
isLoading, isLoading,
request: fetchRoles, request: fetchRoles,
contentError, contentError,
result: { roleCount, roles, options }, result: { roleCount, roles, isAdminOfOrg },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search); const params = parseQueryString(QS_CONFIG, search);
@@ -46,18 +45,23 @@ function TeamRolesList({ i18n }) {
{ {
data: { results, count }, data: { results, count },
}, },
{ { count: orgAdminCount },
data: { actions },
},
] = await Promise.all([ ] = await Promise.all([
TeamsAPI.readRoles(id, params), TeamsAPI.readRoles(team.id, params),
TeamsAPI.readRoleOptions(id), UsersAPI.readAdminOfOrganizations(me.id, {
id: team.organization,
}),
]); ]);
return { roleCount: count, roles: results, options: actions }; return {
}, [id, search]), roleCount: count,
roles: results,
isAdminOfOrg: orgAdminCount > 0,
};
}, [me.id, team.id, team.organization, search]),
{ {
roles: [], roles: [],
roleCount: 0, roleCount: 0,
isAdminOfOrg: false,
} }
); );
@@ -79,14 +83,13 @@ function TeamRolesList({ i18n }) {
setRoleToDisassociate(null); setRoleToDisassociate(null);
await RolesAPI.disassociateTeamRole( await RolesAPI.disassociateTeamRole(
roleToDisassociate.id, roleToDisassociate.id,
parseInt(id, 10) parseInt(team.id, 10)
); );
}, [roleToDisassociate, id]), }, [roleToDisassociate, team.id]),
{ qsConfig: QS_CONFIG, fetchItems: fetchRoles } { qsConfig: QS_CONFIG, fetchItems: fetchRoles }
); );
const canAdd = const canAdd = team?.summary_fields?.user_capabilities?.edit || isAdminOfOrg;
options && Object.prototype.hasOwnProperty.call(options, 'POST');
const detailUrl = role => { const detailUrl = role => {
const { resource_id, resource_type } = role.summary_fields; const { resource_id, resource_type } = role.summary_fields;
@@ -128,7 +131,7 @@ function TeamRolesList({ i18n }) {
hasContentLoading={isLoading || isDisassociateLoading} hasContentLoading={isLoading || isDisassociateLoading}
items={roles} items={roles}
itemCount={roleCount} itemCount={roleCount}
pluralizedItemName={i18n._(t`Teams`)} pluralizedItemName={i18n._(t`Team Roles`)}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
@@ -157,7 +160,7 @@ function TeamRolesList({ i18n }) {
setIsWizardOpen(true); setIsWizardOpen(true);
}} }}
> >
Add {i18n._(t`Add`)}
</Button>, </Button>,
] ]
: []), : []),

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { TeamsAPI, RolesAPI } from '../../../api'; import { TeamsAPI, RolesAPI, UsersAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -9,13 +9,74 @@ import TeamRolesList from './TeamRolesList';
jest.mock('../../../api/models/Teams'); jest.mock('../../../api/models/Teams');
jest.mock('../../../api/models/Roles'); jest.mock('../../../api/models/Roles');
jest.mock('../../../api/models/Users');
jest.mock('react-router-dom', () => ({ const me = {
...jest.requireActual('react-router-dom'), id: 1,
useParams: () => ({ };
id: 18,
}), const team = {
})); id: 18,
type: 'team',
url: '/api/v2/teams/1/',
related: {
created_by: '/api/v2/users/1/',
modified_by: '/api/v2/users/1/',
projects: '/api/v2/teams/1/projects/',
users: '/api/v2/teams/1/users/',
credentials: '/api/v2/teams/1/credentials/',
roles: '/api/v2/teams/1/roles/',
object_roles: '/api/v2/teams/1/object_roles/',
activity_stream: '/api/v2/teams/1/activity_stream/',
access_list: '/api/v2/teams/1/access_list/',
organization: '/api/v2/organizations/1/',
},
summary_fields: {
organization: {
id: 1,
name: 'Default',
description: '',
},
created_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
modified_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
object_roles: {
admin_role: {
description: 'Can manage all aspects of the team',
name: 'Admin',
id: 33,
},
member_role: {
description: 'User is a member of the team',
name: 'Member',
id: 34,
},
read_role: {
description: 'May view settings for the team',
name: 'Read',
id: 35,
},
},
user_capabilities: {
edit: false,
delete: false,
},
},
created: '2020-07-22T18:21:54.233411Z',
modified: '2020-07-22T18:21:54.233442Z',
name: 'a team',
description: '',
organization: 1,
};
const roles = { const roles = {
data: { data: {
@@ -89,32 +150,40 @@ const roles = {
count: 5, count: 5,
}, },
}; };
const options = {
data: { actions: { POST: { id: 1, disassociate: true } } },
};
describe('<TeamRolesList />', () => { describe('<TeamRolesList />', () => {
let wrapper; let wrapper;
beforeEach(() => {
UsersAPI.readAdminOfOrganizations.mockResolvedValue({
count: 1,
results: [
{
id: 1,
name: 'Foo Org',
},
],
});
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
wrapper.unmount(); wrapper.unmount();
}); });
test('should render properly', async () => { test('should render properly', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles); TeamsAPI.readRoles.mockResolvedValue(roles);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TeamRolesList />); wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />);
}); });
expect(wrapper.find('TeamRolesList').length).toBe(1); expect(wrapper.find('TeamRolesList').length).toBe(1);
}); });
test('should create proper detailUrl', async () => { test('should create proper detailUrl', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles); TeamsAPI.readRoles.mockResolvedValue(roles);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TeamRolesList />); wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
@@ -134,9 +203,10 @@ describe('<TeamRolesList />', () => {
'/inventories/smart_inventory/77/details' '/inventories/smart_inventory/77/details'
); );
}); });
test('should not render add button', async () => { test('should not render add button when user cannot edit team and is not an admin of the org', async () => {
TeamsAPI.readRoleOptions.mockResolvedValueOnce({ UsersAPI.readAdminOfOrganizations.mockResolvedValueOnce({
data: {}, count: 0,
results: [],
}); });
TeamsAPI.readRoles.mockResolvedValue({ TeamsAPI.readRoles.mockResolvedValue({
@@ -160,8 +230,9 @@ describe('<TeamRolesList />', () => {
count: 1, count: 1,
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TeamRolesList />); wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
@@ -172,10 +243,9 @@ describe('<TeamRolesList />', () => {
test('should render disassociate modal', async () => { test('should render disassociate modal', async () => {
TeamsAPI.readRoles.mockResolvedValue(roles); TeamsAPI.readRoles.mockResolvedValue(roles);
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TeamRolesList />); wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
@@ -225,10 +295,9 @@ describe('<TeamRolesList />', () => {
}, },
}) })
); );
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TeamRolesList />); wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
@@ -282,10 +351,9 @@ describe('<TeamRolesList />', () => {
count: 1, count: 1,
}, },
}); });
TeamsAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TeamRolesList />); wrapper = mountWithContexts(<TeamRolesList me={me} team={team} />);
}); });
waitForElement( waitForElement(

View File

@@ -127,7 +127,7 @@ function User({ i18n, setBreadcrumb, me }) {
</Route> </Route>
{user && ( {user && (
<Route path="/users/:id/access"> <Route path="/users/:id/access">
<UserAccessList /> <UserAccessList user={user} />
</Route> </Route>
)} )}
<Route path="/users/:id/tokens"> <Route path="/users/:id/tokens">

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
@@ -29,8 +29,7 @@ const QS_CONFIG = getQSConfig('roles', {
// TODO Figure out how to best conduct a search of this list. // TODO Figure out how to best conduct a search of this list.
// Since we only have a role ID in the top level of each role object // Since we only have a role ID in the top level of each role object
// we can't really search using the normal search parameters. // we can't really search using the normal search parameters.
function UserAccessList({ i18n }) { function UserAccessList({ i18n, user }) {
const { id } = useParams();
const { search } = useLocation(); const { search } = useLocation();
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
@@ -51,11 +50,11 @@ function UserAccessList({ i18n }) {
data: { actions }, data: { actions },
}, },
] = await Promise.all([ ] = await Promise.all([
UsersAPI.readRoles(id, params), UsersAPI.readRoles(user.id, params),
UsersAPI.readRoleOptions(id), UsersAPI.readOptions(),
]); ]);
return { roleCount: count, roles: results, options: actions }; return { roleCount: count, roles: results, options: actions };
}, [id, search]), }, [user.id, search]),
{ {
roles: [], roles: [],
roleCount: 0, roleCount: 0,
@@ -75,14 +74,15 @@ function UserAccessList({ i18n }) {
setRoleToDisassociate(null); setRoleToDisassociate(null);
await RolesAPI.disassociateUserRole( await RolesAPI.disassociateUserRole(
roleToDisassociate.id, roleToDisassociate.id,
parseInt(id, 10) parseInt(user.id, 10)
); );
}, [roleToDisassociate, id]), }, [roleToDisassociate, user.id]),
{ qsConfig: QS_CONFIG, fetchItems: fetchRoles } { qsConfig: QS_CONFIG, fetchItems: fetchRoles }
); );
const canAdd = const canAdd =
options && Object.prototype.hasOwnProperty.call(options, 'POST'); user?.summary_fields?.user_capabilities?.edit ||
(options && Object.prototype.hasOwnProperty.call(options, 'POST'));
const saveRoles = () => { const saveRoles = () => {
setIsWizardOpen(false); setIsWizardOpen(false);
@@ -170,7 +170,7 @@ function UserAccessList({ i18n }) {
setIsWizardOpen(true); setIsWizardOpen(true);
}} }}
> >
Add {i18n._(t`Add`)}
</Button>, </Button>,
] ]
: []), : []),
@@ -198,7 +198,7 @@ function UserAccessList({ i18n }) {
<Button <Button
key="disassociate" key="disassociate"
variant="danger" variant="danger"
aria-label={i18n._(t`confirm disassociate`)} aria-label={i18n._(t`Confirm disassociate`)}
onClick={() => disassociateRole()} onClick={() => disassociateRole()}
> >
{i18n._(t`Disassociate`)} {i18n._(t`Disassociate`)}

View File

@@ -10,12 +10,25 @@ import UserAccessList from './UserAccessList';
jest.mock('../../../api/models/Users'); jest.mock('../../../api/models/Users');
jest.mock('../../../api/models/Roles'); jest.mock('../../../api/models/Roles');
jest.mock('react-router-dom', () => ({ UsersAPI.readOptions.mockResolvedValue({
...jest.requireActual('react-router-dom'), data: {
useParams: () => ({ actions: {
id: 18, GET: {},
}), },
})); },
});
const user = {
id: 18,
username: 'Foo User',
summary_fields: {
user_capabilities: {
edit: true,
delete: true,
},
},
};
const roles = { const roles = {
data: { data: {
results: [ results: [
@@ -88,21 +101,18 @@ const roles = {
count: 5, count: 5,
}, },
}; };
const options = {
data: { actions: { POST: { id: 1, disassociate: true } } },
};
describe('<UserAccessList />', () => { describe('<UserAccessList />', () => {
let wrapper; let wrapper;
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// wrapper.unmount(); wrapper.unmount();
}); });
test('should render properly', async () => { test('should render properly', async () => {
UsersAPI.readRoles.mockResolvedValue(roles); UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(<UserAccessList user={user} />);
}); });
expect(wrapper.find('UserAccessList').length).toBe(1); expect(wrapper.find('UserAccessList').length).toBe(1);
@@ -110,13 +120,12 @@ describe('<UserAccessList />', () => {
test('should create proper detailUrl', async () => { test('should create proper detailUrl', async () => {
UsersAPI.readRoles.mockResolvedValue(roles); UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(<UserAccessList user={user} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); wrapper.update();
expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe( expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe(
'/templates/job_template/15/details' '/templates/job_template/15/details'
@@ -134,11 +143,7 @@ describe('<UserAccessList />', () => {
'/inventories/smart_inventory/77/details' '/inventories/smart_inventory/77/details'
); );
}); });
test('should not render add button', async () => { test('should not render add button when user cannot create other users and user cannot edit this user', async () => {
UsersAPI.readRoleOptions.mockResolvedValueOnce({
data: {},
});
UsersAPI.readRoles.mockResolvedValue({ UsersAPI.readRoles.mockResolvedValue({
data: { data: {
results: [ results: [
@@ -177,21 +182,33 @@ describe('<UserAccessList />', () => {
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(
<UserAccessList
user={{
...user,
summary_fields: {
user_capabilities: {
edit: false,
delete: false,
},
},
}}
/>
);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); wrapper.update();
expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe( expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe(
0 0
); );
}); });
test('should open and close wizard', async () => { test('should open and close wizard', async () => {
UsersAPI.readRoles.mockResolvedValue(roles); UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(<UserAccessList user={user} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); wrapper.update();
await act(async () => await act(async () =>
wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')()
); );
@@ -205,13 +222,12 @@ describe('<UserAccessList />', () => {
}); });
test('should render disassociate modal', async () => { test('should render disassociate modal', async () => {
UsersAPI.readRoles.mockResolvedValue(roles); UsersAPI.readRoles.mockResolvedValue(roles);
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(<UserAccessList user={user} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); wrapper.update();
await act(async () => await act(async () =>
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
@@ -234,7 +250,7 @@ describe('<UserAccessList />', () => {
).toBe(1); ).toBe(1);
await act(async () => await act(async () =>
wrapper wrapper
.find('button[aria-label="confirm disassociate"]') .find('button[aria-label="Confirm disassociate"]')
.prop('onClick')() .prop('onClick')()
); );
expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18); expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18);
@@ -257,13 +273,12 @@ describe('<UserAccessList />', () => {
}, },
}) })
); );
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(<UserAccessList user={user} />);
}); });
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); wrapper.update();
await act(async () => await act(async () =>
wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({
@@ -286,7 +301,7 @@ describe('<UserAccessList />', () => {
).toBe(1); ).toBe(1);
await act(async () => await act(async () =>
wrapper wrapper
.find('button[aria-label="confirm disassociate"]') .find('button[aria-label="Confirm disassociate"]')
.prop('onClick')() .prop('onClick')()
); );
wrapper.update(); wrapper.update();
@@ -313,10 +328,9 @@ describe('<UserAccessList />', () => {
count: 1, count: 1,
}, },
}); });
UsersAPI.readRoleOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserAccessList />); wrapper = mountWithContexts(<UserAccessList user={user} />);
}); });
waitForElement( waitForElement(