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);
+ });
+ },
+);