Merge pull request #7125 from mabashian/5880-cred-plugins

Adds basic credential plugin support to relevant fields in the static credential forms

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-06-02 17:25:48 +00:00 committed by GitHub
commit 3dec277331
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1471 additions and 147 deletions

View File

@ -1,5 +1,6 @@
import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config';
import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials';
import Groups from './models/Groups';
@ -14,8 +15,8 @@ import Labels from './models/Labels';
import Me from './models/Me';
import NotificationTemplates from './models/NotificationTemplates';
import Organizations from './models/Organizations';
import Projects from './models/Projects';
import ProjectUpdates from './models/ProjectUpdates';
import Projects from './models/Projects';
import Root from './models/Root';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
@ -24,14 +25,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
import WorkflowJobs from './models/WorkflowJobs';
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs';
const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config();
const CredentialsAPI = new Credentials();
const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes();
const CredentialsAPI = new Credentials();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
@ -44,8 +46,8 @@ const LabelsAPI = new Labels();
const MeAPI = new Me();
const NotificationTemplatesAPI = new NotificationTemplates();
const OrganizationsAPI = new Organizations();
const ProjectsAPI = new Projects();
const ProjectUpdatesAPI = new ProjectUpdates();
const ProjectsAPI = new Projects();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
@ -54,15 +56,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
export {
AdHocCommandsAPI,
ConfigAPI,
CredentialsAPI,
CredentialInputSourcesAPI,
CredentialTypesAPI,
CredentialsAPI,
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
@ -75,8 +78,8 @@ export {
MeAPI,
NotificationTemplatesAPI,
OrganizationsAPI,
ProjectsAPI,
ProjectUpdatesAPI,
ProjectsAPI,
RootAPI,
SchedulesAPI,
SystemJobsAPI,
@ -85,7 +88,7 @@ export {
UnifiedJobsAPI,
UsersAPI,
WorkflowApprovalTemplatesAPI,
WorkflowJobsAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
WorkflowJobsAPI,
};

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class CredentialInputSources extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/credential_input_sources/';
}
}
export default CredentialInputSources;

View File

@ -6,6 +6,7 @@ class Credentials extends Base {
this.baseUrl = '/api/v2/credentials/';
this.readAccessList = this.readAccessList.bind(this);
this.readInputSources = this.readInputSources.bind(this);
}
readAccessList(id, params) {
@ -13,6 +14,12 @@ class Credentials extends Base {
params,
});
}
readInputSources(id, params) {
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
params,
});
}
}
export default Credentials;

View File

@ -1,29 +1,14 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Button,
ButtonVariant,
FormGroup,
InputGroup,
TextInput,
Tooltip,
} from '@patternfly/react-core';
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
import { FormGroup, InputGroup } from '@patternfly/react-core';
import PasswordInput from './PasswordInput';
function PasswordField(props) {
const { id, name, label, validate, isRequired, isDisabled, i18n } = props;
const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate });
const { id, name, label, validate, isRequired } = props;
const [, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
const handlePasswordToggle = () => {
setInputType(inputType === 'text' ? 'password' : 'text');
};
return (
<FormGroup
fieldId={id}
@ -33,32 +18,7 @@ function PasswordField(props) {
label={label}
>
<InputGroup>
<Tooltip
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
>
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Toggle Password`)}
onClick={handlePasswordToggle}
isDisabled={isDisabled}
>
{inputType === 'password' && <EyeSlashIcon />}
{inputType === 'text' && <EyeIcon />}
</Button>
</Tooltip>
<TextInput
id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field}
value={field.value === '$encrypted$' ? '' : field.value}
isDisabled={isDisabled}
isRequired={isRequired}
isValid={isValid}
type={inputType}
onChange={(_, event) => {
field.onChange(event);
}}
/>
<PasswordInput {...props} />
</InputGroup>
</FormGroup>
);
@ -79,4 +39,4 @@ PasswordField.defaultProps = {
isDisabled: false,
};
export default withI18n()(PasswordField);
export default PasswordField;

View File

@ -1,7 +1,6 @@
import React from 'react';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import PasswordField from './PasswordField';
describe('PasswordField', () => {
@ -19,26 +18,4 @@ describe('PasswordField', () => {
);
expect(wrapper).toHaveLength(1);
});
test('properly responds to show/hide toggles', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
password: '',
}}
>
{() => (
<PasswordField id="test-password" name="password" label="Password" />
)}
</Formik>
);
expect(wrapper.find('input').prop('type')).toBe('password');
expect(wrapper.find('EyeSlashIcon').length).toBe(1);
expect(wrapper.find('EyeIcon').length).toBe(0);
wrapper.find('button').simulate('click');
await sleep(1);
expect(wrapper.find('input').prop('type')).toBe('text');
expect(wrapper.find('EyeSlashIcon').length).toBe(0);
expect(wrapper.find('EyeIcon').length).toBe(1);
});
});

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Button,
ButtonVariant,
TextInput,
Tooltip,
} from '@patternfly/react-core';
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
function PasswordInput(props) {
const { id, name, validate, isRequired, isDisabled, i18n } = props;
const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
const handlePasswordToggle = () => {
setInputType(inputType === 'text' ? 'password' : 'text');
};
return (
<>
<Tooltip
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
>
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Toggle Password`)}
onClick={handlePasswordToggle}
isDisabled={isDisabled}
>
{inputType === 'password' && <EyeSlashIcon />}
{inputType === 'text' && <EyeIcon />}
</Button>
</Tooltip>
<TextInput
id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field}
value={field.value === '$encrypted$' ? '' : field.value}
isDisabled={isDisabled}
isRequired={isRequired}
isValid={isValid}
type={inputType}
onChange={(_, event) => {
field.onChange(event);
}}
/>
</>
);
}
PasswordInput.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
validate: PropTypes.func,
isRequired: PropTypes.bool,
isDisabled: PropTypes.bool,
};
PasswordInput.defaultProps = {
validate: () => {},
isRequired: false,
isDisabled: false,
};
export default withI18n()(PasswordInput);

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import PasswordInput from './PasswordInput';
describe('PasswordInput', () => {
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
password: '',
}}
>
{() => (
<PasswordInput id="test-password" name="password" label="Password" />
)}
</Formik>
);
expect(wrapper).toHaveLength(1);
});
test('properly responds to show/hide toggles', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
password: '',
}}
>
{() => (
<PasswordInput id="test-password" name="password" label="Password" />
)}
</Formik>
);
expect(wrapper.find('input').prop('type')).toBe('password');
expect(wrapper.find('EyeSlashIcon').length).toBe(1);
expect(wrapper.find('EyeIcon').length).toBe(0);
wrapper.find('button').simulate('click');
expect(wrapper.find('input').prop('type')).toBe('text');
expect(wrapper.find('EyeSlashIcon').length).toBe(0);
expect(wrapper.find('EyeIcon').length).toBe(1);
});
});

