Hide add access based on the user profile for credentials

* Show add access button if it is a system admin
* Hide access button if the user is credential admin, org admin, but the
  credential does not belong to any org.
* Show access button if the user is a credential admin, org admin, and
  the credential is associated to an org.
* Show access button if the user is an org admin and the credential is
  associated to the org.

All those permutations are allowed by the API RBAC.
This PR update UX to not allow the user to attempt to perform any
action that will raise an error when modifying access to the
credentials.
This commit is contained in:
nixocio 2022-06-09 10:11:12 -04:00
parent 23aaf5b3ad
commit 96330f608d
3 changed files with 376 additions and 5 deletions

View File

@ -77,6 +77,10 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
disassociate: true,
});
}
readAdmins(id, params) {
return this.http.get(`${this.baseUrl}${id}/admins/`, { params });
}
}
export default Organizations;

View File

@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { t } from '@lingui/macro';
import { RolesAPI, TeamsAPI, UsersAPI } from 'api';
import { RolesAPI, TeamsAPI, UsersAPI, OrganizationsAPI } from 'api';
import { getQSConfig, parseQueryString } from 'util/qs';
import useRequest, { useDeleteItems } from 'hooks/useRequest';
import { useUserProfile, useConfig } from 'contexts/Config';
import AddResourceRole from '../AddRole/AddResourceRole';
import AlertModal from '../AlertModal';
import DataListToolbar from '../DataListToolbar';
@ -24,6 +25,8 @@ const QS_CONFIG = getQSConfig('access', {
});
function ResourceAccessList({ apiModel, resource }) {
const { isSuperUser, isOrgAdmin } = useUserProfile();
const { me } = useConfig();
const [submitError, setSubmitError] = useState(null);
const [deletionRecord, setDeletionRecord] = useState(null);
const [deletionRole, setDeletionRole] = useState(null);
@ -31,6 +34,49 @@ function ResourceAccessList({ apiModel, resource }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const location = useLocation();
const {
isLoading: isFetchingOrgAdmins,
error: errorFetchingOrgAdmins,
request: fetchOrgAdmins,
result: { isCredentialOrgAdmin },
} = useRequest(
useCallback(async () => {
if (
isSuperUser ||
resource.type !== 'credential' ||
!isOrgAdmin ||
!resource?.organization
) {
return false;
}
const {
data: { count },
} = await OrganizationsAPI.readAdmins(resource.organization, {
id: me.id,
});
return { isCredentialOrgAdmin: !!count };
}, [me.id, isOrgAdmin, isSuperUser, resource.type, resource.organization]),
{
isCredentialOrgAdmin: false,
}
);
useEffect(() => {
fetchOrgAdmins();
}, [fetchOrgAdmins]);
let canAddAdditionalControls = false;
if (isSuperUser) {
canAddAdditionalControls = true;
}
if (resource.type === 'credential' && isOrgAdmin && isCredentialOrgAdmin) {
canAddAdditionalControls = true;
}
if (resource.type !== 'credential') {
canAddAdditionalControls =
resource?.summary_fields?.user_capabilities?.edit;
}
const {
result: {
accessRecords,
@ -149,8 +195,8 @@ function ResourceAccessList({ apiModel, resource }) {
return (
<>
<PaginatedTable
error={contentError}
hasContentLoading={isLoading || isDeleteLoading}
error={contentError || errorFetchingOrgAdmins}
hasContentLoading={isLoading || isDeleteLoading || isFetchingOrgAdmins}
items={accessRecords}
itemCount={itemCount}
pluralizedItemName={t`Roles`}
@ -163,7 +209,7 @@ function ResourceAccessList({ apiModel, resource }) {
{...props}
qsConfig={QS_CONFIG}
additionalControls={
resource?.summary_fields?.user_capabilities?.edit
canAddAdditionalControls
? [
<ToolbarAddButton
ouiaId="access-add-button"

View File

@ -1,7 +1,15 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { OrganizationsAPI, TeamsAPI, UsersAPI, RolesAPI } from 'api';
import {
CredentialsAPI,
OrganizationsAPI,
RolesAPI,
TeamsAPI,
UsersAPI,
} from 'api';
import { useUserProfile } from 'contexts/Config';
import * as ConfigContext from 'contexts/Config';
import {
mountWithContexts,
waitForElement,
@ -91,11 +99,227 @@ describe('<ResourceAccessList />', () => {
],
};
const credentialAccessList = {
count: 2,
results: [
{
id: 1,
type: 'user',
url: '/api/v2/users/1/',
summary_fields: {
direct_access: [
{
role: {
id: 20,
name: 'Admin',
description: 'Can manage all aspects of the credential',
resource_name: 'Demo Credential',
resource_type: 'credential',
related: { credential: '/api/v2/credentials/1/' },
user_capabilities: { unattach: false },
},
descendant_roles: ['admin_role', 'read_role', 'use_role'],
},
],
indirect_access: [
{
role: {
id: 1,
name: 'System Administrator',
description: 'Can manage all aspects of the system',
user_capabilities: { unattach: false },
},
descendant_roles: ['admin_role', 'read_role', 'use_role'],
},
],
},
created: '2022-06-08T18:31:35.834036Z',
modified: '2022-06-09T16:47:54.712473Z',
username: 'admin',
first_name: '',
last_name: '',
email: 'admin@localhost',
is_superuser: true,
is_system_auditor: false,
ldap_dn: '',
last_login: '2022-06-09T16:47:54.712473Z',
external_account: null,
},
{
id: 2,
type: 'user',
url: '/api/v2/users/2/',
related: {
teams: '/api/v2/users/2/teams/',
organizations: '/api/v2/users/2/organizations/',
admin_of_organizations: '/api/v2/users/2/admin_of_organizations/',
projects: '/api/v2/users/2/projects/',
credentials: '/api/v2/users/2/credentials/',
roles: '/api/v2/users/2/roles/',
activity_stream: '/api/v2/users/2/activity_stream/',
access_list: '/api/v2/users/2/access_list/',
tokens: '/api/v2/users/2/tokens/',
authorized_tokens: '/api/v2/users/2/authorized_tokens/',
personal_tokens: '/api/v2/users/2/personal_tokens/',
},
summary_fields: {
direct_access: [
{
role: {
id: 22,
name: 'Read',
description: 'May view settings for the credential',
resource_name: 'Demo Credential',
resource_type: 'credential',
related: { credential: '/api/v2/credentials/1/' },
user_capabilities: { unattach: false },
},
descendant_roles: ['read_role'],
},
],
indirect_access: [],
},
created: '2022-06-09T13:45:56.049783Z',
modified: '2022-06-09T16:48:46.169760Z',
username: 'second',
first_name: '',
last_name: '',
email: '',
is_superuser: false,
is_system_auditor: false,
ldap_dn: '',
last_login: '2022-06-09T16:48:46.169760Z',
external_account: null,
},
],
};
const credential = {
id: 1,
type: 'credential',
url: '/api/v2/credentials/1/',
related: {
named_url: '/api/v2/credentials/Demo Credential++Machine+ssh++Default/',
created_by: '/api/v2/users/1/',
modified_by: '/api/v2/users/1/',
organization: '/api/v2/organizations/1/',
activity_stream: '/api/v2/credentials/1/activity_stream/',
access_list: '/api/v2/credentials/1/access_list/',
object_roles: '/api/v2/credentials/1/object_roles/',
owner_users: '/api/v2/credentials/1/owner_users/',
owner_teams: '/api/v2/credentials/1/owner_teams/',
copy: '/api/v2/credentials/1/copy/',
input_sources: '/api/v2/credentials/1/input_sources/',
credential_type: '/api/v2/credential_types/1/',
},
summary_fields: {
organization: {
id: 1,
name: 'Default',
description: '',
},
credential_type: {
id: 1,
name: 'Machine',
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 credential',
name: 'Admin',
id: 20,
},
use_role: {
description: 'Can use the credential in a job template',
name: 'Use',
id: 21,
},
read_role: {
description: 'May view settings for the credential',
name: 'Read',
id: 22,
},
},
user_capabilities: {
edit: true,
delete: true,
copy: false,
use: true,
},
owners: [
{
id: 3,
type: 'user',
name: 'third',
description: ' ',
url: '/api/v2/users/3/',
},
{
id: 1,
type: 'user',
name: 'admin',
description: ' ',
url: '/api/v2/users/1/',
},
{
id: 1,
type: 'organization',
name: 'Default',
description: '',
url: '/api/v2/organizations/1/',
},
],
},
created: '2022-06-08T18:31:43.491973Z',
modified: '2022-06-09T19:40:49.460771Z',
name: 'Demo Credential',
description: '',
organization: 1,
credential_type: 1,
managed: false,
inputs: {
username: 'admin',
become_method: '',
become_username: '',
},
kind: 'ssh',
cloud: false,
kubernetes: false,
};
const history = createMemoryHistory({
initialEntries: ['/organizations/1/access'],
});
const credentialHistory = createMemoryHistory({
initialEntries: ['/credentials/1/access'],
});
beforeEach(async () => {
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
me: { id: 2 },
}));
useUserProfile.mockImplementation(() => {
return {
isSuperUser: true,
isSystemAuditor: false,
isOrgAdmin: false,
isNotificationAdmin: false,
isExecEnvAdmin: false,
};
});
OrganizationsAPI.readAccessList.mockResolvedValue({ data });
OrganizationsAPI.readAccessOptions.mockResolvedValue({
data: {
@ -106,6 +330,7 @@ describe('<ResourceAccessList />', () => {
related_search_fields: [],
},
});
OrganizationsAPI.readAdmins.mockResolvedValue({ data: { count: 1 } });
TeamsAPI.disassociateRole.mockResolvedValue({});
UsersAPI.disassociateRole.mockResolvedValue({});
RolesAPI.read.mockResolvedValue({
@ -116,6 +341,16 @@ describe('<ResourceAccessList />', () => {
],
},
});
CredentialsAPI.readAccessList.mockResolvedValue({ credentialAccessList });
CredentialsAPI.readAccessOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
@ -213,4 +448,90 @@ describe('<ResourceAccessList />', () => {
},
]);
});
test('should show add button for system admin', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<ResourceAccessList resource={credential} apiModel={CredentialsAPI} />,
{ context: { router: { credentialHistory } } }
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
wrapper.update();
expect(wrapper.find('ToolbarAddButton').length).toEqual(1);
});
test('should not show add button for non system admin & non org admin', async () => {
useUserProfile.mockImplementation(() => {
return {
isSuperUser: false,
isSystemAuditor: false,
isOrgAdmin: false,
isNotificationAdmin: false,
isExecEnvAdmin: false,
};
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<ResourceAccessList resource={credential} apiModel={CredentialsAPI} />,
{ context: { router: { credentialHistory } } }
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
wrapper.update();
expect(wrapper.find('ToolbarAddButton').length).toEqual(0);
});
test('should show add button for non system admin, org admin, credential admin for credentials associated with org', async () => {
useUserProfile.mockImplementation(() => {
return {
isSuperUser: false,
isSystemAuditor: false,
isOrgAdmin: true,
isNotificationAdmin: false,
isExecEnvAdmin: false,
};
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<ResourceAccessList resource={credential} apiModel={CredentialsAPI} />,
{ context: { router: { credentialHistory } } }
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
wrapper.update();
expect(wrapper.find('ToolbarAddButton').length).toEqual(1);
});
test('should not show add button for non system admin, org admin, credential admin for credentials non associated with org', async () => {
useUserProfile.mockImplementation(() => {
return {
isSuperUser: false,
isSystemAuditor: false,
isOrgAdmin: true,
isNotificationAdmin: false,
isExecEnvAdmin: false,
};
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<ResourceAccessList
resource={{ ...credential, organization: null }}
apiModel={CredentialsAPI}
/>,
{ context: { router: { credentialHistory } } }
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
wrapper.update();
expect(wrapper.find('ToolbarAddButton').length).toEqual(0);
});
});