Merge pull request #7281 from mabashian/7127-cred-input-details

Updates credential details to support plugin and prompt fields

Reviewed-by: John Hill <johill@redhat.com>
             https://github.com/unlikelyzero
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-10-21 00:32:26 +00:00
committed by GitHub
5 changed files with 580 additions and 415 deletions

View File

@@ -20,10 +20,38 @@ class Credentials extends Base {
return this.http.options(`${this.baseUrl}${id}/access_list/`); return this.http.options(`${this.baseUrl}${id}/access_list/`);
} }
readInputSources(id, params) { readInputSources(id) {
return this.http.get(`${this.baseUrl}${id}/input_sources/`, { const maxRequests = 5;
params, let requestCounter = 0;
const fetchInputSources = async (pageNo = 1, inputSources = []) => {
try {
requestCounter++;
const { data } = await this.http.get(
`${this.baseUrl}${id}/input_sources/`,
{
params: {
page: pageNo,
page_size: 200,
},
}
);
if (data?.next && requestCounter <= maxRequests) {
return fetchInputSources(
pageNo + 1,
inputSources.concat(data.results)
);
}
return Promise.resolve({
data: {
results: inputSources.concat(data.results),
},
}); });
} catch (error) {
return Promise.reject(error);
}
};
return fetchInputSources();
} }
test(id, data) { test(id, data) {

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { Fragment, useEffect, useCallback } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } 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 { shape } from 'prop-types'; import { shape } from 'prop-types';
import styled from 'styled-components';
import { Button, List, ListItem } from '@patternfly/react-core'; import { Button, List, ListItem } from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
@@ -11,15 +11,26 @@ import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import DeleteButton from '../../../components/DeleteButton'; import DeleteButton from '../../../components/DeleteButton';
import { import {
DetailList,
Detail, Detail,
DetailList,
UserDateDetail, UserDateDetail,
} from '../../../components/DetailList'; } from '../../../components/DetailList';
import ChipGroup from '../../../components/ChipGroup';
import CodeMirrorInput from '../../../components/CodeMirrorInput';
import CredentialChip from '../../../components/CredentialChip';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import { Credential } from '../../../types'; import { Credential } from '../../../types';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
const PluginInputMetadata = styled(CodeMirrorInput)`
grid-column: 1 / -1;
`;
const PluginFieldText = styled.p`
margin-top: 10px;
`;
function CredentialDetail({ i18n, credential }) { function CredentialDetail({ i18n, credential }) {
const { const {
id: credentialId, id: credentialId,
@@ -36,31 +47,44 @@ function CredentialDetail({ i18n, credential }) {
user_capabilities, user_capabilities,
}, },
} = credential; } = credential;
const [fields, setFields] = useState([]);
const [managedByTower, setManagedByTower] = useState([]);
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const history = useHistory(); const history = useHistory();
useEffect(() => {
(async () => {
setContentError(null);
setHasContentLoading(true);
try {
const { const {
result: { fields, managedByTower, inputSources },
request: fetchDetails,
isLoading: hasContentLoading,
error: contentError,
} = useRequest(
useCallback(async () => {
const [
{
data: { inputs: credentialTypeInputs, managed_by_tower }, data: { inputs: credentialTypeInputs, managed_by_tower },
} = await CredentialTypesAPI.readDetail(credential_type.id); },
{
setFields(credentialTypeInputs.fields || []); data: { results: loadedInputSources },
setManagedByTower(managed_by_tower); },
} catch (error) { ] = await Promise.all([
setContentError(error); CredentialTypesAPI.readDetail(credential_type.id),
} finally { CredentialsAPI.readInputSources(credentialId),
setHasContentLoading(false); ]);
return {
fields: credentialTypeInputs.fields || [],
managedByTower: managed_by_tower,
inputSources: loadedInputSources.reduce(
(inputSourcesMap, inputSource) => {
inputSourcesMap[inputSource.input_field_name] = inputSource;
return inputSourcesMap;
},
{}
),
};
}, [credentialId, credential_type]),
{
fields: [],
managedByTower: true,
inputSources: {},
} }
})(); );
}, [credential_type]);
const { const {
request: deleteCredential, request: deleteCredential,
@@ -75,34 +99,84 @@ function CredentialDetail({ i18n, credential }) {
const { error, dismissError } = useDismissableError(deleteError); const { error, dismissError } = useDismissableError(deleteError);
const renderDetail = ({ id, label, type }) => { const renderDetail = ({ id, label, type, ask_at_runtime }) => {
let detail; if (inputSources[id]) {
return (
<Fragment key={id}>
<Detail
id={`credential-${id}-detail`}
fullWidth
label={<span>{label} *</span>}
value={
<ChipGroup numChips={1} totalChips={1}>
<CredentialChip
credential={inputSources[id].summary_fields.source_credential}
isReadOnly
/>
</ChipGroup>
}
/>
<PluginInputMetadata
id={`credential-${id}-metadata`}
mode="javascript"
readOnly
value={JSON.stringify(inputSources[id].metadata, null, 2)}
onChange={() => {}}
rows={5}
hasErrors={false}
/>
</Fragment>
);
}
if (type === 'boolean') { if (type === 'boolean') {
detail = ( return (
<Detail <Detail
id={`credential-${id}-detail`}
key={id} key={id}
label={i18n._(t`Options`)} label={i18n._(t`Options`)}
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>} value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
/> />
); );
} else if (inputs[id] === '$encrypted$') { }
const isEncrypted = true;
detail = ( if (inputs[id] === '$encrypted$') {
return (
<Detail <Detail
id={`credential-${id}-detail`}
key={id} key={id}
label={label} label={label}
value={i18n._(t`Encrypted`)} value={i18n._(t`Encrypted`)}
isEncrypted={isEncrypted} isEncrypted
/> />
); );
} else {
detail = <Detail key={id} label={label} value={inputs[id]} />;
} }
return detail; if (ask_at_runtime && inputs[id] === 'ASK') {
return (
<Detail
id={`credential-${id}-detail`}
key={id}
label={label}
value={i18n._(t`Prompt on launch`)}
/>
);
}
return (
<Detail
id={`credential-${id}-detail`}
key={id}
label={label}
value={inputs[id]}
/>
);
}; };
useEffect(() => {
fetchDetails();
}, [fetchDetails]);
if (hasContentLoading) { if (hasContentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
@@ -114,10 +188,19 @@ function CredentialDetail({ i18n, credential }) {
return ( return (
<CardBody> <CardBody>
<DetailList> <DetailList>
<Detail label={i18n._(t`Name`)} value={name} /> <Detail
<Detail label={i18n._(t`Description`)} value={description} /> id="credential-name-detail"
label={i18n._(t`Name`)}
value={name}
/>
<Detail
id="credential-description-detail"
label={i18n._(t`Description`)}
value={description}
/>
{organization && ( {organization && (
<Detail <Detail
id="credential-organization-detail"
label={i18n._(t`Organization`)} label={i18n._(t`Organization`)}
value={ value={
<Link to={`/organizations/${organization.id}/details`}> <Link to={`/organizations/${organization.id}/details`}>
@@ -127,6 +210,7 @@ function CredentialDetail({ i18n, credential }) {
/> />
)} )}
<Detail <Detail
id="credential-credential_type-detail"
label={i18n._(t`Credential Type`)} label={i18n._(t`Credential Type`)}
value={ value={
managedByTower ? ( managedByTower ? (
@@ -142,16 +226,25 @@ function CredentialDetail({ i18n, credential }) {
{fields.map(field => renderDetail(field))} {fields.map(field => renderDetail(field))}
<UserDateDetail <UserDateDetail
id="credential-created-detail"
label={i18n._(t`Created`)} label={i18n._(t`Created`)}
date={created} date={created}
user={created_by} user={created_by}
/> />
<UserDateDetail <UserDateDetail
id="credential-last_modified-detail"
label={i18n._(t`Last Modified`)} label={i18n._(t`Last Modified`)}
date={modified} date={modified}
user={modified_by} user={modified_by}
/> />
</DetailList> </DetailList>
{Object.keys(inputSources).length > 0 && (
<PluginFieldText>
{i18n._(
t`* This field will be retrieved from an external secret management system using the specified credential.`
)}
</PluginFieldText>
)}
<CardActionsRow> <CardActionsRow>
{user_capabilities.edit && ( {user_capabilities.edit && (
<Button component={Link} to={`/credentials/${credentialId}/edit`}> <Button component={Link} to={`/credentials/${credentialId}/edit`}>

View File

@@ -12,10 +12,37 @@ jest.mock('../../../api');
const mockCredential = mockCredentials.results[0]; const mockCredential = mockCredentials.results[0];
const mockInputSource = {
id: 33,
type: 'credential_input_source',
url: '/api/v2/credential_input_sources/33/',
summary_fields: {
source_credential: {
id: 424,
name: 'External Credential',
description: '',
kind: 'conjur',
cloud: false,
credential_type_id: 20,
},
},
input_field_name: 'ssh_key_unlock',
metadata: {
secret_path: '/foo/bar/baz',
secret_version: '17',
},
};
CredentialTypesAPI.readDetail.mockResolvedValue({ CredentialTypesAPI.readDetail.mockResolvedValue({
data: mockCredentialType, data: mockCredentialType,
}); });
CredentialsAPI.readInputSources.mockResolvedValue({
data: {
results: [mockInputSource],
},
});
function expectDetailToMatch(wrapper, label, value) { function expectDetailToMatch(wrapper, label, value) {
const detail = wrapper.find(`Detail[label="${label}"]`); const detail = wrapper.find(`Detail[label="${label}"]`);
expect(detail).toHaveLength(1); expect(detail).toHaveLength(1);
@@ -52,6 +79,18 @@ describe('<CredentialDetail />', () => {
mockCredential.summary_fields.credential_type.name mockCredential.summary_fields.credential_type.name
); );
expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username); expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username);
expectDetailToMatch(wrapper, 'Password', 'Encrypted');
expectDetailToMatch(wrapper, 'SSH Private Key', 'Encrypted');
expectDetailToMatch(wrapper, 'Signed SSH Certificate', 'Encrypted');
const sshKeyUnlockDetail = wrapper.find(
'Detail#credential-ssh_key_unlock-detail'
);
expect(sshKeyUnlockDetail.length).toBe(1);
expect(sshKeyUnlockDetail.find('CredentialChip').length).toBe(1);
expect(
wrapper.find('CodeMirrorInput#credential-ssh_key_unlock-metadata').props()
.value
).toBe(JSON.stringify(mockInputSource.metadata, null, 2));
expectDetailToMatch( expectDetailToMatch(
wrapper, wrapper,
'Privilege Escalation Method', 'Privilege Escalation Method',
@@ -62,6 +101,11 @@ describe('<CredentialDetail />', () => {
'Privilege Escalation Username', 'Privilege Escalation Username',
mockCredential.inputs.become_username mockCredential.inputs.become_username
); );
expectDetailToMatch(
wrapper,
'Privilege Escalation Password',
'Prompt on launch'
);
expect(wrapper.find(`Detail[label="Options"] ListItem`).text()).toEqual( expect(wrapper.find(`Detail[label="Options"] ListItem`).text()).toEqual(
'Authorize' 'Authorize'
); );

View File

@@ -151,7 +151,7 @@ function CredentialEdit({ credential, me }) {
}; };
if (error) { if (error) {
return <ContentError />; return <ContentError error={error} />;
} }
if (isLoading) { if (isLoading) {

View File

@@ -78,7 +78,7 @@
"username": "foo", "username": "foo",
"ssh_key_data": "$encrypted$", "ssh_key_data": "$encrypted$",
"become_method": "sudo", "become_method": "sudo",
"become_password": "$encrypted$", "become_password": "ASK",
"become_username": "bar", "become_username": "bar",
"ssh_public_key_data": "$encrypted$", "ssh_public_key_data": "$encrypted$",
"authorize": true "authorize": true