View File

@ -2,4 +2,5 @@ export { default } from './FormField';
export { default as CheckboxField } from './CheckboxField';
export { default as FieldTooltip } from './FieldTooltip';
export { default as PasswordField } from './PasswordField';
export { default as PasswordInput } from './PasswordInput';
export { default as FormSubmitError } from './FormSubmitError';

View File

@ -1,20 +1,74 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core';
import { CardBody } from '../../../components/Card';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
import {
CredentialInputSourcesAPI,
CredentialTypesAPI,
CredentialsAPI,
} from '../../../api';
import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest';
function CredentialAdd({ me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory();
const {
error: submitError,
request: submitRequest,
result: credentialId,
} = useRequest(
useCallback(
async values => {
const { inputs, organization, ...remainingValues } = values;
const nonPluginInputs = {};
const pluginInputs = {};
Object.entries(inputs).forEach(([key, value]) => {
if (value.credential && value.inputs) {
pluginInputs[key] = value;
} else {
nonPluginInputs[key] = value;
}
});
const {
data: { id: newCredentialId },
} = await CredentialsAPI.create({
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
inputs: nonPluginInputs,
...remainingValues,
});
const inputSourceRequests = [];
Object.entries(pluginInputs).forEach(([key, value]) => {
inputSourceRequests.push(
CredentialInputSourcesAPI.create({
input_field_name: key,
metadata: value.inputs,
source_credential: value.credential.id,
target_credential: newCredentialId,
})
);
});
await Promise.all(inputSourceRequests);
return newCredentialId;
},
[me]
)
);
useEffect(() => {
if (credentialId) {
history.push(`/credentials/${credentialId}/details`);
}
}, [credentialId, history]);
useEffect(() => {
const loadData = async () => {
try {
@ -38,21 +92,7 @@ function CredentialAdd({ me }) {
};
const handleSubmit = async values => {
const { organization, ...remainingValues } = values;
setFormSubmitError(null);
try {
const {
data: { id: credentialId },
} = await CredentialsAPI.create({
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
...remainingValues,
});
const url = `/credentials/${credentialId}/details`;
history.push(`${url}`);
} catch (err) {
setFormSubmitError(err);
}
await submitRequest(values);
};
if (error) {
@ -85,7 +125,7 @@ function CredentialAdd({ me }) {
onCancel={handleCancel}
onSubmit={handleSubmit}
credentialTypes={credentialTypes}
submitError={formSubmitError}
submitError={submitError}
/>
</CardBody>
</Card>

View File

@ -1,29 +1,117 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { object } from 'prop-types';
import { CardBody } from '../../../components/Card';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import {
CredentialsAPI,
CredentialInputSourcesAPI,
CredentialTypesAPI,
} from '../../../api';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest';
function CredentialEdit({ credential, me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const [formSubmitError, setFormSubmitError] = useState(null);
const [inputSources, setInputSources] = useState({});
const history = useHistory();
const { error: submitError, request: submitRequest, result } = useRequest(
useCallback(
async (values, inputSourceMap) => {
const createAndUpdateInputSources = pluginInputs =>
Object.entries(pluginInputs).map(([fieldName, fieldValue]) => {
if (!inputSourceMap[fieldName]) {
return CredentialInputSourcesAPI.create({
input_field_name: fieldName,
metadata: fieldValue.inputs,
source_credential: fieldValue.credential.id,
target_credential: credential.id,
});
}
if (fieldValue.touched) {
return CredentialInputSourcesAPI.update(
inputSourceMap[fieldName].id,
{
metadata: fieldValue.inputs,
source_credential: fieldValue.credential.id,
}
);
}
return null;
});
const destroyInputSources = inputs => {
const destroyRequests = [];
Object.values(inputSourceMap).forEach(inputSource => {
const { id, input_field_name } = inputSource;
if (!inputs[input_field_name]?.credential) {
destroyRequests.push(CredentialInputSourcesAPI.destroy(id));
}
});
return destroyRequests;
};
const { inputs, organization, ...remainingValues } = values;
const nonPluginInputs = {};
const pluginInputs = {};
Object.entries(inputs).forEach(([key, value]) => {
if (value.credential && value.inputs) {
pluginInputs[key] = value;
} else {
nonPluginInputs[key] = value;
}
});
const [{ data }] = await Promise.all([
CredentialsAPI.update(credential.id, {
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
inputs: nonPluginInputs,
...remainingValues,
}),
...destroyInputSources(inputs),
]);
await Promise.all(createAndUpdateInputSources(pluginInputs));
return data;
},
[credential.id, me]
)
);
useEffect(() => {
if (result) {
history.push(`/credentials/${result.id}/details`);
}
}, [result, history]);
useEffect(() => {
const loadData = async () => {
try {
const {
data: { results: loadedCredentialTypes },
} = await CredentialTypesAPI.read({
or__namespace: ['gce', 'scm', 'ssh'],
});
const [
{
data: { results: loadedCredentialTypes },
},
{
data: { results: loadedInputSources },
},
] = await Promise.all([
CredentialTypesAPI.read({
or__namespace: ['gce', 'scm', 'ssh'],
}),
CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
]);
setCredentialTypes(loadedCredentialTypes);
setInputSources(
loadedInputSources.reduce((inputSourcesMap, inputSource) => {
inputSourcesMap[inputSource.input_field_name] = inputSource;
return inputSourcesMap;
}, {})
);
} catch (err) {
setError(err);
} finally {
@ -31,30 +119,15 @@ function CredentialEdit({ credential, me }) {
}
};
loadData();
}, []);
}, [credential.id]);
const handleCancel = () => {
const url = `/credentials/${credential.id}/details`;
history.push(`${url}`);
};
const handleSubmit = async values => {
const { organization, ...remainingValues } = values;
setFormSubmitError(null);
try {
const {
data: { id: credentialId },
} = await CredentialsAPI.update(credential.id, {
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
...remainingValues,
});
const url = `/credentials/${credentialId}/details`;
history.push(`${url}`);
} catch (err) {
setFormSubmitError(err);
}
await submitRequest(values, inputSources);
};
if (error) {
@ -72,7 +145,8 @@ function CredentialEdit({ credential, me }) {
onSubmit={handleSubmit}
credential={credential}
credentialTypes={credentialTypes}
submitError={formSubmitError}
inputSources={inputSources}
submitError={submitError}
/>
</CardBody>
);

View File

@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({
});
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } });
describe('<CredentialEdit />', () => {
let wrapper;

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { arrayOf, func, object, shape } from 'prop-types';
import { Form, FormGroup, Title } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
@ -124,6 +124,7 @@ function CredentialFormFields({
function CredentialForm({
credential = {},
credentialTypes,
inputSources,
onSubmit,
onCancel,
submitError,
@ -147,6 +148,13 @@ function CredentialForm({
},
};
Object.values(inputSources).forEach(inputSource => {
initialValues.inputs[inputSource.input_field_name] = {
credential: inputSource.summary_fields.source_credential,
inputs: inputSource.metadata,
};
});
const scmCredentialTypeId = Object.keys(credentialTypes)
.filter(key => credentialTypes[key].namespace === 'scm')
.map(key => credentialTypes[key].id)[0];
@ -232,10 +240,12 @@ CredentialForm.proptype = {
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
credential: shape({}),
inputSources: arrayOf(object),
};
CredentialForm.defaultProps = {
credential: {},
inputSources: [],
};
export default withI18n()(CredentialForm);

View File

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Button,
ButtonVariant,
FormGroup,
InputGroup,
Tooltip,
} from '@patternfly/react-core';
import { KeyIcon } from '@patternfly/react-icons';
import { CredentialPluginPrompt } from './CredentialPluginPrompt';
import CredentialPluginSelected from './CredentialPluginSelected';
function CredentialPluginField(props) {
const {
children,
id,
name,
label,
validate,
isRequired,
isDisabled,
i18n,
} = props;
const [showPluginWizard, setShowPluginWizard] = useState(false);
const [field, meta, helpers] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
return (
<FormGroup
fieldId={id}
helperTextInvalid={meta.error}
isRequired={isRequired}
isValid={isValid}
label={label}
>
{field?.value?.credential ? (
<CredentialPluginSelected
credential={field?.value?.credential}
onClearPlugin={() => helpers.setValue('')}
onEditPlugin={() => setShowPluginWizard(true)}
/>
) : (
<InputGroup>
{React.cloneElement(children, {
...field,
isRequired,
onChange: (_, event) => {
field.onChange(event);
},
})}
<Tooltip
content={i18n._(
t`Populate field from an external secret management system`
)}
>
<Button
variant={ButtonVariant.control}
aria-label={i18n._(
t`Populate field from an external secret management system`
)}
onClick={() => setShowPluginWizard(true)}
isDisabled={isDisabled}
>
<KeyIcon />
</Button>
</Tooltip>
</InputGroup>
)}
{showPluginWizard && (
<CredentialPluginPrompt
initialValues={typeof field.value === 'object' ? field.value : {}}
onClose={() => setShowPluginWizard(false)}
onSubmit={val => {
val.touched = true;
helpers.setValue(val);
setShowPluginWizard(false);
}}
/>
)}
</FormGroup>
);
}
CredentialPluginField.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
validate: PropTypes.func,
isRequired: PropTypes.bool,
isDisabled: PropTypes.bool,
};
CredentialPluginField.defaultProps = {
validate: () => {},
isRequired: false,
isDisabled: false,
};
export default withI18n()(CredentialPluginField);

View File

@ -0,0 +1,85 @@
import React from 'react';
import { Formik } from 'formik';
import { TextInput } from '@patternfly/react-core';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import CredentialPluginField from './CredentialPluginField';
describe('<CredentialPluginField />', () => {
let wrapper;
describe('No plugin configured', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<Formik
initialValues={{
inputs: {
username: '',
},
}}
>
{() => (
<CredentialPluginField
id="credential-username"
name="inputs.username"
label="Username"
>
<TextInput id="credential-username" />
</CredentialPluginField>
)}
</Formik>
);
});
afterAll(() => {
wrapper.unmount();
});
test('renders the expected content', () => {
expect(wrapper.find('input').length).toBe(1);
expect(wrapper.find('KeyIcon').length).toBe(1);
expect(wrapper.find('CredentialPluginSelected').length).toBe(0);
});
test('clicking plugin button shows plugin prompt', () => {
expect(wrapper.find('CredentialPluginPrompt').length).toBe(0);
wrapper.find('KeyIcon').simulate('click');
expect(wrapper.find('CredentialPluginPrompt').length).toBe(1);
});
});
describe('Plugin already configured', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<Formik
initialValues={{
inputs: {
username: {
credential: {
id: 1,
name: 'CyberArk Cred',
cloud: false,
credential_type_id: 20,
kind: 'conjur',
},
},
},
}}
>
{() => (
<CredentialPluginField
id="credential-username"
name="inputs.username"
label="Username"
>
<TextInput id="credential-username" />
</CredentialPluginField>
)}
</Formik>
);
});
afterAll(() => {
wrapper.unmount();
});
test('renders the expected content', () => {
expect(wrapper.find('CredentialPluginPrompt').length).toBe(0);
expect(wrapper.find('input').length).toBe(0);
expect(wrapper.find('KeyIcon').length).toBe(1);
expect(wrapper.find('CredentialPluginSelected').length).toBe(1);
});
});
});

View File

@ -0,0 +1,69 @@
import React from 'react';
import { func, shape } from 'prop-types';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core';
import CredentialsStep from './CredentialsStep';
import MetadataStep from './MetadataStep';
function CredentialPluginWizard({ i18n, handleSubmit, onClose }) {
const [selectedCredential] = useField('credential');
const steps = [
{
id: 1,
name: i18n._(t`Credential`),
component: <CredentialsStep />,
enableNext: !!selectedCredential.value,
},
{
id: 2,
name: i18n._(t`Metadata`),
component: <MetadataStep />,
canJumpTo: !!selectedCredential.value,
nextButtonText: i18n._(t`OK`),
},
];
return (
<Wizard
isOpen
onClose={onClose}
title={i18n._(t`External Secret Management System`)}
steps={steps}
onSave={handleSubmit}
/>
);
}
function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) {
return (
<Formik
initialValues={{
credential: initialValues?.credential || null,
inputs: initialValues?.inputs || {},
}}
onSubmit={onSubmit}
>
{({ handleSubmit }) => (
<CredentialPluginWizard
handleSubmit={handleSubmit}
i18n={i18n}
onClose={onClose}
/>
)}
</Formik>
);
}
CredentialPluginPrompt.propTypes = {
onClose: func.isRequired,
onSubmit: func.isRequired,
initialValues: shape({}),
};
CredentialPluginPrompt.defaultProps = {
initialValues: {},
};
export default withI18n()(CredentialPluginPrompt);

View File

@ -0,0 +1,228 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../../../testUtils/enzymeHelpers';
import { CredentialsAPI, CredentialTypesAPI } from '../../../../../api';
import selectedCredential from '../../data.cyberArkCredential.json';
import azureVaultCredential from '../../data.azureVaultCredential.json';
import hashiCorpCredential from '../../data.hashiCorpCredential.json';
import CredentialPluginPrompt from './CredentialPluginPrompt';
jest.mock('../../../../../api/models/Credentials');
jest.mock('../../../../../api/models/CredentialTypes');
CredentialsAPI.read.mockResolvedValue({
data: {
count: 3,
results: [selectedCredential, azureVaultCredential, hashiCorpCredential],
},
});
CredentialTypesAPI.readDetail.mockResolvedValue({
data: {
id: 20,
type: 'credential_type',
url: '/api/v2/credential_types/20/',
related: {
named_url:
'/api/v2/credential_types/CyberArk Conjur Secret Lookup+external/',
credentials: '/api/v2/credential_types/20/credentials/',
activity_stream: '/api/v2/credential_types/20/activity_stream/',
},
summary_fields: { user_capabilities: { edit: false, delete: false } },
created: '2020-05-18T21:53:35.398260Z',
modified: '2020-05-18T21:54:05.451444Z',
name: 'CyberArk Conjur Secret Lookup',
description: '',
kind: 'external',
namespace: 'conjur',
managed_by_tower: true,
inputs: {
fields: [
{ id: 'url', label: 'Conjur URL', type: 'string', format: 'url' },
{ id: 'api_key', label: 'API Key', type: 'string', secret: true },
{ id: 'account', label: 'Account', type: 'string' },
{ id: 'username', label: 'Username', type: 'string' },
{
id: 'cacert',
label: 'Public Key Certificate',
type: 'string',
multiline: true,
},
],
metadata: [
{
id: 'secret_path',
label: 'Secret Identifier',
type: 'string',
help_text: 'The identifier for the secret e.g., /some/identifier',
},
{
id: 'secret_version',
label: 'Secret Version',
type: 'string',
help_text:
'Used to specify a specific secret version (if left empty, the latest version will be used).',
},
],
required: ['url', 'api_key', 'account', 'username'],
},
injectors: {},
},
});
describe('<CredentialPluginPrompt />', () => {
describe('Plugin not configured', () => {
let wrapper;
const onClose = jest.fn();
const onSubmit = jest.fn();
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialPluginPrompt onClose={onClose} onSubmit={onSubmit} />
);
});
});
afterAll(() => {
wrapper.unmount();
});
test('should render Wizard with all steps', async () => {
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Credential');
expect(steps[1].name).toEqual('Metadata');
});
test('credentials step renders correctly', () => {
expect(wrapper.find('CredentialsStep').length).toBe(1);
expect(wrapper.find('DataListItem').length).toBe(3);
expect(
wrapper.find('Radio').filterWhere(radio => radio.isChecked).length
).toBe(0);
});
test('next button disabled until credential selected', () => {
expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
true
);
});
test('clicking cancel button calls correct function', () => {
wrapper.find('Button[children="Cancel"]').simulate('click');
expect(onClose).toHaveBeenCalledTimes(1);
});
test('clicking credential row enables next button', async () => {
await act(async () => {
wrapper
.find('Radio')
.at(0)
.invoke('onChange')(true);
});
wrapper.update();
expect(
wrapper
.find('Radio')
.at(0)
.prop('isChecked')
).toBe(true);
expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
false
);
});
test('clicking next button shows metatdata step', async () => {
await act(async () => {
wrapper.find('Button[children="Next"]').simulate('click');
});
wrapper.update();
expect(wrapper.find('MetadataStep').length).toBe(1);
expect(wrapper.find('FormField').length).toBe(2);
});
test('submit button calls correct function with parameters', async () => {
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/foo/bar', name: 'secret_path' },
});
});
await act(async () => {
wrapper.find('input#credential-secret_version').simulate('change', {
target: { value: '9000', name: 'secret_version' },
});
});
await act(async () => {
wrapper.find('Button[children="OK"]').simulate('click');
});
// expect(wrapper.debug()).toBe(false);
// wrapper.find('Button[children="OK"]').simulate('click');
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
credential: selectedCredential,
secret_path: '/foo/bar',
secret_version: '9000',
}),
expect.anything()
);
});
});
describe('Plugin already configured', () => {
let wrapper;
const onClose = jest.fn();
const onSubmit = jest.fn();
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialPluginPrompt
onClose={onClose}
onSubmit={onSubmit}
initialValues={{
credential: selectedCredential,
inputs: {
secret_path: '/foo/bar',
secret_version: '9000',
},
}}
/>
);
});
});
afterAll(() => {
wrapper.unmount();
});
test('should render Wizard with all steps', async () => {
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Credential');
expect(steps[1].name).toEqual('Metadata');
});
test('credentials step renders correctly', () => {
expect(wrapper.find('CredentialsStep').length).toBe(1);
expect(wrapper.find('DataListItem').length).toBe(3);
expect(
wrapper
.find('Radio')
.at(0)
.prop('isChecked')
).toBe(true);
expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
false
);
});
test('metadata step renders correctly', async () => {
await act(async () => {
wrapper.find('Button[children="Next"]').simulate('click');
});
wrapper.update();
expect(wrapper.find('MetadataStep').length).toBe(1);
expect(wrapper.find('FormField').length).toBe(2);
expect(wrapper.find('input#credential-secret_path').prop('value')).toBe(
'/foo/bar'
);
expect(
wrapper.find('input#credential-secret_version').prop('value')
).toBe('9000');
});
});
});

