diff --git a/addons/api/addon/models/credential.js b/addons/api/addon/models/credential.js index 63ed31628..e4e4bd21b 100644 --- a/addons/api/addon/models/credential.js +++ b/addons/api/addon/models/credential.js @@ -8,7 +8,16 @@ import GeneratedCredentialModel from '../generated/models/credential'; /** * Supported Credential types. */ -export const types = ['username_password', 'ssh_private_key', 'json']; + +export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password'; +export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key'; +export const TYPE_CREDENTIAL_JSON = 'json'; + +export const TYPES_CREDENTIAL = Object.freeze([ + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_JSON, +]); export default class CredentialModel extends GeneratedCredentialModel { // =attributes @@ -28,7 +37,7 @@ export default class CredentialModel extends GeneratedCredentialModel { * @type {boolean} */ get isJSON() { - return this.type === 'json'; + return this.type === TYPE_CREDENTIAL_JSON; } /** @@ -36,6 +45,6 @@ export default class CredentialModel extends GeneratedCredentialModel { * @type {boolean} */ get isUnknown() { - return !types.includes(this.type); + return !TYPES_CREDENTIAL.includes(this.type); } } diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js index 8e3dbef9a..7ec0b10b1 100644 --- a/addons/api/addon/models/session.js +++ b/addons/api/addon/models/session.js @@ -10,6 +10,7 @@ import { A } from '@ember/array'; import { inject as service } from '@ember/service'; import { flattenObject } from '../utils/flatten-nested-object'; import { TYPES_CREDENTIAL_LIBRARY } from 'api/models/credential-library'; +import { TYPE_CREDENTIAL_JSON } from 'api/models/credential'; export const STATUS_SESSION_ACTIVE = 'active'; export const STATUS_SESSION_PENDING = 'pending'; @@ -36,12 +37,14 @@ export class SessionCredential { name; description; type; + credentialtype; - constructor(id, name, description, type) { + constructor(id, name, description, type, credentialtype) { this.id = id; this.name = name; this.description = description; this.type = type; + this.credentialType = credentialtype; } }; @@ -77,7 +80,11 @@ export class SessionCredential { get secrets() { if (this.#payloadSecret?.decoded) { const secretJSON = this.#payloadSecret.decoded; - return this.extractSecrets(secretJSON, this.source.type); + return this.extractSecrets( + secretJSON, + this.source.type, + this.source.credentialType, + ); } else { // decode from base64 const decodedString = atob(this.#payloadSecret.raw); @@ -87,16 +94,22 @@ export class SessionCredential { /** * Extracts secrets from the payload secret JSON object. - * We only need to flatten the object if the type is vault-generic - * or vault-ssh-certificate and we only need the data object. + * We only need to flatten the object if the type is vault or if its a json type credential. * @param {object} secretJSON - The payload secret JSON object. + * @param {string} type - The credential source type. + * @param {string} credentialType - The credential type. * @returns {SessionCredential.SecretItem[]} - The array of secret items. */ - extractSecrets(secretJSON, type) { - const source = - TYPES_CREDENTIAL_LIBRARY.includes(type) && secretJSON?.data - ? flattenObject(secretJSON?.data) - : secretJSON; + extractSecrets(secretJSON, type, credentialType) { + let source; + if (credentialType === TYPE_CREDENTIAL_JSON) { + source = flattenObject(secretJSON); + } else if (TYPES_CREDENTIAL_LIBRARY.includes(type) && secretJSON?.data) { + source = flattenObject(secretJSON.data); + } else { + source = secretJSON; + } + return Object.entries(source).map( ([key, value]) => new SessionCredential.SecretItem(key, value), ); @@ -105,8 +118,15 @@ export class SessionCredential { // =methods constructor(cred) { - const { id, name, description, type } = cred.credential_source; - this.source = new SessionCredential.Source(id, name, description, type); + const { id, name, description, type, credential_type } = + cred.credential_source; + this.source = new SessionCredential.Source( + id, + name, + description, + type, + credential_type, + ); this.#payloadSecret = cred.secret; this.rawCredential = cred; } diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js index 34b300e2c..bbc285ac0 100644 --- a/addons/api/mirage/scenarios/ipc.js +++ b/addons/api/mirage/scenarios/ipc.js @@ -89,12 +89,14 @@ export default function initializeMockIPC(server, config) { name: 'Credential Library 0', description: 'Source Description', credential_store_id: 'csvlt_Q1HFGt7Jpm', - type: 'vault', + type: 'vault-generic', }, secret: { raw: 'eyJhcnJheSI6WyJvbmUiLCJ0d28iLCJ0aHJlZSIsIm9uZSIsInR3byIsInRocmVlIiwib25lIiwidHdvIiwidGhyZWUiLCJvbmUiLCJ0d28iLCJ0aHJlZSJdLCJuZXN0ZWQiOnsiYm9vbCI6dHJ1ZSwibG9uZyI6IjEyMjM1MzQ1NmFzZWRmYTQzd3J0ZjIzNGYyM2FzZGdmYXNkZnJnYXdzZWZhd3NlZnNkZjQiLCJzZWNlcmV0Ijoic28gbmVzdGVkIn0sInRlc3QiOiJwaHJhc2UifQ', decoded: { data: { + backslash: 'password\\withslash\\', + backslash1: 'password\\fwithslash\\f', password: '123', username: 'test', email: { @@ -127,6 +129,7 @@ export default function initializeMockIPC(server, config) { nested_secret: { session_token: 'ZXCVBNMLKJHGFDSAQWERTYUIOP0987654321', complex_nest: { + blackslash: 'password\\withslash\\', hash: 'qazxswedcvfrtgbnjhyujm.1234567890', }, }, diff --git a/ui/admin/app/components/form/credential/index.js b/ui/admin/app/components/form/credential/index.js index 4b491a6c8..93f2a2974 100644 --- a/ui/admin/app/components/form/credential/index.js +++ b/ui/admin/app/components/form/credential/index.js @@ -5,7 +5,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; -import { types } from 'api/models/credential'; +import { TYPES_CREDENTIAL } from 'api/models/credential'; export default class FormCredentialComponent extends Component { // =services @@ -18,7 +18,7 @@ export default class FormCredentialComponent extends Component { */ get credentialTypes() { return this.features.isEnabled('json-credentials') - ? types - : types.filter((type) => type !== 'json'); + ? TYPES_CREDENTIAL + : TYPES_CREDENTIAL.filter((type) => type !== 'json'); } } diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js index 892928b14..0325fecfd 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js @@ -148,13 +148,125 @@ module('Acceptance | projects | sessions | session', function (hooks) { await click(TARGET_CONNECT_BUTTON); assert.strictEqual(currentURL(), urls.session); - assert.dom('credential-panel-header').doesNotExist(); - assert.dom('credential-panel-body').doesNotExist(); + assert.dom('.credential-panel-header').doesNotExist(); + assert.dom('.credential-panel-body').doesNotExist(); assert .dom('[data-test-no-credentials]') .hasText(`Connected You can now access ${instances.target.name}`); }); + test('visiting session with vault type credentials should display nested data in a key/value format without escape characters', async function (assert) { + this.ipcStub.withArgs('cliExists').returns(true); + this.ipcStub.withArgs('connect').returns({ + session_id: instances.session.id, + address: 'a_123', + port: 'p_123', + protocol: 'tcp', + credentials: [ + { + credential_source: { + id: 'clvlt_4cvscMTl0N', + name: 'Credential Library 0', + description: 'Source Description', + credential_store_id: 'csvlt_Q1HFGt7Jpm', + type: 'vault-generic', + }, + secret: { + raw: 'eyJhcnJheSI6WyJvbmUiLCJ0d28iLCJ0aHJlZSIsIm9uZSIsInR3byIsInRocmVlIiwib25lIiwidHdvIiwidGhyZWUiLCJvbmUiLCJ0d28iLCJ0aHJlZSJdLCJuZXN0ZWQiOnsiYm9vbCI6dHJ1ZSwibG9uZyI6IjEyMjM1MzQ1NmFzZWRmYTQzd3J0ZjIzNGYyM2FzZGdmYXNkZnJnYXdzZWZhd3NlZnNkZjQiLCJzZWNlcmV0Ijoic28gbmVzdGVkIn0sInRlc3QiOiJwaHJhc2UifQ', + decoded: { + data: { + backslash: 'password\\with\\tslash', + email: { + address: 'test.com', + }, + }, + metadata: { + created_time: '2024-04-12T18:38:36.226715555Z', + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 8, + }, + }, + }, + }, + ], + }); + + await visit(urls.target); + await click(TARGET_CONNECT_BUTTON); + assert.strictEqual(currentURL(), urls.session); + + assert.dom('.credential-panel-header').exists(); + assert.dom('.credential-panel-body').exists(); + assert.dom('.credential-secret').exists(); + + // Check if nested data is displayed in a key/value format without escape characters + assert.dom('.secret-container:nth-of-type(1)').includesText('backslash'); + await click('.secret-container:nth-of-type(1) .hds-icon'); + const expectedOutput = String.raw`password\with\tslash`; + assert + .dom('.secret-container:nth-of-type(1) .secret-content') + .hasText(expectedOutput); + + assert + .dom('.secret-container:nth-of-type(2)') + .includesText('email.address'); + await click('.secret-container:nth-of-type(2) .hds-icon'); + assert + .dom('.secret-container:nth-of-type(2) .secret-content') + .hasText('test.com'); + }); + + test('visiting session with static type credentials should display nested data in a key/value format without escape characters', async function (assert) { + this.ipcStub.withArgs('cliExists').returns(true); + this.ipcStub.withArgs('connect').returns({ + session_id: instances.session.id, + address: 'a_123', + port: 'p_123', + protocol: 'tcp', + credentials: [ + { + credential_source: { + id: 'credjson_7cKBbBEkC3', + credential_store_id: 'csst_yzlsot2pum', + type: 'static', + credential_type: 'json', + }, + secret: { + raw: 'eyJhcnJheSI6WyJvbmUiLCJ0d28iLCJ0aHJlZSIsIm9uZSIsInR3byIsInRocmVlIiwib25lIiwidHdvIiwidGhyZWUiLCJvbmUiLCJ0d28iLCJ0aHJlZSJdLCJuZXN0ZWQiOnsiYm9vbCI6dHJ1ZSwibG9uZyI6IjEyMjM1MzQ1NmFzZWRmYTQzd3J0ZjIzNGYyM2FzZGdmYXNkZnJnYXdzZWZhd3NlZnNkZjQiLCJzZWNlcmV0Ijoic28gbmVzdGVkIn0sInRlc3QiOiJwaHJhc2UifQ==', + decoded: { + nested_secret: { + complex_nest: { + blackslash: 'password\\with\\tslash', + }, + }, + }, + }, + }, + ], + }); + + await visit(urls.target); + await click(TARGET_CONNECT_BUTTON); + assert.strictEqual(currentURL(), urls.session); + + assert.dom('.credential-panel-header').exists(); + assert.dom('.credential-panel-body').exists(); + assert.dom('.credential-secret').exists(); + + // Check if nested data is displayed in a key/value format without escape characters + const expectedOutput = String.raw`password\with\tslash`; + assert + .dom('.secret-container:nth-of-type(1)') + .includesText('nested_secret.complex_nest.blackslash'); + await click('.secret-container:nth-of-type(1) .hds-icon'); + + assert + .dom('.secret-container:nth-of-type(1) .secret-content') + .hasText(expectedOutput); + }); + test('visiting a session that does not have permissions to read a host', async function (assert) { assert.expect(1); this.server.get('/hosts/:id', () => new Response(403));