From 30a5f2a7013c246a8034212c3c98410b8a236f47 Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Fri, 20 Jun 2025 09:52:22 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rdp=20form=20-=20create?= =?UTF-8?q?=20and=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/api/addon/abilities/target.js | 3 +- addons/core/translations/resources/en-us.yaml | 5 + .../components/form/target/details/index.hbs | 37 +++++- .../components/form/target/details/index.js | 83 +++++++++++- .../app/components/form/target/rdp/index.hbs | 13 ++ .../components/targets/target/nav/index.hbs | 2 +- .../app/routes/scopes/scope/targets/new.js | 21 ++- ui/admin/app/styles/app.scss | 5 + .../tests/acceptance/targets/create-test.js | 123 +++++++++++++++++- .../tests/acceptance/targets/selectors.js | 7 + .../tests/acceptance/targets/update-test.js | 66 ++++++++++ .../scopes/scope/targets/target/index-test.js | 22 ++++ 12 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 ui/admin/app/components/form/target/rdp/index.hbs create mode 100644 ui/admin/tests/unit/controllers/scopes/scope/targets/target/index-test.js diff --git a/addons/api/addon/abilities/target.js b/addons/api/addon/abilities/target.js index 56981c2d2..dcdf60635 100644 --- a/addons/api/addon/abilities/target.js +++ b/addons/api/addon/abilities/target.js @@ -44,7 +44,8 @@ export default class TargetAbility extends ModelAbility { */ get canAddInjectedApplicationCredentialSources() { return ( - this.model.isSSH && this.hasAuthorizedAction('add-credential-sources') + (this.model.isSSH || this.model.isRDP) && + this.hasAuthorizedAction('add-credential-sources') ); } /** diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index b6f70b277..7cb365157 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -750,6 +750,7 @@ target: default_client_port: label: Default Client Port help: The local proxy port on which to listen by default when a session is started on a client. + rdp-windows-error: 'Note: Windows OS prevents port 3389 from being used.' sidebar: label: Session Recording help: Record SSH sessions on this target and save them to a storage bucket. @@ -812,6 +813,10 @@ target: add: title: Add Injected Application Credential Sources description: Select injected Application credential sources to assign to this target. + alert: + title: Cannot connect to this target without injected application credentials + description: Injected application credentials are used by Boundary to sign in to remote resources without showing them to the end-user. + action: Add injected application credentials workers: description: You can customize how your workers route traffic to this target by setting up a filter that matches specific worker tags. If your target is in a private network, we recommend setting up an egress filter to specify a worker inside the network so that Boundary can access the host. Ingress and egress filters are allowed to select the same worker. messages: diff --git a/ui/admin/app/components/form/target/details/index.hbs b/ui/admin/app/components/form/target/details/index.hbs index da372fb69..fac6e0fcb 100644 --- a/ui/admin/app/components/form/target/details/index.hbs +++ b/ui/admin/app/components/form/target/details/index.hbs @@ -3,6 +3,31 @@ SPDX-License-Identifier: BUSL-1.1 }} +{{#if this.showInjectedApplicationCredentialAlert}} + + {{t + 'resources.target.injected-application-credential-source.messages.alert.title' + }} + {{t + 'resources.target.injected-application-credential-source.messages.alert.description' + }} + + +{{/if}} + - {{#if (and @model.isNew (feature-flag 'ssh-target'))}} + {{#if this.showTargetTypeRadioGroup}} {{t 'resources.target.form.default_client_port.label'}} {{t 'resources.target.form.default_client_port.help' }} - {{#if @model.errors.default_client_port}} + {{#if this.showDefaultClientPortError}} {{#each @model.errors.default_client_port as |error|}} {{error.message}} {{/each}} + {{#if this.showRDPWindowsDefaultClientPortError}} + + {{t 'resources.target.form.default_client_port.rdp-windows-error'}} + + {{/if}} {{/if}} diff --git a/ui/admin/app/components/form/target/details/index.js b/ui/admin/app/components/form/target/details/index.js index 24f6620d6..a9216fd99 100644 --- a/ui/admin/app/components/form/target/details/index.js +++ b/ui/admin/app/components/form/target/details/index.js @@ -4,17 +4,33 @@ */ import Component from '@glimmer/component'; -import { TYPES_TARGET } from 'api/models/target'; +import { + TYPE_TARGET_TCP, + TYPE_TARGET_RDP, + TYPE_TARGET_SSH, +} from 'api/models/target'; import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; -// NOTE: this is all a temporary solution till we have a resource type helper. -const types = [...TYPES_TARGET].reverse(); const icons = { ssh: 'terminal-screen', tcp: 'network', rdp: 'monitor', }; +const RDP_WINDOWS_INVALID_DEFAULT_CLIENT_PORT = '3389'; + export default class FormTargetComponent extends Component { + // attributes + + /** + * Flag to show the RDP Windows default client port error. + * This is set to true when the default client port is '3389' for RDP targets. + * It is used to indicate that the default client port is not recommended for Windows RDP targets. + * @type {boolean} + */ + @tracked showRDPWindowsDefaultClientPortError = false; + // =services @service features; @@ -24,6 +40,10 @@ export default class FormTargetComponent extends Component { * @type {object} */ get typeMetas() { + const types = [TYPE_TARGET_TCP]; + if (this.isSSHEnabled) types.push(TYPE_TARGET_SSH); + if (this.isRDPEnabled) types.push(TYPE_TARGET_RDP); + return types.map((type) => ({ type, icon: icons[type], @@ -38,4 +58,61 @@ export default class FormTargetComponent extends Component { get icon() { return icons[this.args.model.type]; } + + get isRDPEnabled() { + return this.features.isEnabled('rdp-target'); + } + + get isSSHEnabled() { + return this.features.isEnabled('ssh-target'); + } + + /** + * Checks if the default client port error should be shown. + * This is true if either the default client port `3389` error is shown or there is an error on the default client port field. + * @type {boolean} + */ + get showDefaultClientPortError() { + return ( + this.showRDPWindowsDefaultClientPortError || + this.args.model.get('errors.default_client_port') + ); + } + + /** + * Checks if the injected application credential alert should be shown for SSH and RDP targets. + * @type {boolean} + */ + get showInjectedApplicationCredentialAlert() { + return ( + !this.args.model.isNew && + this.args.model.injected_application_credential_source_ids.length === 0 && + this.args.model.type !== TYPE_TARGET_TCP + ); + } + + /** + * Checks if the target type radio group should be displayed. + * This is true if either the 'rdp-target' or 'ssh-target' feature is enabled. + * @returns {boolean} + * @type {boolean} + */ + get showTargetTypeRadioGroup() { + return (this.isRDPEnabled || this.isSSHEnabled) && this.args.model.isNew; + } + + // =actions + + /** + * Validates the default client port for the target. + * If the target type is RDP and the default client port is set to '3389', it sets the error flag. + * @param {Event} event + */ + @action + handleDefaultClientPortBlur(event) { + const defaultClientPort = event.target.value; + this.showRDPWindowsDefaultClientPortError = + defaultClientPort === RDP_WINDOWS_INVALID_DEFAULT_CLIENT_PORT && + this.args.model.type === TYPE_TARGET_RDP; + } } diff --git a/ui/admin/app/components/form/target/rdp/index.hbs b/ui/admin/app/components/form/target/rdp/index.hbs new file mode 100644 index 000000000..c1a6623ff --- /dev/null +++ b/ui/admin/app/components/form/target/rdp/index.hbs @@ -0,0 +1,13 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + \ No newline at end of file diff --git a/ui/admin/app/components/targets/target/nav/index.hbs b/ui/admin/app/components/targets/target/nav/index.hbs index c0f88501a..dac7a9702 100644 --- a/ui/admin/app/components/targets/target/nav/index.hbs +++ b/ui/admin/app/components/targets/target/nav/index.hbs @@ -16,7 +16,7 @@ {{t 'resources.target.brokered-credential-source.title_plural'}} - {{#if @model.isSSH}} + {{#if (or @model.isSSH @model.isRDP)}} diff --git a/ui/admin/app/routes/scopes/scope/targets/new.js b/ui/admin/app/routes/scopes/scope/targets/new.js index 65c29d94f..db40306fe 100644 --- a/ui/admin/app/routes/scopes/scope/targets/new.js +++ b/ui/admin/app/routes/scopes/scope/targets/new.js @@ -5,7 +5,11 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; -import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target'; +import { + TYPE_TARGET_TCP, + TYPE_TARGET_SSH, + TYPE_TARGET_RDP, +} from 'api/models/target'; export default class ScopesScopeTargetsNewRoute extends Route { // =services @@ -47,11 +51,16 @@ export default class ScopesScopeTargetsNewRoute extends Route { let name, description; const scopeModel = this.modelFor('scopes.scope'); - // default type is SSH if the feature is enabled, otherwise TCP - if (!type) - type = this.features.isEnabled('ssh-target') - ? TYPE_TARGET_SSH - : TYPE_TARGET_TCP; + // default type is RDP if the feature is enabled, or is SSH if feature enabled otherwise TCP + if (!type) { + if (this.features.isEnabled('rdp-target')) { + type = TYPE_TARGET_RDP; + } else if (this.features.isEnabled('ssh-target')) { + type = TYPE_TARGET_SSH; + } else { + type = TYPE_TARGET_TCP; + } + } if (this.currentModel?.isNew) { ({ name, description } = this.currentModel); diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index a0e112835..d3b50eba9 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -1020,3 +1020,8 @@ .dropdown-checkbox-list { margin-bottom: 1rem; } + +// target +.injected-app-credential-alert { + margin-bottom: 1rem; +} diff --git a/ui/admin/tests/acceptance/targets/create-test.js b/ui/admin/tests/acceptance/targets/create-test.js index 8e6c8098c..0ba0667ea 100644 --- a/ui/admin/tests/acceptance/targets/create-test.js +++ b/ui/admin/tests/acceptance/targets/create-test.js @@ -10,13 +10,18 @@ import { fillIn, getContext, visit, + blur, } from '@ember/test-helpers'; import { setupApplicationTest } from 'admin/tests/helpers'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { Response } from 'miragejs'; import { authenticateSession } from 'ember-simple-auth/test-support'; -import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; +import { + TYPE_TARGET_TCP, + TYPE_TARGET_SSH, + TYPE_TARGET_RDP, +} from 'api/models/target'; import { setupIntl } from 'ember-intl/test-support'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import * as selectors from './selectors'; @@ -32,6 +37,7 @@ module('Acceptance | targets | create', function (hooks) { let getTCPTargetCount; let getSSHTargetCount; let featuresService; + let getRDPTargetCount; const instances = { scopes: { @@ -48,6 +54,7 @@ module('Acceptance | targets | create', function (hooks) { newTarget: null, newTCPTarget: null, newSSHTarget: null, + newRDPTarget: null, }; hooks.beforeEach(async function () { @@ -72,12 +79,15 @@ module('Acceptance | targets | create', function (hooks) { urls.newTarget = `${urls.targets}/new`; urls.newTCPTarget = `${urls.targets}/new?type=tcp`; urls.newSSHTarget = `${urls.targets}/new?type=ssh`; + urls.newRDPTarget = `${urls.targets}/new?type=rdp`; // Generate resource counter getTargetCount = () => this.server.schema.targets.all().models.length; getSSHTargetCount = () => this.server.schema.targets.where({ type: TYPE_TARGET_SSH }).models.length; getTCPTargetCount = () => this.server.schema.targets.where({ type: TYPE_TARGET_TCP }).models.length; + getRDPTargetCount = () => + this.server.schema.targets.where({ type: TYPE_TARGET_RDP }).models.length; await authenticateSession({}); }); @@ -505,4 +515,115 @@ module('Acceptance | targets | create', function (hooks) { ); assert.strictEqual(currentURL(), urls.targets); }); + + test('defaults to type `rdp` when no query param provided and rdp feature is enabled', async function (assert) { + featuresService.enable('rdp-target'); + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.newTarget)); + + assert.dom(selectors.FIELD_TYPE_CHECKED).hasValue(TYPE_TARGET_RDP); + }); + + test('cannot navigate to new RDP targets route when rdp feature is disabled', async function (assert) { + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.newTarget)); + + assert.false(featuresService.isEnabled('rdp-target')); + assert.true( + instances.scopes.project.authorized_collection_actions.targets.includes( + 'create', + ), + ); + assert.dom(selectors.FIELD_INFO).isVisible({ count: 1 }); + assert.dom(selectors.FIELD_INFO_LABEL).includesText('TCP'); + }); + + test('default port is not marked required for RDP targets', async function (assert) { + featuresService.enable('rdp-target'); + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.newTarget)); + + assert.dom(selectors.FIELD_DEFAULT_PORT_LABEL).includesText('Optional'); + }); + + test('can cancel create new RDP target', async function (assert) { + featuresService.enable('rdp-target'); + const targetCount = getTargetCount(); + const rdpTargetCount = getRDPTargetCount(); + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.newTarget)); + await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE); + await click(selectors.FIELD_TYPE_VALUE('rdp')); + await click(commonSelectors.CANCEL_BTN); + + assert.strictEqual(currentURL(), urls.targets); + assert.strictEqual(getTargetCount(), targetCount); + assert.strictEqual(getRDPTargetCount(), rdpTargetCount); + }); + + test('can create a type `rdp` target', async function (assert) { + featuresService.enable('rdp-target'); + + const targetCount = getTargetCount(); + const rdpTargetCount = getRDPTargetCount(); + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.newTarget)); + await click(selectors.FIELD_TYPE_VALUE('rdp')); + await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE); + await fillIn(selectors.FIELD_DEFAULT_CLIENT_PORT, '3389'); + await blur(selectors.FIELD_DEFAULT_CLIENT_PORT); + + assert + .dom(selectors.FIELD_DEFAULT_CLIENT_PORT_RDP_WINDOWS_ERROR) + .hasText('Note: Windows OS prevents port 3389 from being used.'); + + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(getRDPTargetCount(), rdpTargetCount + 1); + assert.strictEqual(getTargetCount(), targetCount + 1); + assert.strictEqual( + this.server.schema.targets.all().models[getTargetCount() - 1].name, + commonSelectors.FIELD_NAME_VALUE, + ); + assert.strictEqual( + this.server.schema.targets.all().models[getTargetCount() - 1].attributes + .default_client_port, + 3389, + ); + }); + + test('saving a new RDP target with invalid fields displays error messages', async function (assert) { + this.server.post('/targets', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + }, + ); + }); + await visit(urls.newRDPTarget); + + await click(commonSelectors.SAVE_BTN); + + assert + .dom(commonSelectors.ALERT_TOAST_BODY) + .hasText('The request was invalid.'); + assert.dom(selectors.FIELD_NAME_ERROR).hasText('Name is required.'); + }); }); diff --git a/ui/admin/tests/acceptance/targets/selectors.js b/ui/admin/tests/acceptance/targets/selectors.js index b95a496fa..bb0b3c053 100644 --- a/ui/admin/tests/acceptance/targets/selectors.js +++ b/ui/admin/tests/acceptance/targets/selectors.js @@ -24,6 +24,9 @@ export const FIELD_ENABLE_RECORDING_TOGGLE = '[name=target-enable-session-recording]'; export const FIELD_ADD_NEW_STORAGE_BUCKET_LINK = '.enable-session-recording-toggle .hds-link-standalone'; +export const FIELD_DEFAULT_CLIENT_PORT_RDP_WINDOWS_ERROR = + '[data-test-error-message-default-client-port-rdp-windows'; +export const FIELD_DEFAULT_CLIENT_PORT = '[name="default_client_port"]'; export const NEW_TARGET_BTN = '[data-test-new-target-button]'; export const MANAGE_DROPDOWN = '[data-test-manage-targets-dropdown] button'; @@ -68,3 +71,7 @@ export const WORKERS_ACCORDION_DROPDOWN = (name) => `[data-test-target-${name}-workers-accordion-item] .hds-accordion-item__button`; export const WORKERS_ACCORDION_DROPDOWN_LINK = (name) => `[data-test-target-${name}-workers-accordion-item] a`; +export const ALERT_INJECTED_APPLICATION_CREDENTIAL = + '[data-test-injected-application-credential-alert]'; +export const ALERT_INJECTED_APPLICATION_CREDENTIAL_ADD_BTN = + '[data-test-injected-application-credential-alert] .hds-alert__actions a'; diff --git a/ui/admin/tests/acceptance/targets/update-test.js b/ui/admin/tests/acceptance/targets/update-test.js index e443e7e2c..3a2375786 100644 --- a/ui/admin/tests/acceptance/targets/update-test.js +++ b/ui/admin/tests/acceptance/targets/update-test.js @@ -14,6 +14,7 @@ import { authenticateSession } from 'ember-simple-auth/test-support'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import * as selectors from './selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { TYPE_TARGET_RDP, TYPE_TARGET_SSH } from 'api/models/target'; module('Acceptance | targets | update', function (hooks) { setupApplicationTest(hooks); @@ -30,11 +31,15 @@ module('Acceptance | targets | update', function (hooks) { project: null, }, target: null, + rdpTarget: null, + sshTarget: null, }; const urls = { projectScope: null, targets: null, target: null, + rdpTarget: null, + sshTarget: null, }; hooks.beforeEach(async function () { @@ -52,10 +57,22 @@ module('Acceptance | targets | update', function (hooks) { instances.target = this.server.create('target', { scope: instances.scopes.project, }); + instances.rdpTarget = this.server.create('target', { + type: TYPE_TARGET_RDP, + scope: instances.scopes.project, + injected_application_credential_source_ids: [], + }); + instances.sshTarget = this.server.create('target', { + type: TYPE_TARGET_SSH, + scope: instances.scopes.project, + injected_application_credential_source_ids: [], + }); // Generate route URLs for resources urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.targets = `${urls.projectScope}/targets`; urls.target = `${urls.targets}/${instances.target.id}`; + urls.rdpTarget = `${urls.targets}/${instances.rdpTarget.id}`; + urls.sshTarget = `${urls.targets}/${instances.sshTarget.id}`; await authenticateSession({}); }); @@ -344,4 +361,53 @@ module('Acceptance | targets | update', function (hooks) { ); assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0); }); + + test('can save changes to existing rdp target', async function (assert) { + featuresService.enable('rdp-target'); + featuresService.enable('ssh-target'); + + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.rdpTarget)); + + assert + .dom(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL) + .exists('Injected application credential alert is displayed'); + assert + .dom(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL_ADD_BTN) + .hasAttribute( + 'href', + `${urls.rdpTarget}/add-injected-application-credential-sources`, + ); + + await click(commonSelectors.EDIT_BTN, 'Activate edit mode'); + await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(currentURL(), urls.rdpTarget); + assert.strictEqual( + this.server.schema.targets.where({ type: TYPE_TARGET_RDP }).models[0] + .name, + commonSelectors.FIELD_NAME_VALUE, + ); + }); + + test('displays add injected credentials alert for ssh target', async function (assert) { + featuresService.enable('ssh-target'); + + await visit(urls.targets); + + await click(commonSelectors.HREF(urls.sshTarget)); + + assert + .dom(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL) + .exists('Injected application credential alert is displayed'); + + await click(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL_ADD_BTN); + + assert.strictEqual( + currentURL(), + `${urls.sshTarget}/add-injected-application-credential-sources`, + ); + }); }); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/targets/target/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/targets/target/index-test.js new file mode 100644 index 000000000..6fa728678 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/targets/target/index-test.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module( + 'Unit | Controller | scopes/scope/targets/target/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/targets/target/index', + ); + assert.ok(controller); + assert.ok(controller.targets); + }); + }, +);