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"