View File

@ -0,0 +1,101 @@
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import { CredentialsAPI } from '../../../../../api';
import CheckboxListItem from '../../../../../components/CheckboxListItem';
import ContentError from '../../../../../components/ContentError';
import DataListToolbar from '../../../../../components/DataListToolbar';
import PaginatedDataList from '../../../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../../../util/qs';
import useRequest from '../../../../../util/useRequest';
const QS_CONFIG = getQSConfig('credential', {
page: 1,
page_size: 5,
order_by: 'name',
credential_type__kind: 'external',
});
function CredentialsStep({ i18n }) {
const [selectedCredential, , selectedCredentialHelper] = useField(
'credential'
);
const history = useHistory();
const {
result: { credentials, count },
error: credentialsError,
isLoading: isCredentialsLoading,
request: fetchCredentials,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await CredentialsAPI.read({
...params,
});
return {
credentials: data.results,
count: data.count,
};
}, [history.location.search]),
{ credentials: [], count: 0 }
);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
if (credentialsError) {
return <ContentError error={credentialsError} />;
}
return (
<PaginatedDataList
contentError={credentialsError}
hasContentLoading={isCredentialsLoading}
itemCount={count}
items={credentials}
onRowClick={row => selectedCredentialHelper.setValue(row)}
qsConfig={QS_CONFIG}
renderItem={credential => (
<CheckboxListItem
isSelected={selectedCredential?.value?.id === credential.id}
itemId={credential.id}
key={credential.id}
name={credential.name}
label={credential.name}
onSelect={() => selectedCredentialHelper.setValue(credential)}
onDeselect={() => selectedCredentialHelper.setValue(null)}
isRadio
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
/>
);
}
export default withI18n()(CredentialsStep);

View File

@ -0,0 +1,157 @@
import React, { useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField, useFormikContext } from 'formik';
import styled from 'styled-components';
import { Button, Form, FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import { CredentialTypesAPI } from '../../../../../api';
import AnsibleSelect from '../../../../../components/AnsibleSelect';
import ContentError from '../../../../../components/ContentError';
import ContentLoading from '../../../../../components/ContentLoading';
import FormField from '../../../../../components/FormField';
import { FormFullWidthLayout } from '../../../../../components/FormLayout';
import useRequest from '../../../../../util/useRequest';
import { required } from '../../../../../util/validators';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
const TestButton = styled(Button)`
margin-top: 20px;
`;
function MetadataStep({ i18n }) {
const form = useFormikContext();
const [selectedCredential] = useField('credential');
const [inputValues] = useField('inputs');
const {
result: fields,
error,
isLoading,
request: fetchMetadataOptions,
} = useRequest(
useCallback(async () => {
const {
data: {
inputs: { required: requiredFields, metadata },
},
} = await CredentialTypesAPI.readDetail(
selectedCredential.value.credential_type ||
selectedCredential.value.credential_type_id
);
metadata.forEach(field => {
if (inputValues.value[field.id]) {
form.initialValues.inputs[field.id] = inputValues.value[field.id];
} else if (field.type === 'string' && field.choices) {
form.initialValues.inputs[field.id] =
field.default || field.choices[0];
} else {
form.initialValues.inputs[field.id] = '';
}
if (requiredFields && requiredFields.includes(field.id)) {
field.required = true;
}
});
return metadata;
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []),
[]
);
useEffect(() => {
fetchMetadataOptions();
}, [fetchMetadataOptions]);
const testMetadata = () => {
// https://github.com/ansible/awx/issues/7126
};
if (isLoading) {
return <ContentLoading />;
}
if (error) {
return <ContentError error={error} />;
}
return (
<>
{fields.length > 0 && (
<Form>
<FormFullWidthLayout>
{fields.map(field => {
if (field.type === 'string') {
if (field.choices) {
return (
<FormGroup
key={field.id}
fieldId={`credential-${field.id}`}
label={field.label}
isRequired={field.required}
>
{field.help_text && (
<Tooltip content={field.help_text} position="right">
<QuestionCircleIcon />
</Tooltip>
)}
<AnsibleSelect
name={`inputs.${field.id}`}
value={form.values.inputs[field.id]}
id={`credential-${field.id}`}
data={field.choices.map(choice => {
return {
value: choice,
key: choice,
label: choice,
};
})}
onChange={(event, value) => {
form.setFieldValue(`inputs.${field.id}`, value);
}}
validate={field.required ? required(null, i18n) : null}
/>
</FormGroup>
);
}
return (
<FormField
key={field.id}
id={`credential-${field.id}`}
label={field.label}
tooltip={field.help_text}
name={`inputs.${field.id}`}
type={field.multiline ? 'textarea' : 'text'}
isRequired={field.required}
validate={field.required ? required(null, i18n) : null}
/>
);
}
return null;
})}
</FormFullWidthLayout>
</Form>
)}
<Tooltip
content={i18n._(
t`Click this button to verify connection to the secret management system using the selected credential and specified inputs.`
)}
position="right"
>
<TestButton
variant="primary"
type="submit"
onClick={() => testMetadata()}
>
{i18n._(t`Test`)}
</TestButton>
</Tooltip>
</>
);
}
export default withI18n()(MetadataStep);

View File

@ -0,0 +1,3 @@
export { default as CredentialPluginPrompt } from './CredentialPluginPrompt';
export { default as CredentialsStep } from './CredentialsStep';
export { default as MetadataStep } from './MetadataStep';

View File

@ -0,0 +1,70 @@
import React from 'react';
import { func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import styled from 'styled-components';
import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core';
import { KeyIcon } from '@patternfly/react-icons';
import CredentialChip from '../../../../components/CredentialChip';
import { Credential } from '../../../../types';
const SelectedCredential = styled.div`
display: flex;
justify-content: space-between;
background-color: white;
border-bottom-color: var(--pf-global--BorderColor--200);
`;
const SpacedCredentialChip = styled(CredentialChip)`
margin: 5px 8px;
`;
const PluginHelpText = styled.p`
margin-top: 5px;
`;
function CredentialPluginSelected({
i18n,
credential,
onEditPlugin,
onClearPlugin,
}) {
return (
<>
<SelectedCredential>
<SpacedCredentialChip onClick={onClearPlugin} credential={credential} />
<Tooltip
content={i18n._(t`Edit Credential Plugin Configuration`)}
position="top"
>
<Button
aria-label={i18n._(t`Edit Credential Plugin Configuration`)}
onClick={onEditPlugin}
variant={ButtonVariant.control}
>
<KeyIcon />
</Button>
</Tooltip>
</SelectedCredential>
<PluginHelpText>
<Trans>
This field will be retrieved from an external secret management system
using the specified credential.
</Trans>
</PluginHelpText>
</>
);
}
CredentialPluginSelected.propTypes = {
credential: Credential.isRequired,
onEditPlugin: func,
onClearPlugin: func,
};
CredentialPluginSelected.defaultProps = {
onEditPlugin: () => {},
onClearPlugin: () => {},
};
export default withI18n()(CredentialPluginSelected);

View File

@ -0,0 +1,34 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import selectedCredential from '../data.cyberArkCredential.json';
import CredentialPluginSelected from './CredentialPluginSelected';
describe('<CredentialPluginSelected />', () => {
let wrapper;
const onClearPlugin = jest.fn();
const onEditPlugin = jest.fn();
beforeAll(() => {
wrapper = mountWithContexts(
<CredentialPluginSelected
credential={selectedCredential}
onClearPlugin={onClearPlugin}
onEditPlugin={onEditPlugin}
/>
);
});
afterAll(() => {
wrapper.unmount();
});
test('renders the expected content', () => {
expect(wrapper.find('CredentialChip').length).toBe(1);
expect(wrapper.find('KeyIcon').length).toBe(1);
});
test('clearing plugin calls expected function', () => {
wrapper.find('CredentialChip button').simulate('click');
expect(onClearPlugin).toBeCalledTimes(1);
});
test('editing plugin calls expected function', () => {
wrapper.find('KeyIcon').simulate('click');
expect(onEditPlugin).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,2 @@
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
export { default as CredentialPluginField } from './CredentialPluginField';

View File

@ -2,13 +2,18 @@ import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import { FileUpload, FormGroup } from '@patternfly/react-core';
import FormField from '../../../../components/FormField';
import {
FileUpload,
FormGroup,
TextArea,
TextInput,
} from '@patternfly/react-core';
import {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../../components/FormLayout';
import { required } from '../../../../util/validators';
import { CredentialPluginField } from '../CredentialPlugins';
const GoogleComputeEngineSubForm = ({ i18n }) => {
const [fileError, setFileError] = useState(null);
@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => {
}}
/>
</FormGroup>
<FormField
<CredentialPluginField
id="credential-username"
label={i18n._(t`Service account email address`)}
name="inputs.username"
type="email"
validate={required(null, i18n)}
isRequired
/>
<FormField
>
<TextInput id="credential-username" />
</CredentialPluginField>
<CredentialPluginField
id="credential-project"
label={i18n._(t`Project`)}
name="inputs.project"
type="text"
/>
>
<TextInput id="credential-project" />
</CredentialPluginField>
<FormFullWidthLayout>
<FormField
<CredentialPluginField
id="credential-sshKeyData"
label={i18n._(t`RSA private key`)}
name="inputs.ssh_key_data"
type="textarea"
rows={6}
validate={required(null, i18n)}
isRequired
/>
>
<TextArea
id="credential-sshKeyData"
rows={6}
resizeOrientation="vertical"
/>
</CredentialPluginField>
</FormFullWidthLayout>
</FormColumnLayout>
);

View File

@ -1,39 +1,50 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import FormField, { PasswordField } from '../../../../components/FormField';
import { TextArea, TextInput } from '@patternfly/react-core';
import { CredentialPluginField } from '../CredentialPlugins';
import { PasswordInput } from '../../../../components/FormField';
export const UsernameFormField = withI18n()(({ i18n }) => (
<FormField
id="credentual-username"
<CredentialPluginField
id="credential-username"
label={i18n._(t`Username`)}
name="inputs.username"
type="text"
/>
>
<TextInput id="credential-username" />
</CredentialPluginField>
));
export const PasswordFormField = withI18n()(({ i18n }) => (
<PasswordField
<CredentialPluginField
id="credential-password"
label={i18n._(t`Password`)}
name="inputs.password"
/>
>
<PasswordInput id="credential-password" />
</CredentialPluginField>
));
export const SSHKeyDataField = withI18n()(({ i18n }) => (
<FormField
<CredentialPluginField
id="credential-sshKeyData"
label={i18n._(t`SSH Private Key`)}
name="inputs.ssh_key_data"
type="textarea"
rows={6}
/>
>
<TextArea
id="credential-sshKeyData"
rows={6}
resizeOrientation="vertical"
/>
</CredentialPluginField>
));
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
<PasswordField
<CredentialPluginField
id="credential-sshKeyUnlock"
label={i18n._(t`Private Key Passphrase`)}
name="inputs.ssh_key_unlock"
/>
>
<PasswordInput id="credential-password" />
</CredentialPluginField>
));

View File

@ -0,0 +1,85 @@
{
"id": 12,
"type": "credential",
"url": "/api/v2/credentials/12/",
"related": {
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"activity_stream": "/api/v2/credentials/12/activity_stream/",
"access_list": "/api/v2/credentials/12/access_list/",
"object_roles": "/api/v2/credentials/12/object_roles/",
"owner_users": "/api/v2/credentials/12/owner_users/",
"owner_teams": "/api/v2/credentials/12/owner_teams/",
"copy": "/api/v2/credentials/12/copy/",
"input_sources": "/api/v2/credentials/12/input_sources/",
"credential_type": "/api/v2/credential_types/19/",
"user": "/api/v2/users/1/"
},
"summary_fields": {
"credential_type": {
"id": 19,
"name": "Microsoft Azure Key Vault",
"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": 60
},
"use_role": {
"description": "Can use the credential in a job template",
"name": "Use",
"id": 61
},
"read_role": {
"description": "May view settings for the credential",
"name": "Read",
"id": 62
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"copy": true,
"use": true
},
"owners": [
{
"id": 1,
"type": "user",
"name": "admin",
"description": " ",
"url": "/api/v2/users/1/"
}
]
},
"created": "2020-05-26T14:54:45.612847Z",
"modified": "2020-05-26T14:54:45.612861Z",
"name": "Microsoft Azure Key Vault",
"description": "",
"organization": null,
"credential_type": 19,
"inputs": {
"url": "https://localhost",
"client": "foo",
"secret": "$encrypted$",
"tenant": "9000",
"cloud_name": "AzureCloud"
},
"kind": "azure_kv",
"cloud": false,
"kubernetes": false
}

View File

@ -0,0 +1,85 @@
{
"id": 1,
"type": "credential",
"url": "/api/v2/credentials/1/",
"related": {
"named_url": "/api/v2/credentials/CyberArk Conjur Secret Lookup++CyberArk Conjur Secret Lookup+external++/",
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/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/20/",
"user": "/api/v2/users/1/"
},
"summary_fields": {
"credential_type": {
"id": 20,
"name": "CyberArk Conjur Secret Lookup",
"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": 27
},
"use_role": {
"description": "Can use the credential in a job template",
"name": "Use",
"id": 28
},
"read_role": {
"description": "May view settings for the credential",
"name": "Read",
"id": 29
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"copy": true,
"use": true
},
"owners": [
{
"id": 1,
"type": "user",
"name": "admin",
"description": " ",
"url": "/api/v2/users/1/"
}
]
},
"created": "2020-05-19T12:51:36.956029Z",
"modified": "2020-05-19T12:51:36.956086Z",
"name": "CyberArk Conjur Secret Lookup",
"description": "",
"organization": null,
"credential_type": 20,
"inputs": {
"url": "https://localhost",
"account": "adsf",
"api_key": "$encrypted$",
"username": "adsf"
},
"kind": "conjur",
"cloud": false,
"kubernetes": false
}

View File

@ -0,0 +1,82 @@
{
"id": 11,
"type": "credential",
"url": "/api/v2/credentials/11/",
"related": {
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"activity_stream": "/api/v2/credentials/11/activity_stream/",
"access_list": "/api/v2/credentials/11/access_list/",
"object_roles": "/api/v2/credentials/11/object_roles/",
"owner_users": "/api/v2/credentials/11/owner_users/",
"owner_teams": "/api/v2/credentials/11/owner_teams/",
"copy": "/api/v2/credentials/11/copy/",
"input_sources": "/api/v2/credentials/11/input_sources/",
"credential_type": "/api/v2/credential_types/21/",
"user": "/api/v2/users/1/"
},
"summary_fields": {
"credential_type": {
"id": 21,
"name": "HashiCorp Vault Secret Lookup",
"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": 57
},
"use_role": {
"description": "Can use the credential in a job template",
"name": "Use",
"id": 58
},
"read_role": {
"description": "May view settings for the credential",
"name": "Read",
"id": 59
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"copy": true,
"use": true
},
"owners": [
{
"id": 1,
"type": "user",
"name": "admin",
"description": " ",
"url": "/api/v2/users/1/"
}
]
},
"created": "2020-05-26T14:54:00.674404Z",
"modified": "2020-05-26T14:54:00.674418Z",
"name": "HashiCorp Vault Secret Lookup",
"description": "",
"organization": null,
"credential_type": 21,
"inputs": {
"url": "https://localhost",
"api_version": "v1"
},
"kind": "hashivault_kv",
"cloud": false,
"kubernetes": false
}