diff --git a/addons/api/addon/models/target.js b/addons/api/addon/models/target.js index 0e15a8842..0e3f61f7a 100644 --- a/addons/api/addon/models/target.js +++ b/addons/api/addon/models/target.js @@ -48,17 +48,21 @@ export default class TargetModel extends GeneratedTargetModel { } /** - * An array of resolved credential library instances. Model instances + * An array of resolved credential library and credential instances. Model instances * must already be loaded into the store (this method will not load unloaded * instances). Unresolvable instances are excluded from the array. - * @type {[CredentialLibraryModel]} + * @type {[CredentialLibraryModel, CredentialModel]} */ @computed('application_credential_source_ids.[]', 'store') - get credentialLibraries() { + get credentialSources() { return this.application_credential_source_ids - .map((source) => - this.store.peekRecord('credential-library', source.value) - ) + .map((source) => { + if (source.value.startsWith('cred')) { + return this.store.peekRecord('credential', source.value); + } else { + return this.store.peekRecord('credential-library', source.value); + } + }) .filter(Boolean); } @@ -150,17 +154,17 @@ export default class TargetModel extends GeneratedTargetModel { } /** - * Adds credential libraries via the `add-credential-sources` method. + * Adds credential sources via the `add-credential-sources` method. * See serializer and adapter for more information. - * @param {[string]} credentialLibraryIDs + * @param {[string]} credentialSourceIDs * @param {object} options * @param {object} options.adapterOptions * @return {Promise} */ - addCredentialSources(credentialLibraryIDs, options = { adapterOptions: {} }) { + addCredentialSources(credentialSourceIDs, options = { adapterOptions: {} }) { const defaultAdapterOptions = { method: 'add-credential-sources', - credentialLibraryIDs, + credentialSourceIDs, }; // There is no "deep merge" in ES. return this.save({ @@ -173,20 +177,20 @@ export default class TargetModel extends GeneratedTargetModel { } /** - * Delete credential libraries via the `remove-credential-sources` method. + * Delete credential libraries and credentials via the `remove-credential-sources` method. * See serializer and adapter for more information. - * @param {[string]} credentialLibraryIDs + * @param {[string]} credentialSourceIDs * @param {object} options * @param {object} options.adapterOptions * @return {Promise} */ removeCredentialSources( - credentialLibraryIDs, + credentialSourceIDs, options = { adapterOptions: {} } ) { const defaultAdapterOptions = { method: 'remove-credential-sources', - credentialLibraryIDs, + credentialSourceIDs, }; // There is no "deep merge" in ES. return this.save({ @@ -199,13 +203,13 @@ export default class TargetModel extends GeneratedTargetModel { } /** - * Delete a single credential library set via the `remove-credential-sources` method. - * @param {number} credentialLibraryID + * Delete a single credential library/credential set via the `remove-credential-sources` method. + * @param {number} credentialSourceID * @param {object} options * @return {Promise} */ - removeCredentialSource(credentialLibraryID, options) { - return this.removeCredentialSources([credentialLibraryID], options); + removeCredentialSource(credentialSourceID, options) { + return this.removeCredentialSources([credentialSourceID], options); } /** diff --git a/addons/api/addon/serializers/target.js b/addons/api/addon/serializers/target.js index 8bf8b6882..df277ed34 100644 --- a/addons/api/addon/serializers/target.js +++ b/addons/api/addon/serializers/target.js @@ -7,10 +7,10 @@ export default class TargetSerializer extends ApplicationSerializer { * If `adapterOptions.hostSets` is set to an array of host set models, * the resulting target serialization should include **only host sets** * and the version. - * If `adapterOptions.credentialLibraries` is set to an array of - * credential library models, the resulting target serialization should - * include **only credential libraries** and the version. - * Normally, neither host sets or credential libraries are not serialized. + * If `adapterOptions.credentialSources` is set to an array of + * credential library and credential models, the resulting target serialization should + * include **only credential libraries and crendentials** and the version. + * Normally, neither host sets or credential libraries or credentials are not serialized. * @override * @param {Snapshot} snapshot * @return {object} @@ -21,7 +21,7 @@ export default class TargetSerializer extends ApplicationSerializer { if (hostSourceIDs) { serialized = this.serializeWithHostSources(snapshot, hostSourceIDs); } - const credentialSourceIDs = snapshot?.adapterOptions?.credentialLibraryIDs; + const credentialSourceIDs = snapshot?.adapterOptions?.credentialSourceIDs; if (credentialSourceIDs) serialized = this.serializeWithCredentialSources( snapshot, diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index b8d71212d..166ba8f74 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -464,19 +464,31 @@ export default function () { // If adding credential sources, push them into the array if (method === 'add-credential-sources') { updatedAttrs.credentialLibraryIds = target.credentialLibraryIds; + updatedAttrs.credentialIds = target.credentialIds; attrs.applicationCredentialSourceIds.forEach((id) => { - if (!updatedAttrs.credentialLibraryIds.includes(id)) { - updatedAttrs.credentialLibraryIds.push(id); + if ( + !updatedAttrs.credentialLibraryIds.includes(id) || + !updatedAttrs.credentialIds.includes(id) + ) { + if (id.startsWith('cred')) { + updatedAttrs.credentialIds.push(id); + } else { + updatedAttrs.credentialLibraryIds.push(id); + } } }); } // If deleting credential sources, filter them out of the array if (method === 'remove-credential-sources') { updatedAttrs.credentialLibraryIds = target.credentialLibraryIds; + updatedAttrs.credentialIds = target.credentialIds; updatedAttrs.credentialLibraryIds = updatedAttrs.credentialLibraryIds.filter((id) => { return !attrs.applicationCredentialSourceIds.includes(id); }); + updatedAttrs.credentialIds = updatedAttrs.credentialIds.filter((id) => { + return !attrs.applicationCredentialSourceIds.includes(id); + }); } return target.update(updatedAttrs); } diff --git a/addons/api/mirage/factories/target.js b/addons/api/mirage/factories/target.js index 55d12aad8..3b344b3ed 100644 --- a/addons/api/mirage/factories/target.js +++ b/addons/api/mirage/factories/target.js @@ -49,7 +49,9 @@ export default factory.extend({ */ withAssociations: trait({ afterCreate(target, server) { - let randomlySelectedHostSets, randomlySelectedCredentialLibraries; + let randomlySelectedHostSets, + randomlySelectedCredentialLibraries, + randomlySelectedCredentials; randomlySelectedHostSets = server.schema.hostSets // BLERG: fun fact, for no reason at all, the element passed // into a where function is not a full model instance, as you might @@ -64,10 +66,13 @@ export default factory.extend({ (credentialLibrary) => credentialLibrary.scopeId === target.scope.id ) .models.filter(() => randomBoolean()); - + randomlySelectedCredentials = server.schema.credentials + .where((credential) => credential.scopeId === target.scope.id) + .models.filter(() => randomBoolean()); target.update({ hostSets: randomlySelectedHostSets, credentialLibraries: randomlySelectedCredentialLibraries, + credentials: randomlySelectedCredentials, }); }, }), diff --git a/addons/api/mirage/models/target.js b/addons/api/mirage/models/target.js index d6a7b8ab0..b7a6a36b2 100644 --- a/addons/api/mirage/models/target.js +++ b/addons/api/mirage/models/target.js @@ -5,4 +5,5 @@ export default Model.extend({ scope: belongsTo(), hostSets: hasMany(), credentialLibraries: hasMany(), + credentials: hasMany(), }); diff --git a/addons/api/mirage/serializers/target.js b/addons/api/mirage/serializers/target.js index 00249f164..3ed7be347 100644 --- a/addons/api/mirage/serializers/target.js +++ b/addons/api/mirage/serializers/target.js @@ -23,8 +23,11 @@ export default ApplicationSerializer.extend({ return { id: host_set_id, host_catalog_id }; }); } - if (model.credentialLibraryIds?.length) { - json.application_credential_source_ids = model.credentialLibraryIds; + if (model.credentialLibraryIds?.length || model.credentialIds?.length) { + json.application_credential_source_ids = [ + ...model.credentialLibraryIds, + ...model.credentialIds, + ]; } return json; }, diff --git a/addons/api/tests/unit/models/target-test.js b/addons/api/tests/unit/models/target-test.js index fc5b7c489..cee097ca6 100644 --- a/addons/api/tests/unit/models/target-test.js +++ b/addons/api/tests/unit/models/target-test.js @@ -231,7 +231,7 @@ module('Unit | Model | target', function (hooks) { ); }); - test('it has a `credentialLibraries` array of resolved model instances (if those instances are already in the store)', function (assert) { + test('it has a `credentialSources` array of resolved model instances (if those instances are already in the store)', function (assert) { assert.expect(4); const store = this.owner.lookup('service:store'); store.push({ @@ -250,9 +250,9 @@ module('Unit | Model | target', function (hooks) { 'Target has two entires in application_credential_source_ids' ); assert.strictEqual( - target.credentialLibraries.length, + target.credentialSources.length, 0, - 'Target has no resolved credentialLibraries because they are not loaded yet' + 'Target has no resolved credentialSources because they are not loaded yet' ); store.push({ data: { @@ -268,7 +268,7 @@ module('Unit | Model | target', function (hooks) { attributes: {}, }, }); - // Since `credentialLibraries` is computed on `application_credential_source_ids`, + // Since `credentialSources` is computed on `application_credential_source_ids`, // not the store itself, it's necessary to do this assignment to kick-off the // computed update. /* eslint-disable no-self-assign */ @@ -281,9 +281,9 @@ module('Unit | Model | target', function (hooks) { 'Target has two entires in application_credential_source_ids' ); assert.strictEqual( - target.credentialLibraries.length, + target.credentialSources.length, 2, - 'Target has two resolved credentialLibraries' + 'Target has two resolved credentialSources' ); }); diff --git a/addons/api/tests/unit/serializers/target-test.js b/addons/api/tests/unit/serializers/target-test.js index 42acd24b6..28dad5d55 100644 --- a/addons/api/tests/unit/serializers/target-test.js +++ b/addons/api/tests/unit/serializers/target-test.js @@ -66,7 +66,7 @@ module('Unit | Serializer | target', function (hooks) { }); }); - test('it serializes only credential sources and version when an `adapterOptions.credentialLibraryIDs` array is passed', function (assert) { + test('it serializes only credential sources and version when an `adapterOptions.credentialSourceIDs` array is passed', function (assert) { assert.expect(1); const store = this.owner.lookup('service:store'); const serializer = store.serializerFor('target'); @@ -78,7 +78,7 @@ module('Unit | Serializer | target', function (hooks) { }); const snapshot = record._createSnapshot(); snapshot.adapterOptions = { - credentialLibraryIDs: ['4', '5'], + credentialSourceIDs: ['4', '5'], }; const serializedRecord = serializer.serialize(snapshot); assert.deepEqual(serializedRecord, { diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 2579a0763..fcc6e664d 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -378,6 +378,7 @@ credential-library: types: vault: Vault static: Static + username_password: Username & Password credential: title: Credential title_plural: Credentials diff --git a/ui/admin/app/routes/scopes/scope/targets/target/credential-sources.js b/ui/admin/app/routes/scopes/scope/targets/target/credential-sources.js index c4dc5ff37..b196734b1 100644 --- a/ui/admin/app/routes/scopes/scope/targets/target/credential-sources.js +++ b/ui/admin/app/routes/scopes/scope/targets/target/credential-sources.js @@ -10,15 +10,19 @@ export default class ScopesScopeTargetsTargetCredentialSourcesRoute extends Rout /** * Loads all credential libraries under the current target. - * @return {Promise{[CredentialLibraryModel]}} + * @return {Promise{[CredentialLibraryModel, CredentialModel]}} */ beforeModel() { const { application_credential_source_ids: sourceIDFragments } = this.modelFor('scopes.scope.targets.target'); return all( sourceIDFragments.map(({ value }) => { - const isStoreOrLibrary = !value.includes('cred'); - if (isStoreOrLibrary) { + const isStatic = value.includes('cred'); + if (isStatic) { + return this.store.findRecord('credential', value, { + reload: true, + }); + } else { return this.store.findRecord('credential-library', value, { reload: true, }); diff --git a/ui/admin/app/templates/scopes/scope/targets/target/credential-sources.hbs b/ui/admin/app/templates/scopes/scope/targets/target/credential-sources.hbs index 31abd9a7d..21468a043 100644 --- a/ui/admin/app/templates/scopes/scope/targets/target/credential-sources.hbs +++ b/ui/admin/app/templates/scopes/scope/targets/target/credential-sources.hbs @@ -19,7 +19,7 @@ - {{#if @model.credentialLibraries}} + {{#if @model.credentialSources}} @@ -31,35 +31,48 @@ - {{#each @model.credentialLibraries as |credentialLibrary|}} + {{#each @model.credentialSources as |credentialSource|}} - - {{credentialLibrary.displayName}} - -

{{credentialLibrary.description}}

+ {{#if (eq credentialSource.type 'username_password')}} + + {{credentialSource.displayName}} + + {{else}} + + {{credentialSource.displayName}} + + {{/if}} + +

{{credentialSource.description}}

{{t (concat - 'resources.credential-library.types.' - credentialLibrary.type + 'resources.target.credential-source.types.' + credentialSource.type ) }} - {{credentialLibrary.id}} + {{credentialSource.id}} @@ -77,7 +90,7 @@ {{on 'click' (route-action - 'removeCredentialSource' @model credentialLibrary + 'removeCredentialSource' @model credentialSource ) }} > diff --git a/ui/admin/tests/acceptance/targets/credential-sources-test.js b/ui/admin/tests/acceptance/targets/credential-sources-test.js index 56d375483..1105e9607 100644 --- a/ui/admin/tests/acceptance/targets/credential-sources-test.js +++ b/ui/admin/tests/acceptance/targets/credential-sources-test.js @@ -30,6 +30,7 @@ module('Acceptance | targets | credential sources', function (hooks) { credentialLibraries: null, credentialLibrary: null, credentials: null, + credential: null, }; const urls = { globalScope: null, @@ -74,6 +75,7 @@ module('Acceptance | targets | credential sources', function (hooks) { } ); instances.credentialLibrary = instances.credentialLibraries[0]; + instances.credential = instances.credentials[0]; instances.target = this.server.create('target', { scope: instances.scopes.project, credentialLibraries: instances.credentialLibraries, @@ -86,6 +88,7 @@ module('Acceptance | targets | credential sources', function (hooks) { urls.target = `${urls.targets}/${instances.target.id}`; urls.credentialSources = `${urls.target}/credential-sources`; urls.credentialLibrary = `${urls.projectScope}/credential-stores/${instances.credentialLibrary.credentialStoreId}/credential-libraries/${instances.credentialLibrary.id}`; + urls.credential = `${urls.projectScope}/credential-stores/${instances.credential.credentialStoreId}/credentials/${instances.credential.id}`; urls.addCredentialSources = `${urls.target}/add-credential-sources`; // Generate resource counter getCredentialLibraryCount = () => @@ -104,7 +107,7 @@ module('Acceptance | targets | credential sources', function (hooks) { assert.strictEqual(findAll('tbody tr').length, getCredentialLibraryCount()); }); - test('can navigate to a credential library', async function (assert) { + test('can navigate to a vault type credential library', async function (assert) { assert.expect(1); await visit(urls.credentialSources); await click('main tbody .rose-table-header-cell:nth-child(1) a'); @@ -112,6 +115,18 @@ module('Acceptance | targets | credential sources', function (hooks) { assert.strictEqual(currentURL(), urls.credentialLibrary); }); + test('can navigate to a username & password type credential', async function (assert) { + assert.expect(1); + instances.target.update({ + credentialLibraries: [], + credentials: instances.credentials, + }); + await visit(urls.credentialSources); + await click('main tbody .rose-table-header-cell:nth-child(1) a'); + await a11yAudit(); + assert.strictEqual(currentURL(), urls.credential); + }); + test('visiting add credential sources', async function (assert) { assert.expect(1); await visit(urls.addCredentialSources); @@ -165,19 +180,46 @@ module('Acceptance | targets | credential sources', function (hooks) { assert.strictEqual(currentURL(), urls.credentialSources); }); - test('can select and save credential sources to add', async function (assert) { + test('can select and save a vault type credential library to add', async function (assert) { assert.expect(4); instances.target.update({ credentialLibraries: [] }); await visit(urls.credentialSources); assert.strictEqual(findAll('tbody tr').length, 0); await visit(urls.addCredentialSources); assert.strictEqual(findAll('tbody tr').length, credentialSourceCount); - await click('tbody label'); + await click('tbody tr:first-child label'); await click('form [type="submit"]'); assert.strictEqual(currentURL(), urls.credentialSources); assert.strictEqual(findAll('tbody tr').length, 1); }); + test('can select and save a username & password type credential to add', async function (assert) { + assert.expect(4); + instances.target.update({ credentialLibraries: [] }); + await visit(urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, 0); + await visit(urls.addCredentialSources); + assert.strictEqual(findAll('tbody tr').length, credentialSourceCount); + await click('tbody tr:last-child label'); + await click('form [type="submit"]'); + assert.strictEqual(currentURL(), urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, 1); + }); + + test('can select and save both a credential-library and a credential to add', async function (assert) { + assert.expect(4); + instances.target.update({ credentialLibraries: [] }); + await visit(urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, 0); + await visit(urls.addCredentialSources); + assert.strictEqual(findAll('tbody tr').length, credentialSourceCount); + await click('tbody tr:last-child label'); + await click('tbody tr:first-child label'); + await click('form [type="submit"]'); + assert.strictEqual(currentURL(), urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, 2); + }); + test('cannot add credential sources without proper authorization', async function (assert) { assert.expect(1); instances.target.authorized_actions = @@ -201,6 +243,20 @@ module('Acceptance | targets | credential sources', function (hooks) { assert.strictEqual(findAll('tbody tr').length, 0); }); + test('can select multiple credential sources to add and cancel', async function (assert) { + assert.expect(4); + instances.target.update({ credentialLibraries: [] }); + await visit(urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, 0); + await visit(urls.addCredentialSources); + assert.strictEqual(findAll('tbody tr').length, credentialSourceCount); + await click('tbody tr:last-child label'); + await click('tbody tr:first-child label'); + await click('form [type="button"]'); + assert.strictEqual(currentURL(), urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, 0); + }); + test('adding credential sources which errors displays error message', async function (assert) { assert.expect(1); this.server.post('/targets/:idMethod', () => { @@ -217,18 +273,38 @@ module('Acceptance | targets | credential sources', function (hooks) { }); instances.target.update({ credentialLibraries: [] }); await visit(urls.addCredentialSources); - await click('tbody label'); + await click('tbody tr:last-child label'); + await click('tbody tr:first-child label'); await click('form [type="submit"]'); assert.ok(find('[role="alert"]')); }); - test('can remove a credential library', async function (assert) { - assert.expect(2); - const count = getCredentialLibraryCount(); + test('can remove a vault type credential library', async function (assert) { + assert.expect(3); + const credentialLibraryCount = getCredentialLibraryCount(); + const credentialCount = getCredentialCount(); await visit(urls.credentialSources); - assert.strictEqual(findAll('tbody tr').length, count); + assert.strictEqual(findAll('tbody tr').length, credentialLibraryCount); await click('tbody tr .rose-dropdown-button-danger'); - assert.strictEqual(findAll('tbody tr').length, count - 1); + assert.strictEqual(findAll('tbody tr').length, credentialLibraryCount - 1); + await visit(urls.addCredentialSources); + assert.strictEqual(findAll('tbody tr').length, credentialCount + 1); + }); + + test('can remove a username & password type credential', async function (assert) { + assert.expect(3); + instances.target.update({ + credentialLibraries: [], + credentials: instances.credentials, + }); + const credentialCount = getCredentialCount(); + const credentialLibraryCount = getCredentialLibraryCount(); + await visit(urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, credentialCount); + await click('tbody tr .rose-dropdown-button-danger'); + assert.strictEqual(findAll('tbody tr').length, credentialCount - 1); + await visit(urls.addCredentialSources); + assert.strictEqual(findAll('tbody tr').length, credentialLibraryCount + 1); }); test('cannot remove credential libraries without proper authorization', async function (assert) { @@ -261,4 +337,29 @@ module('Acceptance | targets | credential sources', function (hooks) { await click('tbody tr .rose-dropdown-button-danger'); assert.ok(find('[role="alert"]')); }); + + test('removing a target credential which errors displays error messages', async function (assert) { + assert.expect(2); + instances.target.update({ + credentialLibraries: [], + credentials: instances.credentials, + }); + this.server.post('/targets/:idMethod', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: {}, + } + ); + }); + const count = getCredentialCount(); + await visit(urls.credentialSources); + assert.strictEqual(findAll('tbody tr').length, count); + await click('tbody tr .rose-dropdown-button-danger'); + assert.ok(find('[role="alert"]')); + }); });