Icu 5651 create spk type (#1266)

Add functionality for creating a ssh private key credential and add
corresponding acceptance tests.

✅ Closes: ICU-5651

Co-authored-by: Cameron Perera <cameron.perera@hashicorp.com>
Co-authored-by: Randall Morey <randall@randallmorey.com>
Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
This commit is contained in:
authored and GitHub committed 2022-08-24 15:50:48 -07:00
1 parent 1ee83dd1e6
commit fb981c02ea
9 files changed
+423 -129

No files matched your search

+10 -4
View File
@@ -26,15 +26,19 @@ export default class CredentialSerializer extends ApplicationSerializer {
serializeUsernamePassword() {
const serialized = super.serialize(...arguments);
// Remove non-username_password type attributes
delete serialized['attributes']['private_key'];
delete serialized['attributes']['passphrase'];
delete serialized.attributes.private_key;
delete serialized.attributes.passphrase;
return serialized;
}
serializeSSHPrivateKey() {
const serialized = super.serialize(...arguments);
// Remove non-ssh_private_key type attributes
delete serialized['attributes']['password'];
delete serialized.attributes.password;
if (!serialized?.attributes?.passphrase)
delete serialized.attributes.passphrase;
if (!serialized?.attributes?.private_key)
delete serialized.attributes.private_key;
return serialized;
}
@@ -42,7 +46,9 @@ export default class CredentialSerializer extends ApplicationSerializer {
const normalizedHash = copy(hash, true);
const normalized = super.normalize(typeClass, normalizedHash, ...rest);
// Remove passphrase as we don't track it after being created/updated
delete normalized['data']['attributes']['passphrase'];
normalized.data.attributes.passphrase = '';
normalized.data.attributes.password = '';
normalized.data.attributes.private_key = '';
return normalized;
}
}
@@ -86,6 +86,9 @@ module('Unit | Serializer | credential', function (hooks) {
type: 'username_password',
username: 'username',
version: 1,
passphrase: '',
private_key: '',
password: '',
},
type: 'credential',
id: 'credup_123',
@@ -117,6 +120,9 @@ module('Unit | Serializer | credential', function (hooks) {
type: 'ssh_private_key',
username: 'username',
version: 1,
passphrase: '',
private_key: '',
password: '',
},
type: 'credential',
id: 'credspk_123',
@@ -396,6 +396,20 @@ credential:
ssh_private_key: Username & Key Pair
help:
username_password: This host requires a username and password to connect.
ssh_private_key: This host requires a username, public key, and private key to connect.
form:
username:
label: Username
help: The username to use when connecting with this credential store.
password:
label: Password
help: The password to use when signing in with this credential store.
private_key:
label: SSH Private Key
help: The private key to use when connecting with this credential store.
passphrase:
label: Pass phrase
help: Optional.
scope:
title: Scope
title_plural: Scopes
@@ -1,118 +1,9 @@
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
<form.input
@name='name'
@type='name'
@value={{@model.name}}
@label={{t 'form.name.label'}}
@error={{@model.errors.name}}
@helperText={{t 'form.name.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.name}}
<field.errors as |errors|>
{{#each @model.errors.name as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
<form.textarea
@name='description'
@type='description'
@value={{@model.description}}
@label={{t 'form.description.label'}}
@error={{@model.errors.description}}
@helperText={{t 'form.description.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.description}}
<field.errors as |errors|>
{{#each @model.errors.description as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
<form.fieldset>
<:title>{{t 'form.type.label'}}</:title>
<:body>
<form.radioGroup
@name='types'
@selectedValue='username_password'
@changed={{@changeType}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.credential.types.username_password'}}
@helperText={{t 'resources.credential.help.username_password'}}
@value='username_password'
@layout='wide'
readonly={{true}}
@disabled={{true}}
/>
</form.radioGroup>
</:body>
</form.fieldset>
<form.input
@name='username'
@type='username'
@value={{@model.username}}
@label={{t 'form.username.label'}}
@error={{@model.errors.username}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.username}}
<field.errors as |errors|>
{{#each @model.errors.username as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
{{#if (or @model.isNew form.isEditable)}}
<form.input
@name='password'
@type='password'
@value={{@model.password}}
@label={{t 'form.password.label'}}
@error={{@model.errors.password}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.password}}
<field.errors as |errors|>
{{#each @model.errors.password as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
{{/if}}
{{#if (can 'save model' @model)}}
<form.actions
@disabled={{if @model.cannotSave @model.cannotSave}}
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
{{#if @model.type}}
{{component
(concat 'form/credential/' @model.type)
model=@model
submit=@submit
cancel=@cancel
changeType=@changeType
}}
{{/if}}
@@ -0,0 +1,154 @@
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
<form.input
@name='name'
@value={{@model.name}}
@label={{t 'form.name.label'}}
@error={{@model.errors.name}}
@helperText={{t 'form.name.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.name}}
<field.errors as |errors|>
{{#each @model.errors.name as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
<form.textarea
@name='description'
@value={{@model.description}}
@label={{t 'form.description.label'}}
@error={{@model.errors.description}}
@helperText={{t 'form.description.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.description}}
<field.errors as |errors|>
{{#each @model.errors.description as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
<form.fieldset>
<:title>{{t 'form.type.label'}}</:title>
<:body>
{{#if @model.isNew}}
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
@changed={{@changeType}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.credential.types.username_password'}}
@helperText={{t 'resources.credential.help.username_password'}}
@value='username_password'
@layout='wide'
/>
<radioGroup.card
@label={{t 'resources.credential.types.ssh_private_key'}}
@helperText={{t 'resources.credential.help.ssh_private_key'}}
@value='ssh_private_key'
@layout='wide'
/>
</form.radioGroup>
{{else}}
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
as |radioGroup|
>
<radioGroup.card
@label={{t (concat 'resources.credential.types.' @model.type)}}
@helperText={{t (concat 'resources.credential.help.' @model.type)}}
@value={{@model.type}}
@layout='wide'
@disabled={{true}}
readonly={{true}}
/>
</form.radioGroup>
{{/if}}
</:body>
</form.fieldset>
<form.input
@name='username'
@value={{@model.username}}
@label={{t 'resources.credential.form.username.label'}}
@helperText={{t 'resources.credential.form.username.help'}}
@error={{@model.errors.username}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.username}}
<field.errors as |errors|>
{{#each @model.errors.username as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
{{#if (or @model.isNew form.isEditable)}}
<form.textarea
@name='private_key'
@value={{@model.private_key}}
@label={{t 'resources.credential.form.private_key.label'}}
@helperText={{t 'resources.credential.form.private_key.help'}}
@error={{@model.errors.private_key}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.private_key}}
<field.errors as |errors|>
{{#each @model.errors.private_key as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
<form.textarea
@name='passphrase'
@value={{@model.passphrase}}
@label={{t 'resources.credential.form.passphrase.label'}}
@helperText={{t 'resources.credential.form.passphrase.help'}}
@error={{@model.errors.passphrase}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.passphrase}}
<field.errors as |errors|>
{{#each @model.errors.passphrase as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
{{/if}}
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -0,0 +1,136 @@
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
<form.input
@name='name'
@value={{@model.name}}
@label={{t 'form.name.label'}}
@error={{@model.errors.name}}
@helperText={{t 'form.name.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.name}}
<field.errors as |errors|>
{{#each @model.errors.name as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
<form.textarea
@name='description'
@value={{@model.description}}
@label={{t 'form.description.label'}}
@error={{@model.errors.description}}
@helperText={{t 'form.description.help'}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.description}}
<field.errors as |errors|>
{{#each @model.errors.description as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.textarea>
<form.fieldset>
<:title>{{t 'form.type.label'}}</:title>
<:body>
{{#if @model.isNew}}
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
@changed={{@changeType}}
as |radioGroup|
>
<radioGroup.card
@label={{t 'resources.credential.types.username_password'}}
@helperText={{t 'resources.credential.help.username_password'}}
@value='username_password'
@layout='wide'
/>
<radioGroup.card
@label={{t 'resources.credential.types.ssh_private_key'}}
@helperText={{t 'resources.credential.help.ssh_private_key'}}
@value='ssh_private_key'
@layout='wide'
/>
</form.radioGroup>
{{else}}
<form.radioGroup
@name='types'
@selectedValue={{@model.type}}
as |radioGroup|
>
<radioGroup.card
@label={{t (concat 'resources.credential.types.' @model.type)}}
@helperText={{t (concat 'resources.credential.help.' @model.type)}}
@value={{@model.type}}
@layout='wide'
@disabled={{true}}
readonly={{true}}
/>
</form.radioGroup>
{{/if}}
</:body>
</form.fieldset>
<form.input
@name='username'
@value={{@model.username}}
@label={{t 'resources.credential.form.username.label'}}
@helperText={{t 'resources.credential.form.username.help'}}
@error={{@model.errors.username}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.username}}
<field.errors as |errors|>
{{#each @model.errors.username as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
{{#if (or @model.isNew form.isEditable)}}
<form.input
@name='password'
@value={{@model.password}}
@label={{t 'resources.credential.form.password.label'}}
@helperText={{t 'resources.credential.form.password.help'}}
@error={{@model.errors.password}}
disabled={{@model.isSaving}}
readonly={{false}}
as |field|
>
{{#if @model.errors.password}}
<field.errors as |errors|>
{{#each @model.errors.password as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</field.errors>
{{/if}}
</form.input>
{{/if}}
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -58,9 +58,6 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialsRoute
isNew ? 'notifications.create-success' : 'notifications.save-success'
)
async save(credential) {
//hardcoding the type to its subtype
//as the api expects the subtype `username_password`
credential.type = 'username_password';
await credential.save();
if (this.can.can('read model', credential)) {
await this.router.transitionTo(
@@ -19,17 +19,27 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialsNewRou
/**
* Creates a new unsaved credential in current credential store.
* Also rollback/destroy any new, unsaved instances from this route before
* creating another, but reuse name/description if available.
* @return {CredentialModel}
*/
model({ type = 'username_password' }) {
const { id: credential_store_id } = this.modelFor(
'scopes.scope.credential-stores.credential-store'
);
let name, description;
if (this.currentModel?.isNew) {
({ name, description } = this.currentModel);
this.currentModel.rollbackAttributes();
}
return this.store.createRecord('credential', {
type,
credential_store_id,
name,
description,
});
}
/**
* Update type of credential
* @param {string} type
@@ -12,12 +12,15 @@ module(
setupMirage(hooks);
let getCredentialsCount;
let getUsernamePasswordCredentialCount;
let getUsernameKeyPairCredentialCount;
const instances = {
scopes: {
org: null,
project: null,
},
staticCredentialStore: null,
};
const urls = {
@@ -52,19 +55,50 @@ module(
getCredentialsCount = () => {
return this.server.schema.credentials.all().models.length;
};
getUsernamePasswordCredentialCount = () => {
return this.server.schema.credentials.where({
type: 'username_password',
}).length;
};
getUsernameKeyPairCredentialCount = () => {
return this.server.schema.credentials.where({ type: 'ssh_private_key' })
.length;
};
authenticateSession({});
});
test('Users can create a new credential', async function (assert) {
assert.expect(1);
test('Users can create a new username & password credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
const usernamePasswordCredentialCount =
getUsernamePasswordCredentialCount();
await visit(urls.newCredential);
await fillIn('[name="name"]', 'random string');
await click('[type="submit"]');
assert.strictEqual(getCredentialsCount(), credentialsCount + 1);
assert.strictEqual(
getUsernamePasswordCredentialCount(),
usernamePasswordCredentialCount + 1
);
});
test('Users can cancel create new credential', async function (assert) {
test('Users can create a new username & key pair credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
const usernameKeyPairCredentialCount =
getUsernameKeyPairCredentialCount();
await visit(urls.newCredential);
await fillIn('[name="name"]', 'random string');
await click('[value="ssh_private_key"]');
await click('[type="submit"]');
assert.strictEqual(getCredentialsCount(), credentialsCount + 1);
assert.strictEqual(
getUsernameKeyPairCredentialCount(),
usernameKeyPairCredentialCount + 1
);
});
test('Users can cancel create new username & password credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
await visit(urls.newCredential);
@@ -74,6 +108,17 @@ module(
assert.strictEqual(getCredentialsCount(), credentialsCount);
});
test('Users can cancel create new username & key pair credential', async function (assert) {
assert.expect(2);
const credentialsCount = getCredentialsCount();
await visit(urls.newCredential);
await fillIn('[name="name"]', 'random string');
await click('[value="ssh_private_key"]');
await click('.rose-form-actions [type="button"]');
assert.strictEqual(currentURL(), urls.credentials);
assert.strictEqual(getCredentialsCount(), credentialsCount);
});
test('Users cannot navigate to new credential route without proper authorization', async function (assert) {
assert.expect(2);
instances.staticCredentialStore.authorized_collection_actions.credentials =
@@ -87,7 +132,7 @@ module(
assert.notOk(find(`[href="${urls.newCredential}"]`));
});
test('saving a new credential with invalid fields displays error messages', async function (assert) {
test('saving a new username & password credential with invalid fields displays error messages', async function (assert) {
assert.expect(2);
this.server.post('/credentials', () => {
return new Response(
@@ -120,5 +165,40 @@ module(
'Field required for creating a username-password credential.'
);
});
test('saving a new username & key pair credential with invalid fields displays error messages', async function (assert) {
assert.expect(2);
this.server.post('/credentials', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'Error in provided request.',
details: {
request_fields: [
{
name: 'attributes.private_key',
description:
'Field required for creating a username-key-pair credential.',
},
],
},
}
);
});
await visit(urls.newCredential);
await click('[value="ssh_private_key"]');
await click('[type="submit"]');
assert.ok(
find('[role="alert"]').textContent.trim(),
'Error in provided request.'
);
assert.ok(
find('.rose-form-error-message').textContent.trim(),
'Field required for creating a username-key-pair credential.'
);
});
}
);