diff --git a/addons/api/addon/generated/models/host-catalog.js b/addons/api/addon/generated/models/host-catalog.js index f1f78db34..0591352e2 100644 --- a/addons/api/addon/generated/models/host-catalog.js +++ b/addons/api/addon/generated/models/host-catalog.js @@ -54,4 +54,64 @@ export default class GeneratedHostCatalogModel extends BaseModel { readOnly: true, }) plugin; + + // AWS & Azure + + @attr('boolean', { + isNestedAttribute: true, + description: '', + }) + disable_credential_rotation; + + // AWS specific + + @attr('string', { + isNestedAttribute: true, + description: '', + }) + region; + + @attr('string', { + isNestedSecret: true, + description: '', + }) + access_key_id; + + @attr('string', { + isNestedSecret: true, + description: '', + }) + secret_access_key; + + // Azure specific + + @attr('string', { + isNestedAttribute: true, + description: '', + }) + tenant_id; + + @attr('string', { + isNestedAttribute: true, + description: '', + }) + client_id; + + @attr('string', { + isNestedAttribute: true, + description: '', + }) + subscription_id; + + @attr('string', { + isNestedSecret: true, + description: '', + }) + secret_id; + + @attr('string', { + isNestedSecret: true, + description: '', + }) + secret_value; } diff --git a/addons/api/addon/serializers/application.js b/addons/api/addon/serializers/application.js index cb61f1a34..29f3fa1b4 100644 --- a/addons/api/addon/serializers/application.js +++ b/addons/api/addon/serializers/application.js @@ -56,6 +56,14 @@ export default class ApplicationSerializer extends RESTSerializer { json.attributes[key] = json[key]; delete json[key]; } + // Push nested secrets down into the secrets key + if (options.isNestedSecret) { + if (json[key]) { + if (!json.secrets) json.secrets = {}; + json.secrets[key] = json[key]; + } + delete json[key]; + } return value; } @@ -205,6 +213,7 @@ export default class ApplicationSerializer extends RESTSerializer { const scopeID = get(normalizedHash, 'scope.id'); if (scopeID) normalizedHash.scope.scope_id = scopeID; normalizedHash = this.normalizeNestedAttributes(typeClass, normalizedHash); + normalizedHash = this.normalizeNestedSecrets(typeClass, normalizedHash); return super.normalize(typeClass, normalizedHash); } @@ -224,14 +233,36 @@ export default class ApplicationSerializer extends RESTSerializer { * @return {Object} */ normalizeNestedAttributes(typeClass, hash) { - const normalizedHash = copy(hash, true); typeClass.attributes.forEach((attribute) => { const { isNestedAttribute } = attribute.options; - const attributeValue = normalizedHash.attributes?.[attribute.name]; + const attributeValue = hash.attributes?.[attribute.name]; if (isNestedAttribute && attributeValue) { - normalizedHash[attribute.name] = attributeValue; + hash[attribute.name] = attributeValue; } }); - return normalizedHash; + return hash; + } + + /** + * If an attribute is declared with the `isNestedSecret` option, it will + * be explicitly unset in the incoming payload to `null`. This helps ensure + * secret values do not linger in the UI. + * + * For example: + * `@attr('string', { isNestedSecret: true }) superSecretShhh;` + * + * @param {Model} typeClass + * @param {Object} hash + * @return {Object} + */ + normalizeNestedSecrets(typeClass, hash) { + typeClass.attributes.forEach((attribute) => { + const { + name, + options: { isNestedSecret }, + } = attribute; + if (isNestedSecret) hash[name] = null; + }); + return hash; } } diff --git a/addons/api/addon/serializers/host-catalog.js b/addons/api/addon/serializers/host-catalog.js new file mode 100644 index 000000000..77c78c680 --- /dev/null +++ b/addons/api/addon/serializers/host-catalog.js @@ -0,0 +1,59 @@ +import ApplicationSerializer from './application'; + +const fieldsByType = { + aws: [ + 'disable_credential_rotation', + 'region', + 'access_key_id', + 'secret_access_key', + ], + azure: [ + 'disable_credential_rotation', + 'tenant_id', + 'client_id', + 'subscription_id', + 'secret_id', + 'secret_value', + ], +}; + +export default class HostCatalogSerializer extends ApplicationSerializer { + // =methods + serialize(snapshot) { + const serialized = super.serialize(...arguments); + switch (snapshot.record.compositeType) { + case 'static': + return this.serializeStatic(...arguments); + default: + return serialized; + } + } + + serializeAttribute(snapshot, json, key, attribute) { + const value = super.serializeAttribute(...arguments); + const { isPlugin, compositeType } = snapshot.record; + const { options } = attribute; + // Nested conditionals for readability + // This deletes any fields that don't belong to the record type + if (isPlugin && options.isNestedAttribute && json.attributes) { + // The key must be included in the fieldsByType list above + if (!fieldsByType[compositeType].includes(key)) + delete json.attributes[key]; + } + // This deletes any fields that don't belong to the record type + if (isPlugin && options.isNestedSecret && json.secrets) { + if (!fieldsByType[compositeType].includes(key)) delete json.secrets[key]; + } + + // new one for isNestedSecret + return value; + } + + serializeStatic() { + const serialized = super.serialize(...arguments); + // Delete unnecessary fields for static host-catalog + delete serialized.attributes; + delete serialized.secrets; + return serialized; + } +} diff --git a/addons/api/app/serializers/host-catalog.js b/addons/api/app/serializers/host-catalog.js new file mode 100644 index 000000000..e2818e2c7 --- /dev/null +++ b/addons/api/app/serializers/host-catalog.js @@ -0,0 +1 @@ +export { default } from 'api/serializers/host-catalog'; diff --git a/addons/api/mirage/factories/host-catalog.js b/addons/api/mirage/factories/host-catalog.js index 75bcc3928..3179f2c99 100644 --- a/addons/api/mirage/factories/host-catalog.js +++ b/addons/api/mirage/factories/host-catalog.js @@ -1,6 +1,7 @@ import factory from '../generated/factories/host-catalog'; import { trait } from 'ember-cli-mirage'; import permissions from '../helpers/permissions'; +import { datatype, address } from 'faker'; export default factory.extend({ authorized_actions: () => @@ -16,6 +17,26 @@ export default factory.extend({ 'host-sets': ['create', 'list'], }; }, + attributes() { + switch (this.plugin?.name) { + case 'aws': + return { + disable_credential_rotation: datatype.boolean(), + tenant_id: datatype.hexaDecimal(6), + client_id: datatype.hexaDecimal(6), + subscription_id: datatype.hexaDecimal(8), + secret_id: datatype.hexaDecimal(6), + secret_value: datatype.string(12), + }; + case 'azure': + return { + disable_credential_rotation: datatype.boolean(), + region: address.state(), + access_key_id: datatype.hexaDecimal(8), + secret_access_key: datatype.string(12), + }; + } + }, withChildren: trait({ afterCreate(hostCatalog, server) { const { scope, type } = hostCatalog; diff --git a/addons/api/mirage/scenarios/default.js b/addons/api/mirage/scenarios/default.js index 9291f8a3e..51175f672 100644 --- a/addons/api/mirage/scenarios/default.js +++ b/addons/api/mirage/scenarios/default.js @@ -46,7 +46,7 @@ export default function (server) { // Other resources server.schema.scopes.where({ type: 'project' }).models.forEach((scope) => { - server.createList('host-catalog', 2, { scope }, 'withChildren'); + server.createList('host-catalog', 4, { scope }, 'withChildren'); server.createList('credential-store', 3, { scope }, 'withAssociations'); server.createList('target', 2, { scope }, 'withAssociations'); // Sessions have target data. Create it after targets. diff --git a/addons/api/tests/unit/serializers/host-catalog-test.js b/addons/api/tests/unit/serializers/host-catalog-test.js new file mode 100644 index 000000000..d82869348 --- /dev/null +++ b/addons/api/tests/unit/serializers/host-catalog-test.js @@ -0,0 +1,174 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | host catalog', function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('host-catalog'); + + assert.ok(serializer); + }); + + test('it serializes records', function (assert) { + const store = this.owner.lookup('service:store'); + const record = store.createRecord('host-catalog', {}); + + assert.ok(record.serialize()); + }); + + test('it serializes a static host-catalog as expected', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const record = store.createRecord('host-catalog', { + name: 'static', + description: 'this is a static host-catalog', + type: 'static', + }); + const expectedResult = { + name: 'static', + description: 'this is a static host-catalog', + type: 'static', + }; + assert.deepEqual(record.serialize(), expectedResult); + }); + + test('it serializes a new aws plugin as expected, ignoring azure fields', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const record = store.createRecord('host-catalog', { + compositeType: 'aws', + name: 'AWS', + description: 'this is a Aws plugin host-catalog', + disable_credential_rotation: true, + // these are AWS fields and should be included + region: 'spain', + access_key_id: 'foobars', + secret_access_key: 'testing', + // these are Azure fields and should be excluded + tenant_id: 'a1b2c3', + client_id: 'a1b2c3', + subscription_id: 'a1b2c3', + secret_id: 'a1b2c3', + secret_value: 'a1b2c3', + }); + const expectedResult = { + name: 'AWS', + description: 'this is a Aws plugin host-catalog', + type: 'plugin', + attributes: { + disable_credential_rotation: true, + region: 'spain', + }, + secrets: { + access_key_id: 'foobars', + secret_access_key: 'testing', + }, + }; + assert.deepEqual(record.serialize(), expectedResult); + }); + + test('it serializes a new azure record as expected, ignoring aws fields', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const record = store.createRecord('host-catalog', { + compositeType: 'azure', + name: 'Azure', + description: 'this is a Azure plugin host-catalog', + disable_credential_rotation: true, + region: 'spain', + access_key_id: 'foobars', + secret_access_key: 'testing', + // these are Azure fields and should be excluded + tenant_id: 'a1b2c3', + client_id: 'a1b2c3', + subscription_id: 'a1b2c3', + secret_id: 'a1b2c3', + secret_value: 'a1b2c3', + }); + const expectedResult = { + name: 'Azure', + description: 'this is a Azure plugin host-catalog', + type: 'plugin', + attributes: { + disable_credential_rotation: true, + tenant_id: 'a1b2c3', + client_id: 'a1b2c3', + subscription_id: 'a1b2c3', + }, + secrets: { + secret_id: 'a1b2c3', + secret_value: 'a1b2c3', + }, + }; + assert.deepEqual(record.serialize(), expectedResult); + }); + + test('it serializes an existing aws record correctly', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '1', + type: 'host-catalog', + attributes: { + type: 'plugin', + name: 'aws', + description: 'test description', + plugin: { + name: 'aws', + }, + region: 'andorra', + }, + }, + }); + const record = store.peekRecord('host-catalog', '1'); + const expectedResult = { + type: 'plugin', + name: 'aws', + description: 'test description', + attributes: { + disable_credential_rotation: false, + region: 'andorra', + }, + }; + assert.deepEqual(record.serialize(), expectedResult); + }); + + test('it serializes an existing azure record correctly', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + store.push({ + data: { + id: '2', + type: 'host-catalog', + attributes: { + type: 'plugin', + name: 'azure', + description: 'test description', + plugin: { + name: 'azure', + }, + tenant_id: 'a1b2c3', + client_id: 'foobars12', + subscription_id: 'barsfoo21', + }, + }, + }); + const record = store.peekRecord('host-catalog', '2'); + const expectedResult = { + type: 'plugin', + name: 'azure', + description: 'test description', + attributes: { + disable_credential_rotation: false, + tenant_id: 'a1b2c3', + client_id: 'foobars12', + subscription_id: 'barsfoo21', + }, + }; + assert.deepEqual(record.serialize(), expectedResult); + }); +});