From f0530e1f8f8d36441d210d3608a9c761d8aaf086 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Wed, 1 Feb 2023 13:46:23 -0500 Subject: [PATCH] Vault SSH Cert Feature (#1596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 Update models for vault ssh cert feature (#1591) ✅ Closes: ICU-7546 * feat: 🎸 Setup types in cred library forms and use HDS component (#1597) * feat: 🎸 Setup types in cred library forms and use HDS component ✅ Closes: ICU-7673 * feat: 🎸 Add vault ssh cert type for credential library (#1608) * feat: 🎸 Add vault ssh cert type for credential library ✅ Closes: ICU-7678 * feat: 🎸 Enable feature flag for vault ssh cert work (#1614) * fix: 🐛 Change fields to optional as they get default by backend (#1615) * Chore/cleanup vault ssh cert (#1616) --- .../api/addon/abilities/credential-library.js | 4 +- .../generated/models/credential-library.js | 46 +++ addons/api/addon/models/credential-library.js | 33 +- .../addon/serializers/credential-library.js | 5 + .../api/addon/transforms/object-as-array.js | 22 ++ addons/api/app/transforms/object-as-array.js | 1 + .../mirage/factories/credential-library.js | 18 +- .../unit/models/credential-library-test.js | 5 +- .../serializers/credential-library-test.js | 99 +++++- .../unit/transforms/object-as-array-test.js | 52 ++++ addons/core/translations/en-us.yaml | 1 + addons/core/translations/resources/en-us.yaml | 28 +- addons/rose/package.json | 2 +- ui/admin/app/abilities/credential-library.js | 20 ++ .../form/credential-library/index.hbs | 139 +-------- .../vault-generic/index.hbs | 169 ++++++++++ .../{ => vault-generic}/index.js | 15 +- .../vault-ssh-certificate/index.hbs | 292 ++++++++++++++++++ .../vault-ssh-certificate/index.js | 55 ++++ .../form/field/key-value-list/index.hbs | 98 ++++++ .../form/field/key-value-list/index.js | 42 +++ .../credential-libraries/new.js | 1 + .../credential-library.js | 18 ++ .../credential-libraries/new.js | 32 +- ui/admin/app/styles/app.scss | 44 +++ .../credential-library/index.hbs | 1 + .../credential-libraries/new.hbs | 1 + ui/admin/config/environment.js | 7 +- .../credential-library/create-test.js | 93 +++++- .../credential-library/read-test.js | 18 ++ .../credential-library/update-test.js | 69 ++++- .../form/field/key-value-list-test.js | 44 +++ .../unit/abilities/credential-library-test.js | 73 +++++ yarn.lock | 56 ++-- 34 files changed, 1421 insertions(+), 182 deletions(-) create mode 100644 addons/api/addon/transforms/object-as-array.js create mode 100644 addons/api/app/transforms/object-as-array.js create mode 100644 addons/api/tests/unit/transforms/object-as-array-test.js create mode 100644 ui/admin/app/abilities/credential-library.js create mode 100644 ui/admin/app/components/form/credential-library/vault-generic/index.hbs rename ui/admin/app/components/form/credential-library/{ => vault-generic}/index.js (57%) create mode 100644 ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs create mode 100644 ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.js create mode 100644 ui/admin/app/components/form/field/key-value-list/index.hbs create mode 100644 ui/admin/app/components/form/field/key-value-list/index.js create mode 100644 ui/admin/tests/integration/components/form/field/key-value-list-test.js create mode 100644 ui/admin/tests/unit/abilities/credential-library-test.js diff --git a/addons/api/addon/abilities/credential-library.js b/addons/api/addon/abilities/credential-library.js index 5968a80f0..f6ede826b 100644 --- a/addons/api/addon/abilities/credential-library.js +++ b/addons/api/addon/abilities/credential-library.js @@ -2,11 +2,11 @@ import ModelAbility from './model'; /** * Provides abilities for credential. */ -export default class CredentialLibaryAbility extends ModelAbility { +export default class CredentialLibraryAbility extends ModelAbility { // =permissions /** - * Only "known" credential libary types may be read. + * Only "known" credential library types may be read. * @type {boolean} */ get canRead() { diff --git a/addons/api/addon/generated/models/credential-library.js b/addons/api/addon/generated/models/credential-library.js index 75723f756..80febf8dc 100644 --- a/addons/api/addon/generated/models/credential-library.js +++ b/addons/api/addon/generated/models/credential-library.js @@ -67,4 +67,50 @@ export default class GeneratedCredentialLibraryModel extends BaseModel { description: 'The path in Vault to request credentials from.', }) path; + + @attr('string', { + isNestedAttribute: true, + description: 'The username to use when making an SSH connection.', + }) + username; + + @attr('string', { + isNestedAttribute: true, + description: 'The desired key type to use when generating a private key.', + }) + key_type; + + @attr('number', { + isNestedAttribute: true, + description: + 'Specifies the number of bits used when generating the private key. Not used if key type is ed25519', + }) + key_bits; + + @attr('string', { + isNestedAttribute: true, + description: 'Specifies the requested time to live for the certificate.', + }) + ttl; + + @attr('string', { + isNestedAttribute: true, + description: + 'Specifies the key id that the created certificate should have.', + }) + key_id; + + @attr('object-as-array', { + isNestedAttribute: true, + description: + 'Specifies a map of the critical options that the certificate should be signed for.', + }) + critical_options; + + @attr('object-as-array', { + isNestedAttribute: true, + description: + 'Specifies a map of the extensions that the certificate should be signed for.', + }) + extensions; } diff --git a/addons/api/addon/models/credential-library.js b/addons/api/addon/models/credential-library.js index f864abcbb..7f64f5420 100644 --- a/addons/api/addon/models/credential-library.js +++ b/addons/api/addon/models/credential-library.js @@ -5,12 +5,20 @@ import GeneratedCredentialLibraryModel from '../generated/models/credential-libr */ export const options = { http_method: ['GET', 'POST'], + key_type: ['ed25519', 'ecdsa', 'rsa'], }; +export const TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC = 'vault-generic'; +export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE = + 'vault-ssh-certificate'; + /** * Supported Credential Library types. */ -export const types = ['vault']; +export const TYPES_CREDENTIAL_LIBRARY = Object.freeze([ + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, +]); export default class CredentialLibraryModel extends GeneratedCredentialLibraryModel { // =attributes @@ -20,7 +28,7 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo * @type {boolean} */ get isUnknown() { - return !types.includes(this.type); + return !TYPES_CREDENTIAL_LIBRARY.includes(this.type); } /** @@ -28,6 +36,25 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo * @type {boolean} */ get isVault() { - return this.type === 'vault'; + return ( + this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC || + this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE + ); + } + + /** + * True if credential is a generic vault type. + * @type {boolean} + */ + get isVaultGeneric() { + return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC; + } + + /** + * True if credential is a vault ssh cert type. + * @type {boolean} + */ + get isVaultSSHCertificate() { + return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE; } } diff --git a/addons/api/addon/serializers/credential-library.js b/addons/api/addon/serializers/credential-library.js index bd8f44fff..4ecec3867 100644 --- a/addons/api/addon/serializers/credential-library.js +++ b/addons/api/addon/serializers/credential-library.js @@ -1,4 +1,5 @@ import ApplicationSerializer from './application'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from '../models/credential-library'; export default class CredentialLibrarySerializer extends ApplicationSerializer { // =properties @@ -16,6 +17,10 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer { serialize() { const serialized = super.serialize(...arguments); if (serialized.attributes) { + if (serialized.type !== TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC) { + delete serialized.attributes.http_method; + } + // Serialize `http_request_body` only if `http_method` is POST if (!serialized.attributes?.http_method?.match(/post/i)) delete serialized.attributes.http_request_body; diff --git a/addons/api/addon/transforms/object-as-array.js b/addons/api/addon/transforms/object-as-array.js new file mode 100644 index 000000000..620c58bf6 --- /dev/null +++ b/addons/api/addon/transforms/object-as-array.js @@ -0,0 +1,22 @@ +import Transform from '@ember-data/serializer/transform'; + +export default class ObjectAsArrayTransform extends Transform { + deserialize(serialized) { + const obj = serialized || {}; + return Object.entries(obj).map(([key, value]) => ({ key, value })); + } + serialize(deserialized) { + const array = deserialized || []; + + if (!array.length) { + return null; + } + + return array.reduce((result, currentValue) => { + const { key, value } = currentValue; + result[key] = value; + + return result; + }, {}); + } +} diff --git a/addons/api/app/transforms/object-as-array.js b/addons/api/app/transforms/object-as-array.js new file mode 100644 index 000000000..5f30f8111 --- /dev/null +++ b/addons/api/app/transforms/object-as-array.js @@ -0,0 +1 @@ +export { default } from 'api/transforms/object-as-array'; diff --git a/addons/api/mirage/factories/credential-library.js b/addons/api/mirage/factories/credential-library.js index 5808adcad..d50213679 100644 --- a/addons/api/mirage/factories/credential-library.js +++ b/addons/api/mirage/factories/credential-library.js @@ -2,12 +2,15 @@ import factory from '../generated/factories/credential-library'; import { faker } from '@faker-js/faker'; import permissions from '../helpers/permissions'; import generateId from '../helpers/id'; - -const types = ['vault']; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + TYPES_CREDENTIAL_LIBRARY, +} from 'api/models/credential-library'; export default factory.extend({ id: () => generateId('cl_'), - type: (i) => types[i % types.length], + type: (i) => TYPES_CREDENTIAL_LIBRARY[i % TYPES_CREDENTIAL_LIBRARY.length], authorized_actions: () => permissions.authorizedActionsFor('credential-library') || [ @@ -19,12 +22,19 @@ export default factory.extend({ attributes() { switch (this.type) { - case 'vault': + case TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC: return { http_method: 'GET', http_request_body: faker.random.word(), path: faker.system.directoryPath(), }; + case TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE: + return { + username: faker.random.word(), + key_type: faker.random.word(), + key_bits: faker.datatype.number(999), + path: faker.system.directoryPath(), + }; } }, }); diff --git a/addons/api/tests/unit/models/credential-library-test.js b/addons/api/tests/unit/models/credential-library-test.js index 5016b902b..88174f0ed 100644 --- a/addons/api/tests/unit/models/credential-library-test.js +++ b/addons/api/tests/unit/models/credential-library-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from 'api/models/credential-library'; module('Unit | Model | credential library', function (hooks) { setupTest(hooks); @@ -13,7 +14,9 @@ module('Unit | Model | credential library', function (hooks) { test('it has isUnknown property and returns the expected values', async function (assert) { assert.expect(2); const store = this.owner.lookup('service:store'); - const modelA = store.createRecord('credential-library', { type: 'vault' }); + const modelA = store.createRecord('credential-library', { + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + }); const modelB = store.createRecord('credential-library', { type: 'unknown', }); diff --git a/addons/api/tests/unit/serializers/credential-library-test.js b/addons/api/tests/unit/serializers/credential-library-test.js index 7a5613783..249ae8f89 100644 --- a/addons/api/tests/unit/serializers/credential-library-test.js +++ b/addons/api/tests/unit/serializers/credential-library-test.js @@ -1,5 +1,9 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, +} from 'api/models/credential-library'; module('Unit | Serializer | credential library', function (hooks) { setupTest(hooks); @@ -9,7 +13,7 @@ module('Unit | Serializer | credential library', function (hooks) { const store = this.owner.lookup('service:store'); const serializer = store.serializerFor('credential-library'); const record = store.createRecord('credential-library', { - type: 'vault', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, name: 'Name', description: 'Description', path: '/vault/path', @@ -19,7 +23,7 @@ module('Unit | Serializer | credential library', function (hooks) { const snapshot = record._createSnapshot(); const serializedRecord = serializer.serialize(snapshot); assert.deepEqual(serializedRecord, { - type: 'vault', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, credential_store_id: null, name: 'Name', description: 'Description', @@ -27,6 +31,13 @@ module('Unit | Serializer | credential library', function (hooks) { attributes: { path: '/vault/path', http_method: 'GET', + critical_options: null, + extensions: null, + key_bits: null, + key_id: null, + key_type: null, + ttl: null, + username: null, }, }); }); @@ -40,7 +51,7 @@ module('Unit | Serializer | credential library', function (hooks) { id: '1', type: 'credential-library', attributes: { - type: 'vault', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, name: 'Name', description: 'Description', path: '/vault/path', @@ -53,13 +64,20 @@ module('Unit | Serializer | credential library', function (hooks) { const snapshot = record._createSnapshot(); const serializedRecord = serializer.serialize(snapshot); assert.deepEqual(serializedRecord, { - type: 'vault', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, credential_store_id: null, name: 'Name', description: 'Description', attributes: { path: '/vault/path', http_method: 'GET', + critical_options: null, + extensions: null, + key_bits: null, + key_id: null, + key_type: null, + ttl: null, + username: null, }, version: 1, }); @@ -75,8 +93,14 @@ module('Unit | Serializer | credential library', function (hooks) { let serializedRecord = record.serialize(); assert.deepEqual(serializedRecord, { attributes: { - http_method: null, path: null, + critical_options: null, + extensions: null, + key_bits: null, + key_id: null, + key_type: null, + ttl: null, + username: null, }, credential_store_id: null, description: null, @@ -89,6 +113,7 @@ module('Unit | Serializer | credential library', function (hooks) { assert.expect(2); const store = this.owner.lookup('service:store'); const record = store.createRecord('credential-library', { + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, http_method: 'GET', http_request_body: 'body', }); @@ -99,11 +124,18 @@ module('Unit | Serializer | credential library', function (hooks) { attributes: { http_method: 'GET', path: null, + critical_options: null, + extensions: null, + key_bits: null, + key_id: null, + key_type: null, + ttl: null, + username: null, }, credential_store_id: null, description: null, name: null, - type: null, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, }, 'http_request_body attribute is not expected' ); @@ -117,13 +149,66 @@ module('Unit | Serializer | credential library', function (hooks) { http_method: 'POST', http_request_body: 'body', path: null, + critical_options: null, + extensions: null, + key_bits: null, + key_id: null, + key_type: null, + ttl: null, + username: null, }, credential_store_id: null, description: null, name: null, - type: null, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, }, 'http_request_body attribute is expected' ); }); + + test('it serializes vault_ssh_certificate correctly', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('credential-library'); + store.push({ + data: { + id: '1', + type: 'credential-library', + attributes: { + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + version: 1, + name: 'Name', + description: 'Description', + path: '/vault/path', + username: 'user', + key_type: 'rsa', + key_bits: 100, + ttl: '100', + key_id: 'id', + extensions: [{ key: 'key', value: 'value' }], + critical_options: [{ key: 'key', value: 'value' }], + }, + }, + }); + const record = store.peekRecord('credential-library', '1'); + const snapshot = record._createSnapshot(); + const serializedRecord = serializer.serialize(snapshot); + assert.deepEqual(serializedRecord, { + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + credential_store_id: null, + name: 'Name', + description: 'Description', + attributes: { + path: '/vault/path', + username: 'user', + key_type: 'rsa', + key_bits: 100, + ttl: '100', + key_id: 'id', + extensions: { key: 'value' }, + critical_options: { key: 'value' }, + }, + version: 1, + }); + }); }); diff --git a/addons/api/tests/unit/transforms/object-as-array-test.js b/addons/api/tests/unit/transforms/object-as-array-test.js new file mode 100644 index 000000000..997206a4f --- /dev/null +++ b/addons/api/tests/unit/transforms/object-as-array-test.js @@ -0,0 +1,52 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Transform | object as array', function (hooks) { + setupTest(hooks); + + test('it deserializes an object to an array of key value objects', function (assert) { + assert.expect(1); + let transform = this.owner.lookup('transform:object-as-array'); + const deserialized = transform.deserialize({ + first: 'Hey', + second: 'There', + third: 'Everyone', + }); + assert.deepEqual(deserialized, [ + { key: 'first', value: 'Hey' }, + { key: 'second', value: 'There' }, + { key: 'third', value: 'Everyone' }, + ]); + }); + + test('it deserializes undefined to an empty object', function (assert) { + assert.expect(1); + let transform = this.owner.lookup('transform:object-as-array'); + const deserialized = transform.deserialize(undefined); + assert.deepEqual(deserialized, []); + }); + + test('it serializes an array of key value objects to an object', function (assert) { + assert.expect(1); + let transform = this.owner.lookup('transform:object-as-array'); + const serialized = transform.serialize([ + { key: 'first', value: 'Hey' }, + { key: 'second', value: 'There' }, + { key: 'third', value: 'Everyone' }, + ]); + + assert.deepEqual(serialized, { + first: 'Hey', + second: 'There', + third: 'Everyone', + }); + }); + + test('it serializes an empty array of key value objects to null', function (assert) { + assert.expect(1); + let transform = this.owner.lookup('transform:object-as-array'); + const serialized = transform.serialize([]); + + assert.deepEqual(serialized, null); + }); +}); diff --git a/addons/core/translations/en-us.yaml b/addons/core/translations/en-us.yaml index 83b880c0b..10cf32a94 100644 --- a/addons/core/translations/en-us.yaml +++ b/addons/core/translations/en-us.yaml @@ -34,6 +34,7 @@ titles: provider: Provider success: Success create-resources: Create Resources + choose-an-option: Choose an option descriptions: empty-set: There are no items to display yet. You may be able to add items or try back later. cluster-url-initialization: To get started, please enter your cluster URL diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 046c502d2..ce0922371 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -468,9 +468,33 @@ credential-library: http_request_body: label: HTTP Method POST Request Body help: The body of the HTTP request the library sends to Vault when requesting credentials. Only allowed when HTTP method is set to POST. + username: + label: Username + help: The username to use when making an SSH connection. This can be templated to use the requesting user's name. This will be included in the valid_principles when making the request to Vault. + key_type: + label: Key Type + help: Specifies the desired key type to use when generating a private key. + key_bits: + label: Key Bits + help: Specifies the number of bits used generating the private key. + ttl: + label: TTL + help: Specifies the Requested Time to Live for the certificate. + key_id: + label: Key ID + help: Specifies the key id that the created certificate should have. + critical_options: + label: Critical Options + help: Specifies a map of the critical options that the certificate should be signed for. + extensions: + label: Extensions + help: Specifies a map of the extensions that the certificate should be signed for. types: - vault: Vault - static: Static + vault-generic: Generic Secrets + vault-ssh-certificate: SSH Certificates + help: + vault-generic: Authorize sessions using credentials brokered by Vault + vault-ssh-certificate: Authorize sessions using the SSH Secrets Engine in Vault credential: title: Credential title_plural: Credentials diff --git a/addons/rose/package.json b/addons/rose/package.json index e5d74ff9f..87156f37c 100644 --- a/addons/rose/package.json +++ b/addons/rose/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@embroider/test-setup": "^0.48.1", - "@hashicorp/design-system-components": "^1.3.2", + "@hashicorp/design-system-components": "^1.5.1", "codemirror": "5.65.7", "ember-auto-import": "^2.4.2", "ember-cli-babel": "^7.26.10", diff --git a/ui/admin/app/abilities/credential-library.js b/ui/admin/app/abilities/credential-library.js new file mode 100644 index 000000000..135b76f7d --- /dev/null +++ b/ui/admin/app/abilities/credential-library.js @@ -0,0 +1,20 @@ +import CredentialLibraryAbility from 'api/abilities/credential-library'; +import { inject as service } from '@ember/service'; + +export default class OverrideCredentialLibraryAbility extends CredentialLibraryAbility { + // =service + @service features; + + /** + * This override ensures that vault ssh cert may be read only if the + * credential-library-vault-ssh-certificate feature flag is enabled. + * All other types are subject to the standard logic found in the api addon. + */ + get canRead() { + return this.features.isEnabled( + 'credential-library-vault-ssh-certificate' + ) || !this.model.isVaultSSHCertificate + ? super.canRead + : false; + } +} diff --git a/ui/admin/app/components/form/credential-library/index.hbs b/ui/admin/app/components/form/credential-library/index.hbs index 19f46da5e..0038d0d4a 100644 --- a/ui/admin/app/components/form/credential-library/index.hbs +++ b/ui/admin/app/components/form/credential-library/index.hbs @@ -1,129 +1,10 @@ - - - - - - {{#if @model.errors.name}} - - {{#each @model.errors.name as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - - - {{#if @model.errors.description}} - - {{#each @model.errors.description as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - - - {{#if @model.errors.path}} - - {{#each @model.errors.path as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - - - <:title>{{t 'resources.credential-library.form.http_method.label'}} - <:description> - {{t 'resources.credential-library.form.http_method.help'}} - - <:body> - - {{#each this.httpMethodOptions as |httpMethod|}} - - {{/each}} - - - - - {{#if this.isHttpRequestBodyAllowed}} - - {{#if @model.errors.http_request_body}} - - {{#each @model.errors.http_request_body as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - {{/if}} - - {{#if (can 'save model' @model)}} - - {{/if}} - - \ No newline at end of file +{{#if @model.type}} + {{component + (concat 'form/credential-library/' @model.type) + model=@model + submit=@submit + cancel=@cancel + changeType=@changeType + edit=@edit + }} +{{/if}} \ No newline at end of file diff --git a/ui/admin/app/components/form/credential-library/vault-generic/index.hbs b/ui/admin/app/components/form/credential-library/vault-generic/index.hbs new file mode 100644 index 000000000..0130a0554 --- /dev/null +++ b/ui/admin/app/components/form/credential-library/vault-generic/index.hbs @@ -0,0 +1,169 @@ + + + {{t 'form.name.label'}} + {{t 'form.name.help'}} + {{#if @model.errors.name}} + + {{#each @model.errors.name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + {{#if @model.errors.description}} + + {{#each @model.errors.description as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if + (and @model.isNew (feature-flag 'credential-library-vault-ssh-certificate')) + }} + + {{t 'form.type.label'}} + {{#each this.types as |type|}} + + {{t + (concat 'resources.credential-library.types.' type) + }} + {{t + (concat 'resources.credential-library.help.' type) + }} + + {{/each}} + + {{else}} + + {{t 'form.type.label'}} + + {{t (concat 'resources.credential-library.help.' @model.type)}} + + + {{/if}} + + + {{t + 'resources.credential-library.form.vault_path.label' + }} + {{t + 'resources.credential-library.form.vault_path.help' + }} + {{#if @model.errors.path}} + + {{#each @model.errors.path as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + + {{t 'resources.credential-library.form.http_method.label'}} + + + {{t 'resources.credential-library.form.http_method.help'}} + + {{#each this.httpMethodOptions as |httpMethod|}} + + {{httpMethod}} + + {{/each}} + + + {{#if this.isHttpRequestBodyAllowed}} + + {{t + 'resources.credential-library.form.http_request_body.label' + }} + {{t + 'resources.credential-library.form.http_request_body.help' + }} + {{#if @model.errors.http_request_body}} + + {{#each @model.errors.http_request_body as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{/if}} + + {{#if (can 'save model' @model)}} + + {{/if}} + + \ No newline at end of file diff --git a/ui/admin/app/components/form/credential-library/index.js b/ui/admin/app/components/form/credential-library/vault-generic/index.js similarity index 57% rename from ui/admin/app/components/form/credential-library/index.js rename to ui/admin/app/components/form/credential-library/vault-generic/index.js index 3faf5f8d3..3737a3686 100644 --- a/ui/admin/app/components/form/credential-library/index.js +++ b/ui/admin/app/components/form/credential-library/vault-generic/index.js @@ -1,19 +1,26 @@ import Component from '@glimmer/component'; -import { options } from 'api/models/credential-library'; -import { computed } from '@ember/object'; +import { + options, + TYPES_CREDENTIAL_LIBRARY, +} from 'api/models/credential-library'; -export default class FormCredentialLibraryIndexComponent extends Component { +export default class FormCredentialLibraryVaultGenericComponent extends Component { // =properties /** * @type {object} */ httpMethodOptions = options.http_method; + /** + * + * @type {Array.} + */ + types = TYPES_CREDENTIAL_LIBRARY; + /** * Only allow HTTP request body field if http_method is set to POST. * @type {boolean} */ - @computed('args.model.http_method') get isHttpRequestBodyAllowed() { return this.args.model.http_method?.match(/post/i); } diff --git a/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs b/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs new file mode 100644 index 000000000..378873216 --- /dev/null +++ b/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs @@ -0,0 +1,292 @@ + + + {{t 'form.name.label'}} + {{t 'form.name.help'}} + {{#if @model.errors.name}} + + {{#each @model.errors.name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + {{#if @model.errors.description}} + + {{#each @model.errors.description as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if @model.isNew}} + + {{t 'form.type.label'}} + {{#each this.types as |type|}} + + {{t + (concat 'resources.credential-library.types.' type) + }} + {{t + (concat 'resources.credential-library.help.' type) + }} + + {{/each}} + + {{else}} + + {{t 'form.type.label'}} + + {{t (concat 'resources.credential-library.help.' @model.type)}} + + + {{/if}} + + + {{t + 'resources.credential-library.form.vault_path.label' + }} + {{t + 'resources.credential-library.form.vault_path.help' + }} + {{#if @model.errors.path}} + + {{#each @model.errors.path as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'resources.credential-library.form.username.label'}} + {{t + 'resources.credential-library.form.username.help' + }} + {{#if @model.errors.username}} + + {{#each @model.errors.username as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'resources.credential-library.form.key_type.label'}} + {{t + 'resources.credential-library.form.key_type.help' + }} + + + {{#each this.keyTypes as |keyType|}} + + {{/each}} + + {{#if @model.errors.key_type}} + + {{#each @model.errors.key_type as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if this.showKeyBits}} + + {{t + 'resources.credential-library.form.key_bits.label' + }} + {{t + 'resources.credential-library.form.key_bits.help' + }} + {{#if @model.errors.key_bits}} + + {{#each @model.errors.key_bits as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{/if}} + + + {{t 'resources.credential-library.form.ttl.label'}} + {{t + 'resources.credential-library.form.ttl.help' + }} + {{#if @model.errors.ttl}} + + {{#each @model.errors.ttl as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'resources.credential-library.form.key_id.label'}} + {{t + 'resources.credential-library.form.key_id.help' + }} + {{#if @model.errors.key_id}} + + {{#each @model.errors.key_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + + {{t 'resources.credential-library.form.critical_options.label'}} + + + {{t 'resources.credential-library.form.critical_options.help'}} + + {{#if @model.errors.critical_options}} + + {{#each @model.errors.critical_options as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + + {{t 'resources.credential-library.form.extensions.label'}} + + + {{t 'resources.credential-library.form.extensions.help'}} + + {{#if @model.errors.extensions}} + + {{#each @model.errors.extensions as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if (can 'save model' @model)}} + + {{/if}} + + \ No newline at end of file diff --git a/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.js b/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.js new file mode 100644 index 000000000..aa1adfb17 --- /dev/null +++ b/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.js @@ -0,0 +1,55 @@ +import Component from '@glimmer/component'; +import { action, set } from '@ember/object'; +import { + options, + TYPES_CREDENTIAL_LIBRARY, +} from 'api/models/credential-library'; + +export default class FormCredentialLibraryVaultSshCertComponent extends Component { + // =properties + + /** + * @type {object} + */ + keyTypes = options.key_type; + + /** + * @type {Array.} + */ + types = TYPES_CREDENTIAL_LIBRARY; + + /** + * Boolean to determine if the key bits field should be displayed on the form + * @returns {boolean} + */ + get showKeyBits() { + const keyType = this.args.model.key_type; + return keyType === 'rsa' || keyType === 'ecdsa'; + } + + /** + * Adds a key/value option object. We recreate a new array after adding + * so that ember is aware that the array has been modified. + * @param field {string} + * @param key {string} + * @param value {string} + */ + @action + addOption(field, { key, value }) { + const existingArray = this.args.model[field] ?? []; + const newArray = [...existingArray, { key, value }]; + set(this.args.model, field, newArray); + } + + /** + * Removes an option by index. We recreate a new array after + * splicing out the item so that ember is aware that the array has been modified. + * @param field {string} + * @param index {number} + */ + @action + removeOptionByIndex(field, index) { + const newArray = this.args.model[field].filter((_, i) => i !== index); + set(this.args.model, field, newArray); + } +} diff --git a/ui/admin/app/components/form/field/key-value-list/index.hbs b/ui/admin/app/components/form/field/key-value-list/index.hbs new file mode 100644 index 000000000..e562cbf4f --- /dev/null +++ b/ui/admin/app/components/form/field/key-value-list/index.hbs @@ -0,0 +1,98 @@ + + {{yield F}} + + + <:head as |H|> + + {{t 'form.key.label'}} + {{t 'form.value.label'}} + + {{#if @removeOptionByIndex}} + {{t 'titles.actions'}} + {{/if}} + + + + <:body as |B|> + + {{#each @options as |option index|}} + + + + {{t 'form.key.label'}} + + + + + {{t 'form.value.label'}} + + + + {{#if @removeOptionByIndex}} + + {{/if}} + + + {{/each}} + + + + + {{t 'form.key.label'}} + + + + {{t 'form.value.label'}} + + + + + + + + + + + \ No newline at end of file diff --git a/ui/admin/app/components/form/field/key-value-list/index.js b/ui/admin/app/components/form/field/key-value-list/index.js new file mode 100644 index 000000000..51f18bf55 --- /dev/null +++ b/ui/admin/app/components/form/field/key-value-list/index.js @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { assert } from '@ember/debug'; + +export default class MappingListComponent extends Component { + // =attributes + + /** + * @type {string} + */ + @tracked newOptionKey = ''; + + /** + * @type {string} + */ + @tracked newOptionValue = ''; + + // =actions + + /** + * If a new key value is entered and an addOption method was specified, + * calls addOption with the new key and value. Resets key and value. + */ + @action + addOption() { + assert( + '[boundary-admin-key-value-list-field] `@addOption` is required.', + this.args.addOption + ); + + if (this.newOptionKey) { + this.args.addOption({ + key: this.newOptionKey, + value: this.newOptionValue, + }); + } + + this.newOptionKey = ''; + this.newOptionValue = ''; + } +} diff --git a/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/new.js b/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/new.js index d707e8442..e18392ce7 100644 --- a/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/new.js +++ b/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/new.js @@ -7,6 +7,7 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrari @service intl; // =attributes + queryParams = ['type']; /** * Translated new credential libraries breadcrumb diff --git a/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library.js b/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library.js index 41693a535..2e7e1bc76 100644 --- a/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library.js +++ b/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library.js @@ -1,5 +1,6 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrariesCredentialLibraryRoute extends Route { // =services @@ -19,4 +20,21 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrari reload: true, }); } + + /** + * Copies the contents of array fields in order to force the instance + * into a dirty state. This ensures that `model.rollbackAttributes()` reverts + * to the original expected array. + * + * The deep copy implemented here is required to ensure that both the + * array itself and its members are all new. + * + * @param {credentialLibraryModel} credentialLibrary + */ + @action + edit(credentialLibrary) { + const { critical_options, extensions } = credentialLibrary; + credentialLibrary.critical_options = structuredClone(critical_options); + credentialLibrary.extensions = structuredClone(extensions); + } } diff --git a/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js b/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js index cd995433f..a327d13cf 100644 --- a/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js +++ b/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js @@ -1,10 +1,22 @@ import Route from '@ember/routing/route'; +import { action } from '@ember/object'; import { inject as service } from '@ember/service'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from 'api/models/credential-library'; export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrariesNewRoute extends Route { // =services @service store; + @service router; + @service features; + + // =attributes + + queryParams = { + type: { + refreshModel: true, + }, + }; // =methods @@ -12,13 +24,29 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrari * Creates a new unsaved credential library in current credential store. * @return {CredentialLibraryModel} */ - model() { - const { id: credential_store_id, type } = this.modelFor( + model({ type = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC }) { + const { id: credential_store_id } = this.modelFor( 'scopes.scope.credential-stores.credential-store' ); + + // Set the type to generic vault if feature flag isn't enabled in cases where + // user sets the query parameter manually + if (!this.features.isEnabled('credential-library-vault-ssh-certificate')) { + type = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC; + } + return this.store.createRecord('credential-library', { type, credential_store_id, }); } + + /** + * Update type of credential library + * @param {string} type + */ + @action + changeType(type) { + this.router.replaceWith({ queryParams: { type } }); + } } diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index ed4254386..3f451bf83 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -637,3 +637,47 @@ margin-top: sizing.rems(xs) - sizing.rems(xxxxs); // 10 } } + +.key-value-list { + border: 0; + + /* stylelint-disable selector-class-pattern */ + .hds-table__thead { + .hds-table__th { + height: auto; + line-height: 1; + } + } + + .hds-table__thead, + .hds-table__tbody { + .hds-table__tr { + // Remove background color on all cells (especially header cells) + background-color: transparent; + } + + .hds-table__th, + .hds-table__td { + padding: 0.75rem 1rem; + + // Rely on right padding for cell spacing... + padding-left: 0; + + &:last-child { + // ...except in the last cell, which doesn't need right padding + padding-right: 0; + + // Shrink last cell to minimum possible width + width: 1%; + white-space: nowrap; + } + + // Reset field margins to rely instead on cell paddings + .hds-form-field--layout-vertical, + .hds-form-text-input { + margin: 0; + } + } + } + /* stylelint-enable selector-class-pattern */ +} diff --git a/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library/index.hbs b/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library/index.hbs index fe4f7069e..b7310e49e 100644 --- a/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library/index.hbs +++ b/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/credential-library/index.hbs @@ -50,6 +50,7 @@ diff --git a/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/new.hbs b/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/new.hbs index 715b4a53d..34b2ed8cc 100644 --- a/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/new.hbs +++ b/ui/admin/app/templates/scopes/scope/credential-stores/credential-store/credential-libraries/new.hbs @@ -19,6 +19,7 @@ @model={{@model}} @submit={{route-action 'save' @model}} @cancel={{route-action 'cancel' @model}} + @changeType={{route-action 'changeType'}} /> \ No newline at end of file diff --git a/ui/admin/config/environment.js b/ui/admin/config/environment.js index 31656f2b3..cf9742693 100644 --- a/ui/admin/config/environment.js +++ b/ui/admin/config/environment.js @@ -15,7 +15,7 @@ const featureEditions = { 'vault-worker-filter': false, 'target-worker-filters-v2': true, 'target-network-address': true, - 'credential-library-vault-ssh-cert': false, + 'credential-library-vault-ssh-certificate': false, }, }; featureEditions.enterprise = { @@ -23,6 +23,7 @@ featureEditions.enterprise = { 'ssh-target': true, 'vault-worker-filter': true, 'target-worker-filters-v2-ingress': true, + 'credential-library-vault-ssh-certificate': true, }; featureEditions.hcp = { ...featureEditions.enterprise, @@ -152,7 +153,7 @@ module.exports = function (environment) { ENV.featureFlags['target-worker-filters-v2-ingress'] = true; ENV.featureFlags['target-worker-filters-v2-hcp'] = true; ENV.featureFlags['target-network-address'] = true; - ENV.featureFlags['credential-library-vault-ssh-cert'] = true; + ENV.featureFlags['credential-library-vault-ssh-certificate'] = true; } if (environment === 'test') { @@ -178,7 +179,7 @@ module.exports = function (environment) { ENV.featureFlags['vault-worker-filter'] = true; ENV.featureFlags['target-worker-filters-v2'] = true; ENV.featureFlags['target-network-address'] = true; - ENV.featureFlags['credential-library-vault-ssh-cert'] = true; + ENV.featureFlags['credential-library-vault-ssh-certificate'] = true; } if (environment === 'production') { diff --git a/ui/admin/tests/acceptance/credential-library/create-test.js b/ui/admin/tests/acceptance/credential-library/create-test.js index 2ca075dae..cc1ed02b1 100644 --- a/ui/admin/tests/acceptance/credential-library/create-test.js +++ b/ui/admin/tests/acceptance/credential-library/create-test.js @@ -1,10 +1,18 @@ import { module, test } from 'qunit'; -import { visit, click, fillIn, currentURL, find } from '@ember/test-helpers'; +import { + visit, + click, + fillIn, + currentURL, + find, + select, +} from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { authenticateSession } from 'ember-simple-auth/test-support'; import { Response } from 'miragejs'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE } from 'api/models/credential-library'; module('Acceptance | credential-libraries | create', function (hooks) { setupApplicationTest(hooks); @@ -83,6 +91,76 @@ module('Acceptance | credential-libraries | create', function (hooks) { assert.strictEqual(getCredentialLibraryCount(), count + 1); }); + test('can create a new credential library of type vault ssh cert', async function (assert) { + assert.expect(12); + const count = getCredentialLibraryCount(); + await visit(urls.newCredentialLibrary); + await click('[value="vault-ssh-certificate"]'); + await fillIn('[name="name"]', 'name'); + await fillIn('[name="description"]', 'description'); + await fillIn('[name="vault_path"]', 'path'); + await fillIn('[name="username"]', 'username'); + await select('[name="key_type"]', 'rsa'); + await fillIn('[name="key_bits"]', 100); + await fillIn('[name="ttl"]', 'ttl'); + await fillIn('[name="key_id"]', 'key_id'); + await fillIn( + '[name="critical_options"] tbody td:nth-of-type(1) input', + 'co_key' + ); + await fillIn( + '[name="critical_options"] tbody td:nth-of-type(2) input', + 'co_value' + ); + await click('[name="critical_options"] button'); + await fillIn( + '[name="extensions"] tbody td:nth-of-type(1) input', + 'ext_key' + ); + await fillIn( + '[name="extensions"] tbody td:nth-of-type(2) input', + 'ext_value' + ); + await click('[name="extensions"] button'); + await click('[type="submit"]'); + assert.strictEqual(getCredentialLibraryCount(), count + 1); + assert.strictEqual( + this.server.schema.credentialLibraries.where({ + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }).length, + 1 + ); + const credentialLibrary = this.server.schema.credentialLibraries.findBy({ + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + assert.strictEqual(credentialLibrary.name, 'name'); + assert.strictEqual(credentialLibrary.description, 'description'); + assert.strictEqual(credentialLibrary.attributes.path, 'path'); + assert.strictEqual(credentialLibrary.attributes.username, 'username'); + assert.strictEqual(credentialLibrary.attributes.key_type, 'rsa'); + assert.strictEqual(credentialLibrary.attributes.key_bits, 100); + assert.strictEqual(credentialLibrary.attributes.ttl, 'ttl'); + assert.strictEqual(credentialLibrary.attributes.key_id, 'key_id'); + assert.deepEqual(credentialLibrary.attributes.critical_options, { + co_key: 'co_value', + }); + assert.deepEqual(credentialLibrary.attributes.extensions, { + ext_key: 'ext_value', + }); + }); + + test('ecdsa and rsa key types bring up a key bits field', async function (assert) { + assert.expect(3); + await visit(urls.newCredentialLibrary); + await click('[value="vault-ssh-certificate"]'); + await select('[name="key_type"]', 'ed25519'); + assert.dom('[name="key_bits"]').doesNotExist(); + await select('[name="key_type"]', 'ecdsa'); + assert.dom('[name="key_bits"]').isVisible(); + await select('[name="key_type"]', 'rsa'); + assert.dom('[name="key_bits"]').isVisible(); + }); + test('Users cannot navigate to new credential library route without proper authorization', async function (assert) { assert.expect(2); instances.credentialStore.authorized_collection_actions[ @@ -107,7 +185,7 @@ module('Acceptance | credential-libraries | create', function (hooks) { assert.strictEqual(getCredentialLibraryCount(), count); }); - test('saving a new credential library with invalid fields displays error messasges', async function (assert) { + test('saving a new credential library with invalid fields displays error messages', async function (assert) { assert.expect(2); this.server.post('/credential-libraries', () => { return new Response( @@ -135,8 +213,17 @@ module('Acceptance | credential-libraries | create', function (hooks) { 'The request was invalid.' ); assert.ok( - find('.rose-form-error-message').textContent.trim(), + find('.hds-form-error__message').textContent.trim(), 'Name is required.' ); }); + + test('cannot select vault ssh cert when feature is disabled', async function (assert) { + const featuresService = this.owner.lookup('service:features'); + featuresService.disable('credential-library-vault-ssh-certificate'); + assert.expect(1); + await visit(urls.newCredentialLibrary); + + assert.dom('[value="vault-ssh-certificate"]').doesNotExist(); + }); }); diff --git a/ui/admin/tests/acceptance/credential-library/read-test.js b/ui/admin/tests/acceptance/credential-library/read-test.js index 6900dc023..4682110ba 100644 --- a/ui/admin/tests/acceptance/credential-library/read-test.js +++ b/ui/admin/tests/acceptance/credential-library/read-test.js @@ -4,6 +4,7 @@ import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { authenticateSession } from 'ember-simple-auth/test-support'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE } from 'api/models/credential-library'; module('Acceptance | credential-libraries | read', function (hooks) { setupApplicationTest(hooks); @@ -78,6 +79,23 @@ module('Acceptance | credential-libraries | read', function (hooks) { assert.notOk(find('main tbody .rose-table-header-cell a')); }); + test('cannot navigate to vault ssh cert form when feature is not enabled', async function (assert) { + assert.expect(1); + instances.credentialLibrary = this.server.create('credential-library', { + scope: instances.scopes.project, + credentialStore: instances.credentialStore, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + await visit( + `${urls.credentialLibraries}/${instances.credentialLibrary.id}` + ); + const featuresService = this.owner.lookup('service:features'); + featuresService.disable('credential-library-vault-ssh-certificate'); + await visit(urls.credentialLibraries); + + assert.dom('.rose-table-body tr:nth-of-type(2) a').doesNotExist(); + }); + test('visiting an unknown credential library displays 404 message', async function (assert) { assert.expect(1); await visit(urls.unknownCredentialLibrary); diff --git a/ui/admin/tests/acceptance/credential-library/update-test.js b/ui/admin/tests/acceptance/credential-library/update-test.js index b608128b7..4bdcabd9a 100644 --- a/ui/admin/tests/acceptance/credential-library/update-test.js +++ b/ui/admin/tests/acceptance/credential-library/update-test.js @@ -1,9 +1,17 @@ import { module, test } from 'qunit'; -import { visit, click, fillIn, currentURL, find } from '@ember/test-helpers'; +import { + visit, + click, + fillIn, + currentURL, + find, + select, +} from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { authenticateSession } from 'ember-simple-auth/test-support'; import { Response } from 'miragejs'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE } from 'api/models/credential-library'; module('Acceptance | credential-libraries | update', function (hooks) { setupApplicationTest(hooks); @@ -130,7 +138,7 @@ module('Acceptance | credential-libraries | update', function (hooks) { 'The request was invalid.' ); assert.ok( - find('.rose-form-error-message').textContent.trim(), + find('.hds-form-error__message').textContent.trim(), 'Name is required.' ); }); @@ -181,4 +189,61 @@ module('Acceptance | credential-libraries | update', function (hooks) { ); } }); + + test('can update a vault ssh cert credential library and save changes', async function (assert) { + assert.expect(10); + instances.credentialLibrary = this.server.create('credential-library', { + scope: instances.scopes.project, + credentialStore: instances.credentialStore, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + await visit( + `${urls.credentialLibraries}/${instances.credentialLibrary.id}` + ); + await click('form [type="button"]:not(:disabled)', 'Activate edit mode'); + await fillIn('[name="name"]', 'name'); + await fillIn('[name="description"]', 'description'); + await fillIn('[name="vault_path"]', 'path'); + await fillIn('[name="username"]', 'username'); + await select('[name="key_type"]', 'rsa'); + await fillIn('[name="key_bits"]', 100); + await fillIn('[name="ttl"]', 'ttl'); + await fillIn('[name="key_id"]', 'key_id'); + await fillIn( + '[name="critical_options"] tbody td:nth-of-type(1) input', + 'co_key' + ); + await fillIn( + '[name="critical_options"] tbody td:nth-of-type(2) input', + 'co_value' + ); + await click('[name="critical_options"] button'); + await fillIn( + '[name="extensions"] tbody td:nth-of-type(1) input', + 'ext_key' + ); + await fillIn( + '[name="extensions"] tbody td:nth-of-type(2) input', + 'ext_value' + ); + await click('[name="extensions"] button'); + await click('[type="submit"]'); + const credentialLibrary = this.server.schema.credentialLibraries.findBy({ + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + assert.strictEqual(credentialLibrary.name, 'name'); + assert.strictEqual(credentialLibrary.description, 'description'); + assert.strictEqual(credentialLibrary.attributes.path, 'path'); + assert.strictEqual(credentialLibrary.attributes.username, 'username'); + assert.strictEqual(credentialLibrary.attributes.key_type, 'rsa'); + assert.strictEqual(credentialLibrary.attributes.key_bits, 100); + assert.strictEqual(credentialLibrary.attributes.ttl, 'ttl'); + assert.strictEqual(credentialLibrary.attributes.key_id, 'key_id'); + assert.deepEqual(credentialLibrary.attributes.critical_options, { + co_key: 'co_value', + }); + assert.deepEqual(credentialLibrary.attributes.extensions, { + ext_key: 'ext_value', + }); + }); }); diff --git a/ui/admin/tests/integration/components/form/field/key-value-list-test.js b/ui/admin/tests/integration/components/form/field/key-value-list-test.js new file mode 100644 index 000000000..d7985e657 --- /dev/null +++ b/ui/admin/tests/integration/components/form/field/key-value-list-test.js @@ -0,0 +1,44 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +module('Integration | Component | key-value-list', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + test('it renders fields', async function (assert) { + await render(hbs` + + Label + Help + + Error! + + + `); + + assert.dom('tbody tr').exists({ count: 1 }); + assert.dom('legend').exists().hasText('Label'); + assert.dom('.hds-form-helper-text').exists().hasText('Help'); + assert.dom('.hds-form-error__message').exists().hasText('Error!'); + assert.dom('button').exists().hasText('Add'); + }); + + test('it renders multiple options', async function (assert) { + this.options = [ + { key: 'one', value: 'two' }, + { key: 'three', value: 'four' }, + ]; + + // Template block usage: + await render(hbs` + + `); + + assert.dom('tbody tr').exists({ count: 3 }); + }); +}); diff --git a/ui/admin/tests/unit/abilities/credential-library-test.js b/ui/admin/tests/unit/abilities/credential-library-test.js new file mode 100644 index 000000000..8c2b2c340 --- /dev/null +++ b/ui/admin/tests/unit/abilities/credential-library-test.js @@ -0,0 +1,73 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, +} from 'api/models/credential-library'; + +module('Unit | Abilities | credential-library', function (hooks) { + setupTest(hooks); + + let features; + + hooks.beforeEach(function () { + features = this.owner.lookup('service:features'); + features.enable('credential-library-vault-ssh-certificate'); + }); + + test('can read credential library type when authorized and feature is enabled', function (assert) { + assert.expect(2); + const canService = this.owner.lookup('service:can'); + const store = this.owner.lookup('service:store'); + const credentialLibrary = store.createRecord('credential-library', { + authorized_actions: ['read'], + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + assert.true(canService.can('read credential-library', credentialLibrary)); + credentialLibrary.type = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC; + assert.true(canService.can('read credential-library', credentialLibrary)); + }); + + test('cannot read credential library type when unauthorized and feature is enabled', function (assert) { + assert.expect(2); + const canService = this.owner.lookup('service:can'); + const store = this.owner.lookup('service:store'); + const credentialLibrary = store.createRecord('credential-library', { + authorized_actions: [], + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + assert.false(canService.can('read credential-library', credentialLibrary)); + credentialLibrary.type = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC; + assert.false(canService.can('read credential-library', credentialLibrary)); + }); + + test('cannot read credential library type when unauthorized and feature is disabled', function (assert) { + assert.expect(2); + const canService = this.owner.lookup('service:can'); + const store = this.owner.lookup('service:store'); + const featuresService = this.owner.lookup('service:features'); + featuresService.disable('credential-library-vault-ssh-certificate'); + const credentialLibrary = store.createRecord('credential-library', { + authorized_actions: [], + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + assert.false(canService.can('read credential-library', credentialLibrary)); + credentialLibrary.type = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC; + assert.false(canService.can('read credential-library', credentialLibrary)); + }); + + test('can read vault-generic but not vault-ssh-certificate when authorized and feature is disabled', function (assert) { + assert.expect(2); + const canService = this.owner.lookup('service:can'); + const store = this.owner.lookup('service:store'); + const featuresService = this.owner.lookup('service:features'); + featuresService.disable('credential-library-vault-ssh-certificate'); + const credentialLibrary = store.createRecord('credential-library', { + authorized_actions: ['read'], + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }); + assert.false(canService.can('read credential-library', credentialLibrary)); + credentialLibrary.type = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC; + assert.true(canService.can('read credential-library', credentialLibrary)); + }); +}); diff --git a/yarn.lock b/yarn.lock index 05d89322f..39c0f1a3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4689,43 +4689,46 @@ resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== -"@hashicorp/design-system-components@^1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-1.3.2.tgz#42d10b20bf93bdf4587012af8a4a944f3538272a" - integrity sha512-aXm5pv9LurVFpoHTgOo87H2LxcdykSPMo88W2E/sw5dEEwJPZARI/Mg3H1i3EjR8Psc1COAozP5jpOhfer1b4w== +"@hashicorp/design-system-components@^1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-1.5.1.tgz#ed02dd834db2d254f2dc6c108f3520ddf3268de3" + integrity sha512-P1kSd6t3V/cbAiWBWw/iib7WOZFDBIXFqbFP2UvT9LuZoP6tejrRfVy8Xhiy+NxCb2uVR543T3d8M/7E+N2e0Q== dependencies: - "@hashicorp/design-system-tokens" "^1.2.0" - "@hashicorp/ember-flight-icons" "^3.0.1" + "@hashicorp/design-system-tokens" "^1.3.0" + "@hashicorp/ember-flight-icons" "^3.0.2" + dialog-polyfill "^0.5.6" ember-auto-import "^2.4.2" ember-cached-decorator-polyfill "^0.1.4" ember-cli-babel "^7.26.11" ember-cli-htmlbars "^6.1.0" ember-cli-sass "^10.0.1" + ember-composable-helpers "^4.4.1" + ember-focus-trap "^1.0.1" ember-keyboard "^8.1.0" ember-named-blocks-polyfill "^0.2.5" ember-style-modifier "^0.8.0" ember-truth-helpers "^3.0.0" sass "^1.43.4" -"@hashicorp/design-system-tokens@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.2.0.tgz#e83a69934c5c0c84de3c4572811e56543905bf39" - integrity sha512-N74hb1mLImbY1NyH7Of4UDY7HBO5A4VSdXzXyLO2ZrB/WYIfX++uKjOMknca0lAAIMC7kqaYm3OIbND/sHbu8g== +"@hashicorp/design-system-tokens@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.3.0.tgz#0a143f7fb20b72ac003e56c7408e23b0b55d45f9" + integrity sha512-Re16f4tqyAApChk20Eev0xVWijCo8m3YYIo4A84tStTTkXk4d63lbY1MuebWaaAjcjzKGuyPh0w3u0SJWJFCUg== -"@hashicorp/ember-flight-icons@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.0.1.tgz#38895453a7994d7089bc058ccaa11f9c7eb9584e" - integrity sha512-1gmzv3d//YzW87vd5FsJQpgq+addDdl3xgPkT1v9uVaDh7k8AZsz9IltymhP+Q62OIGHhx7GwagIwnwf3B6bAQ== +"@hashicorp/ember-flight-icons@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.0.2.tgz#8d884c07842a6c88df18ca680d7883a59222a2ba" + integrity sha512-WomQg1hw/IHA1N9hC77WbTNazVXqu2RdRoaVCGT99NTXQ4S7Bw7vhHheR4JAgt10ksMZFI3X/bJVHxFfjUCkSQ== dependencies: - "@hashicorp/flight-icons" "^2.11.1" + "@hashicorp/flight-icons" "^2.12.0" ember-auto-import "^2.4.2" ember-cli-babel "^7.26.11" ember-cli-htmlbars "^6.1.0" -"@hashicorp/flight-icons@^2.11.1": - version "2.11.1" - resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.11.1.tgz#0ef70fac396699fb1987afab9570786eda57e95b" - integrity sha512-JD3/o1jtru9b2nzMeYYABcQ3TCcBx3wv0XjibdjQ18yhZ4b3zW6v0sl9eRDl+wbfVfBeXLXuGabt7ZXWNaPCCA== +"@hashicorp/flight-icons@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.12.0.tgz#48bc21f21678668ffe9147b181a2991d8b151fc7" + integrity sha512-PhjTTHCjoq4EJirifbxLxnxXnCRf1NUAYZ1WnFW8i0yOmmax6fgjsJRPlf0VIGsR8R7isFpjuy6gJ5c7mNhE0w== "@humanwhocodes/config-array@^0.5.0": version "0.5.0" @@ -11908,6 +11911,11 @@ dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" +dialog-polyfill@^0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a" + integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w== + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -13230,6 +13238,16 @@ ember-compatibility-helpers@^1.2.5: fs-extra "^9.1.0" semver "^5.4.1" +ember-composable-helpers@^4.4.1: + version "4.5.0" + resolved "https://registry.yarnpkg.com/ember-composable-helpers/-/ember-composable-helpers-4.5.0.tgz#94febbdf4348e64f45f7a6f993f326e32540a61e" + integrity sha512-XjpDLyVPsLCy6kd5dIxZonOECCO6AA5sY5Hr6tYUbJg3s5ghFAiFWaNcYraYC+fL2yPJQAswwpfwGlQORUJZkw== + dependencies: + "@babel/core" "^7.0.0" + broccoli-funnel "2.0.1" + ember-cli-babel "^7.26.3" + resolve "^1.10.0" + ember-composable-helpers@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/ember-composable-helpers/-/ember-composable-helpers-5.0.0.tgz#055bab3a3e234ab2917499b1465e968c253ca885"