diff --git a/changelog/20790.txt b/changelog/20790.txt new file mode 100644 index 0000000000..1e185e3fc3 --- /dev/null +++ b/changelog/20790.txt @@ -0,0 +1,3 @@ +```release-note:feature +**UI LDAP secrets engine**: Add LDAP secrets engine to the UI. +``` diff --git a/ui/app/adapters/kubernetes/config.js b/ui/app/adapters/kubernetes/config.js index 761e0c3002..8175e95720 100644 --- a/ui/app/adapters/kubernetes/config.js +++ b/ui/app/adapters/kubernetes/config.js @@ -3,41 +3,12 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import ApplicationAdapter from 'vault/adapters/application'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; +import SecretsEnginePathAdapter from 'vault/adapters/secrets-engine-path'; -export default class KubernetesConfigAdapter extends ApplicationAdapter { - namespace = 'v1'; +export default class KubernetesConfigAdapter extends SecretsEnginePathAdapter { + path = 'config'; - getURL(backend, path = 'config') { - return `${this.buildURL()}/${encodePath(backend)}/${path}`; - } - urlForUpdateRecord(name, modelName, snapshot) { - return this.getURL(snapshot.attr('backend')); - } - urlForDeleteRecord(backend) { - return this.getURL(backend); - } - - queryRecord(store, type, query) { - const { backend } = query; - return this.ajax(this.getURL(backend), 'GET').then((resp) => { - resp.backend = backend; - return resp; - }); - } - createRecord() { - return this._saveRecord(...arguments); - } - updateRecord() { - return this._saveRecord(...arguments); - } - _saveRecord(store, { modelName }, snapshot) { - const data = store.serializerFor(modelName).serialize(snapshot); - const url = this.getURL(snapshot.attr('backend')); - return this.ajax(url, 'POST', { data }).then(() => data); - } checkConfigVars(backend) { - return this.ajax(`${this.getURL(backend, 'check')}`, 'GET'); + return this.ajax(`${this._getURL(backend, 'check')}`, 'GET'); } } diff --git a/ui/app/adapters/ldap/config.js b/ui/app/adapters/ldap/config.js new file mode 100644 index 0000000000..f7439659b3 --- /dev/null +++ b/ui/app/adapters/ldap/config.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import SecretsEnginePathAdapter from 'vault/adapters/secrets-engine-path'; + +export default class LdapConfigAdapter extends SecretsEnginePathAdapter { + path = 'config'; + + async rotateRoot(backend) { + return this.ajax(this._getURL(backend, 'rotate-root'), 'POST'); + } +} diff --git a/ui/app/adapters/ldap/library.js b/ui/app/adapters/ldap/library.js new file mode 100644 index 0000000000..c1556beb32 --- /dev/null +++ b/ui/app/adapters/ldap/library.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import NamedPathAdapter from 'vault/adapters/named-path'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default class LdapLibraryAdapter extends NamedPathAdapter { + getURL(backend, name) { + const base = `${this.buildURL()}/${encodePath(backend)}/library`; + return name ? `${base}/${name}` : base; + } + + urlForUpdateRecord(name, modelName, snapshot) { + return this.getURL(snapshot.attr('backend'), name); + } + urlForDeleteRecord(name, modelName, snapshot) { + return this.getURL(snapshot.attr('backend'), name); + } + + query(store, type, query) { + const { backend } = query; + return this.ajax(this.getURL(backend), 'GET', { data: { list: true } }) + .then((resp) => { + return resp.data.keys.map((name) => ({ name, backend })); + }) + .catch((error) => { + if (error.httpStatus === 404) { + return []; + } + throw error; + }); + } + queryRecord(store, type, query) { + const { backend, name } = query; + return this.ajax(this.getURL(backend, name), 'GET').then((resp) => ({ ...resp.data, backend, name })); + } + + fetchStatus(backend, name) { + const url = `${this.getURL(backend, name)}/status`; + return this.ajax(url, 'GET').then((resp) => { + const statuses = []; + for (const key in resp.data) { + const status = { + ...resp.data[key], + account: key, + library: name, + }; + statuses.push(status); + } + return statuses; + }); + } + checkOutAccount(backend, name, ttl) { + const url = `${this.getURL(backend, name)}/check-out`; + return this.ajax(url, 'POST', { data: { ttl } }).then((resp) => { + const { lease_id, lease_duration, renewable } = resp; + const { service_account_name: account, password } = resp.data; + return { account, password, lease_id, lease_duration, renewable }; + }); + } + checkInAccount(backend, name, service_account_names) { + const url = `${this.getURL(backend, name)}/check-in`; + return this.ajax(url, 'POST', { data: { service_account_names } }).then((resp) => resp.data); + } +} diff --git a/ui/app/adapters/ldap/role.js b/ui/app/adapters/ldap/role.js new file mode 100644 index 0000000000..61945f2a5a --- /dev/null +++ b/ui/app/adapters/ldap/role.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import NamedPathAdapter from 'vault/adapters/named-path'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; +import { inject as service } from '@ember/service'; + +export default class LdapRoleAdapter extends NamedPathAdapter { + @service flashMessages; + + getURL(backend, path, name) { + const base = `${this.buildURL()}/${encodePath(backend)}/${path}`; + return name ? `${base}/${name}` : base; + } + pathForRoleType(type, isCred) { + const staticPath = isCred ? 'static-cred' : 'static-role'; + const dynamicPath = isCred ? 'creds' : 'role'; + return type === 'static' ? staticPath : dynamicPath; + } + + urlForUpdateRecord(name, modelName, snapshot) { + const { backend, type } = snapshot.record; + return this.getURL(backend, this.pathForRoleType(type), name); + } + urlForDeleteRecord(name, modelName, snapshot) { + const { backend, type } = snapshot.record; + return this.getURL(backend, this.pathForRoleType(type), name); + } + + async query(store, type, query, recordArray, options) { + const { showPartialError } = options.adapterOptions || {}; + const { backend } = query; + const roles = []; + const errors = []; + + for (const roleType of ['static', 'dynamic']) { + const url = this.getURL(backend, this.pathForRoleType(roleType)); + try { + const models = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => { + return resp.data.keys.map((name) => ({ name, backend, type: roleType })); + }); + roles.addObjects(models); + } catch (error) { + if (error.httpStatus !== 404) { + errors.push(error); + } + } + } + + if (errors.length) { + const errorMessages = errors.reduce((errors, e) => { + e.errors.forEach((error) => { + errors.push(`${e.path}: ${error}`); + }); + return errors; + }, []); + if (errors.length === 2) { + // throw error as normal if both requests fail + // ignore status code and concat errors to be displayed in Page::Error component with generic message + throw { message: 'Error fetching roles:', errors: errorMessages }; + } else if (showPartialError) { + // if only one request fails, surface the error to the user an info level flash message + // this may help for permissions errors where a users policy may be incorrect + this.flashMessages.info(`Error fetching roles from ${errorMessages.join(', ')}`); + } + } + + return roles.sortBy('name'); + } + queryRecord(store, type, query) { + const { backend, name, type: roleType } = query; + const url = this.getURL(backend, this.pathForRoleType(roleType), name); + return this.ajax(url, 'GET').then((resp) => ({ ...resp.data, backend, name, type: roleType })); + } + + fetchCredentials(backend, type, name) { + const url = this.getURL(backend, this.pathForRoleType(type, true), name); + return this.ajax(url, 'GET').then((resp) => { + if (type === 'dynamic') { + const { lease_id, lease_duration, renewable } = resp; + return { ...resp.data, lease_id, lease_duration, renewable, type }; + } + return { ...resp.data, type }; + }); + } + rotateStaticPassword(backend, name) { + const url = this.getURL(backend, 'rotate-role', name); + return this.ajax(url, 'POST'); + } +} diff --git a/ui/app/adapters/secrets-engine-path.js b/ui/app/adapters/secrets-engine-path.js new file mode 100644 index 0000000000..027b402101 --- /dev/null +++ b/ui/app/adapters/secrets-engine-path.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * General use adapter to access specified paths on secrets engines + * For example /:backend/config is a typical use case for this adapter + * These types of records do not have an id and use the backend value of the secrets engine as the primaryKey in the serializer + */ + +import ApplicationAdapter from 'vault/adapters/application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default class SecretsEnginePathAdapter extends ApplicationAdapter { + namespace = 'v1'; + + // define path value in extending class or pass into method directly + _getURL(backend, path) { + return `${this.buildURL()}/${encodePath(backend)}/${path || this.path}`; + } + urlForUpdateRecord(name, modelName, snapshot) { + return this._getURL(snapshot.attr('backend')); + } + // primaryKey must be set to backend in serializer + urlForDeleteRecord(backend) { + return this._getURL(backend); + } + + queryRecord(store, type, query) { + const { backend } = query; + return this.ajax(this._getURL(backend), 'GET').then((resp) => { + resp.backend = backend; + return resp; + }); + } + createRecord() { + return this._saveRecord(...arguments); + } + updateRecord() { + return this._saveRecord(...arguments); + } + _saveRecord(store, { modelName }, snapshot) { + const data = store.serializerFor(modelName).serialize(snapshot); + const url = this._getURL(snapshot.attr('backend')); + return this.ajax(url, 'POST', { data }).then(() => data); + } +} diff --git a/ui/app/app.js b/ui/app/app.js index 5fbe6b5456..f761a598dd 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -52,6 +52,14 @@ export default class App extends Application { }, }, }, + ldap: { + dependencies: { + services: ['router', 'store', 'secret-mount-path', 'flash-messages', 'auth'], + externalRoutes: { + secrets: 'vault.cluster.secrets.backends', + }, + }, + }, kv: { dependencies: { services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'], diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index e4c3dee0eb..8c0140f884 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -30,11 +30,6 @@ const ENTERPRISE_SECRET_ENGINES = [ ]; const MOUNTABLE_SECRET_ENGINES = [ - { - displayName: 'Active Directory', - type: 'ad', - category: 'cloud', - }, { displayName: 'AliCloud', type: 'alicloud', @@ -110,9 +105,15 @@ const MOUNTABLE_SECRET_ENGINES = [ type: 'totp', category: 'generic', }, + { + displayName: 'LDAP', + type: 'ldap', + engineRoute: 'ldap.overview', + category: 'generic', + glyph: 'folder-users', + }, { displayName: 'Kubernetes', - value: 'kubernetes', type: 'kubernetes', engineRoute: 'kubernetes.overview', category: 'generic', diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js index bfec42d1f8..144fcf0f00 100644 --- a/ui/app/helpers/supported-secret-backends.js +++ b/ui/app/helpers/supported-secret-backends.js @@ -18,6 +18,7 @@ const SUPPORTED_SECRET_BACKENDS = [ 'transform', 'keymgmt', 'kubernetes', + 'ldap', ]; export function supportedSecretBackends() { diff --git a/ui/app/models/ldap/config.js b/ui/app/models/ldap/config.js new file mode 100644 index 0000000000..adfe7d5a75 --- /dev/null +++ b/ui/app/models/ldap/config.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Model, { attr } from '@ember-data/model'; +import { withFormFields } from 'vault/decorators/model-form-fields'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const validations = { + binddn: [{ type: 'presence', message: 'Administrator distinguished name is required.' }], + bindpass: [{ type: 'presence', message: 'Administrator password is required.' }], +}; +const formGroups = [ + { default: ['binddn', 'bindpass', 'url', 'password_policy'] }, + { 'TLS options': ['starttls', 'insecure_tls', 'certificate', 'client_tls_cert', 'client_tls_key'] }, + { 'More options': ['userdn', 'userattr', 'upndomain', 'connection_timeout', 'request_timeout'] }, +]; + +@withModelValidations(validations) +@withFormFields(null, formGroups) +export default class LdapConfigModel extends Model { + @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord + + @attr('string', { + label: 'Administrator Distinguished Name', + subText: + 'Distinguished name of the administrator to bind (Bind DN) when performing user and group search. Example: cn=vault,ou=Users,dc=example,dc=com.', + }) + binddn; + + @attr('string', { + label: 'Administrator Password', + subText: 'Password to use along with Bind DN when performing user search.', + }) + bindpass; + + @attr('string', { + label: 'URL', + subText: 'The directory server to connect to.', + }) + url; + + @attr('string', { + editType: 'optionalText', + label: 'Use custom password policy', + subText: 'Specify the name of an existing password policy.', + defaultSubText: 'Unless a custom policy is specified, Vault will use a default.', + defaultShown: 'Default', + docLink: '/vault/docs/concepts/password-policies', + }) + password_policy; + + @attr('string') schema; + + @attr('boolean', { + label: 'Start TLS', + subText: 'If checked, or address contains “ldaps://”, creates an encrypted connection with LDAP.', + }) + starttls; + + @attr('boolean', { + label: 'Insecure TLS', + subText: 'If checked, skips LDAP server SSL certificate verification - insecure, use with caution!', + }) + insecure_tls; + + @attr('string', { + editType: 'file', + label: 'CA Certificate', + helpText: 'CA certificate to use when verifying LDAP server certificate, must be x509 PEM encoded.', + }) + certificate; + + @attr('string', { + editType: 'file', + label: 'Client TLS Certificate', + helpText: 'Client certificate to provide to the LDAP server, must be x509 PEM encoded.', + }) + client_tls_cert; + + @attr('string', { + editType: 'file', + label: 'Client TLS Key', + helpText: 'Client key to provide to the LDAP server, must be x509 PEM encoded.', + }) + client_tls_key; + + @attr('string', { + label: 'Userdn', + helpText: 'The base DN under which to perform user search in library management and static roles.', + }) + userdn; + + @attr('string', { + label: 'Userattr', + subText: 'The attribute field name used to perform user search in library management and static roles.', + }) + userattr; + + @attr('string', { + label: 'Upndomain', + subText: 'The domain (userPrincipalDomain) used to construct a UPN string for authentication.', + }) + upndomain; + + @attr('number', { + editType: 'optionalText', + label: 'Connection Timeout', + subText: 'Specify the connection timeout length in seconds.', + defaultSubText: 'Vault will use the default of 30 seconds.', + defaultShown: 'Default 30 seconds.', + }) + connection_timeout; + + @attr('number', { + editType: 'optionalText', + label: 'Request Timeout', + subText: 'Specify the connection timeout length in seconds.', + defaultSubText: 'Vault will use the default of 90 seconds.', + defaultShown: 'Default 90 seconds.', + }) + request_timeout; + + async rotateRoot() { + const adapter = this.store.adapterFor('ldap/config'); + return adapter.rotateRoot(this.backend); + } +} diff --git a/ui/app/models/ldap/library.js b/ui/app/models/ldap/library.js new file mode 100644 index 0000000000..5ac3518748 --- /dev/null +++ b/ui/app/models/ldap/library.js @@ -0,0 +1,106 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Model, { attr } from '@ember-data/model'; +import { withFormFields } from 'vault/decorators/model-form-fields'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; + +const validations = { + name: [{ type: 'presence', message: 'Library name is required.' }], + service_account_names: [{ type: 'presence', message: 'At least one service account is required.' }], +}; +const formFields = ['name', 'service_account_names', 'ttl', 'max_ttl', 'disable_check_in_enforcement']; + +@withModelValidations(validations) +@withFormFields(formFields) +export default class LdapLibraryModel extends Model { + @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord + + @attr('string', { label: 'Library name' }) name; + + @attr('string', { + editType: 'stringArray', + label: 'Accounts', + subText: + 'The names of all the accounts that can be checked out from this set. These accounts must only be used by Vault, and may only be in one set.', + }) + service_account_names; + + @attr({ + editType: 'ttl', + label: 'Default lease TTL', + detailsLabel: 'TTL', + helperTextDisabled: 'Vault will use the default lease duration.', + defaultValue: '24h', + defaultShown: 'Engine default', + }) + ttl; + + @attr({ + editType: 'ttl', + label: 'Max lease TTL', + detailsLabel: 'Max TTL', + helperTextDisabled: 'Vault will use the default lease duration.', + defaultValue: '24h', + defaultShown: 'Engine default', + }) + max_ttl; + + // this is a boolean from the server but is transformed in the serializer to display as Disabled or Enabled + @attr('string', { + editType: 'radio', + label: 'Check-in enforcement', + subText: + 'When enabled, accounts must be checked in by the entity or client token that checked them out. If disabled, anyone with the right permission can check the account back in.', + possibleValues: ['Disabled', 'Enabled'], + defaultValue: 'Enabled', + }) + disable_check_in_enforcement; + + get displayFields() { + return this.formFields.filter((field) => field.name !== 'service_account_names'); + } + + @lazyCapabilities(apiPath`${'backend'}/library/${'name'}`, 'backend', 'name') libraryPath; + @lazyCapabilities(apiPath`${'backend'}/library/${'name'}/status`, 'backend', 'name') statusPath; + @lazyCapabilities(apiPath`${'backend'}/library/${'name'}/check-out`, 'backend', 'name') checkOutPath; + @lazyCapabilities(apiPath`${'backend'}/library/${'name'}/check-in`, 'backend', 'name') checkInPath; + + get canCreate() { + return this.libraryPath.get('canCreate') !== false; + } + get canDelete() { + return this.libraryPath.get('canDelete') !== false; + } + get canEdit() { + return this.libraryPath.get('canUpdate') !== false; + } + get canRead() { + return this.libraryPath.get('canRead') !== false; + } + get canList() { + return this.libraryPath.get('canList') !== false; + } + get canReadStatus() { + return this.statusPath.get('canRead') !== false; + } + get canCheckOut() { + return this.checkOutPath.get('canUpdate') !== false; + } + get canCheckIn() { + return this.checkInPath.get('canUpdate') !== false; + } + + fetchStatus() { + return this.store.adapterFor('ldap/library').fetchStatus(this.backend, this.name); + } + checkOutAccount(ttl) { + return this.store.adapterFor('ldap/library').checkOutAccount(this.backend, this.name, ttl); + } + checkInAccount(account) { + return this.store.adapterFor('ldap/library').checkInAccount(this.backend, this.name, [account]); + } +} diff --git a/ui/app/models/ldap/role.js b/ui/app/models/ldap/role.js new file mode 100644 index 0000000000..d44e8bdeb2 --- /dev/null +++ b/ui/app/models/ldap/role.js @@ -0,0 +1,230 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Model, { attr } from '@ember-data/model'; +import { withFormFields } from 'vault/decorators/model-form-fields'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; + +const creationLdifExample = `# The example below is treated as a comment and will not be submitted +# dn: cn={{.Username}},ou=users,dc=learn,dc=example +# objectClass: person +# objectClass: top +`; +const deletionLdifExample = `# The example below is treated as a comment and will not be submitted +# dn: cn={{.Username}},ou=users,dc=learn,dc=example +# changetype: delete +`; +const rollbackLdifExample = `# The example below is treated as a comment and will not be submitted +# dn: cn={{.Username}},ou=users,dc=learn,dc=example +# changetype: delete +`; + +const validations = { + name: [{ type: 'presence', message: 'Name is required' }], + username: [ + { + validator: (model) => (model.isStatic && !model.username ? false : true), + message: 'Username is required.', + }, + ], + rotation_period: [ + { + validator: (model) => (model.isStatic && !model.rotation_period ? false : true), + message: 'Rotation Period is required.', + }, + ], + creation_ldif: [ + { + validator: (model) => (model.isDynamic && !model.creation_ldif ? false : true), + message: 'Creation LDIF is required.', + }, + ], + deletion_ldif: [ + { + validator: (model) => (model.isDynamic && !model.creation_ldif ? false : true), + message: 'Deletion LDIF is required.', + }, + ], +}; + +export const staticRoleFields = ['username', 'dn', 'rotation_period']; +export const dynamicRoleFields = [ + 'default_ttl', + 'max_ttl', + 'username_template', + 'creation_ldif', + 'deletion_ldif', + 'rollback_ldif', +]; + +@withModelValidations(validations) +@withFormFields() +export default class LdapRoleModel extends Model { + @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord + + @attr('string', { + defaultValue: 'static', + }) + type; // this must be set to either static or dynamic in order for the adapter to build the correct url and for the correct form fields to display + + @attr('string', { + label: 'Role name', + subText: 'The name of the role that will be used in Vault.', + }) + name; + + // static role properties + @attr('string', { + label: 'Distinguished name', + subText: 'Distinguished name (DN) of entry Vault should manage.', + }) + dn; + + @attr('string', { + label: 'Username', + subText: + "The name of the user to be used when logging in. This is useful when DN isn't used for login purposes.", + }) + username; + + @attr({ + editType: 'ttl', + label: 'Rotation period', + helperTextEnabled: + 'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds.', + hideToggle: true, + }) + rotation_period; + + // dynamic role properties + @attr({ + editType: 'ttl', + label: 'Generated credential’s time-to-live (TTL)', + detailsLabel: 'TTL', + helperTextDisabled: 'Vault will use the default of 1 hour.', + defaultValue: '1h', + defaultShown: 'Engine default', + }) + default_ttl; + + @attr({ + editType: 'ttl', + label: 'Generated credential’s maximum time-to-live (Max TTL)', + detailsLabel: 'Max TTL', + helperTextDisabled: 'Vault will use the engine default of 24 hours.', + defaultValue: '24h', + defaultShown: 'Engine default', + }) + max_ttl; + + @attr('string', { + editType: 'optionalText', + label: 'Username template', + subText: 'Enter the custom username template to use.', + defaultSubText: + 'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.', + docLink: '/vault/docs/concepts/username-templating', + defaultShown: 'Default', + }) + username_template; + + @attr('string', { + editType: 'json', + label: 'Creation LDIF', + helpText: 'Specifies the LDIF statements executed to create a user. May optionally be base64 encoded.', + example: creationLdifExample, + mode: 'ruby', + sectionHeading: 'LDIF Statements', // render section heading before form field + }) + creation_ldif; + + @attr('string', { + editType: 'json', + label: 'Deletion LDIF', + helpText: + 'Specifies the LDIF statements executed to delete a user once its TTL has expired. May optionally be base64 encoded.', + example: deletionLdifExample, + mode: 'ruby', + }) + deletion_ldif; + + @attr('string', { + editType: 'json', + label: 'Rollback LDIF', + helpText: + 'Specifies the LDIF statement to attempt to rollback any changes if the creation results in an error. May optionally be base64 encoded.', + example: rollbackLdifExample, + mode: 'ruby', + }) + rollback_ldif; + + get isStatic() { + return this.type === 'static'; + } + get isDynamic() { + return this.type === 'dynamic'; + } + // this is used to build the form fields as well as serialize the correct payload based on type + // if a new attr is added be sure to add it to the appropriate array + get fieldsForType() { + return this.isStatic + ? ['username', 'dn', 'rotation_period'] + : ['default_ttl', 'max_ttl', 'username_template', 'creation_ldif', 'deletion_ldif', 'rollback_ldif']; + } + get formFields() { + // filter all fields and return only those relevant to type + return this.allFields.filter((field) => { + // name is the only common field + return field.name === 'name' || this.fieldsForType.includes(field.name); + }); + } + + get displayFields() { + // insert type after role name + const [name, ...rest] = this.formFields; + const typeField = { name: 'type', options: { label: 'Role type' } }; + return [name, typeField, ...rest]; + } + + get roleUri() { + return this.isStatic ? 'static-role' : 'role'; + } + get credsUri() { + return this.isStatic ? 'static-cred' : 'creds'; + } + @lazyCapabilities(apiPath`${'backend'}/${'roleUri'}/${'name'}`, 'backend', 'roleUri', 'name') rolePath; + @lazyCapabilities(apiPath`${'backend'}/${'credsUri'}/${'name'}`, 'backend', 'credsUri', 'name') credsPath; + @lazyCapabilities(apiPath`${'backend'}/rotate-role/${'name'}`, 'backend', 'name') staticRotateCredsPath; + + get canCreate() { + return this.rolePath.get('canCreate') !== false; + } + get canDelete() { + return this.rolePath.get('canDelete') !== false; + } + get canEdit() { + return this.rolePath.get('canUpdate') !== false; + } + get canRead() { + return this.rolePath.get('canRead') !== false; + } + get canList() { + return this.rolePath.get('canList') !== false; + } + get canReadCreds() { + return this.credsPath.get('canRead') !== false; + } + get canRotateStaticCreds() { + return this.isStatic && this.staticRotateCredsPath.get('canCreate') !== false; + } + + fetchCredentials() { + return this.store.adapterFor('ldap/role').fetchCredentials(this.backend, this.type, this.name); + } + rotateStaticPassword() { + return this.store.adapterFor('ldap/role').rotateStaticPassword(this.backend, this.name); + } +} diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index e0195b093e..b4fb2401ee 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -129,13 +129,14 @@ export default class SecretEngineModel extends Model { } get icon() { - if (!this.engineType || this.engineType === 'kmip') { - return 'secrets'; - } - if (this.engineType === 'keymgmt') { - return 'key'; - } - return this.engineType; + const defaultIcon = this.engineType || 'secrets'; + return ( + { + keymgmt: 'key', + kmip: 'secrets', + ldap: 'folder-users', + }[this.engineType] || defaultIcon + ); } get engineType() { diff --git a/ui/app/router.js b/ui/app/router.js index 8fec213a01..f6fc3f20b0 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -161,6 +161,7 @@ Router.map(function () { this.mount('kmip'); this.mount('kubernetes'); this.mount('kv'); + this.mount('ldap'); this.mount('pki'); this.route('index', { path: '/' }); this.route('configuration'); diff --git a/ui/app/serializers/kubernetes/role.js b/ui/app/serializers/kubernetes/role.js index bfb8381a8c..46dbac5d1d 100644 --- a/ui/app/serializers/kubernetes/role.js +++ b/ui/app/serializers/kubernetes/role.js @@ -5,13 +5,10 @@ import ApplicationSerializer from '../application'; -export default class KubernetesConfigSerializer extends ApplicationSerializer { +export default class KubernetesRoleSerializer extends ApplicationSerializer { primaryKey = 'name'; - serialize() { - const json = super.serialize(...arguments); - // remove backend value from payload - delete json.backend; - return json; - } + attrs = { + backend: { serialize: false }, + }; } diff --git a/ui/app/serializers/ldap/config.js b/ui/app/serializers/ldap/config.js new file mode 100644 index 0000000000..f462434f71 --- /dev/null +++ b/ui/app/serializers/ldap/config.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from '../application'; + +export default class LdapConfigSerializer extends ApplicationSerializer { + primaryKey = 'backend'; +} diff --git a/ui/app/serializers/ldap/library.js b/ui/app/serializers/ldap/library.js new file mode 100644 index 0000000000..108afba78d --- /dev/null +++ b/ui/app/serializers/ldap/library.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from '../application'; + +export default class LdapLibrarySerializer extends ApplicationSerializer { + primaryKey = 'name'; + + attrs = { + backend: { serialize: false }, + name: { serialize: false }, + }; + + // disable_check_in_enforcement is a boolean but needs to be presented as Disabled or Enabled + normalize(modelClass, data) { + data.disable_check_in_enforcement = data.disable_check_in_enforcement ? 'Disabled' : 'Enabled'; + return super.normalize(modelClass, data); + } + + serialize() { + const json = super.serialize(...arguments); + json.disable_check_in_enforcement = json.disable_check_in_enforcement === 'Enabled' ? false : true; + return json; + } +} diff --git a/ui/app/serializers/ldap/role.js b/ui/app/serializers/ldap/role.js new file mode 100644 index 0000000000..c32c7a10d4 --- /dev/null +++ b/ui/app/serializers/ldap/role.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from '../application'; + +export default class LdapRoleSerializer extends ApplicationSerializer { + primaryKey = 'name'; + + serialize(snapshot) { + // remove all fields that are not relevant to specified role type + const { fieldsForType } = snapshot.record; + const json = super.serialize(...arguments); + Object.keys(json).forEach((key) => { + if (!fieldsForType.includes(key)) { + delete json[key]; + } + }); + return json; + } +} diff --git a/ui/app/services/secret-mount-path.js b/ui/app/services/secret-mount-path.ts similarity index 86% rename from ui/app/services/secret-mount-path.js rename to ui/app/services/secret-mount-path.ts index a43bdb212d..a9f54c9369 100644 --- a/ui/app/services/secret-mount-path.js +++ b/ui/app/services/secret-mount-path.ts @@ -10,10 +10,8 @@ import Service from '@ember/service'; // are not accessible export default class SecretMountPath extends Service { currentPath = ''; - update(path) { + + update(path: string) { this.currentPath = path; } - get() { - return this.currentPath; - } } diff --git a/ui/app/styles/components/overview-card.scss b/ui/app/styles/components/overview-card.scss index 3fdd78c722..7134b91e75 100644 --- a/ui/app/styles/components/overview-card.scss +++ b/ui/app/styles/components/overview-card.scss @@ -5,7 +5,6 @@ .overview-card { padding: $spacing-l; - display: initial; line-height: initial; .title-number { diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index cc099350ed..5efd76a4ad 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -326,4 +326,9 @@ a.button.disabled { font-size: inherit; font-weight: inherit; cursor: pointer; + + &:disabled { + color: $grey-light; + cursor: not-allowed; + } } diff --git a/ui/app/styles/helper-classes/colors.scss b/ui/app/styles/helper-classes/colors.scss index 90add57ef7..830eec0bca 100644 --- a/ui/app/styles/helper-classes/colors.scss +++ b/ui/app/styles/helper-classes/colors.scss @@ -18,6 +18,10 @@ background-color: $ui-gray-200; } +.has-background-gray-900 { + background-color: $ui-gray-900; +} + .background-color-black { background-color: black; } @@ -34,7 +38,9 @@ } .has-error-border, -select.has-error-border { +select.has-error-border, +.ttl-picker-form-field-error input, +.string-list-form-field-error .field:first-of-type textarea { border: 1px solid $red-500; } @@ -75,6 +81,10 @@ select.has-error-border { .has-text-info { color: $blue-500 !important; } +// same without the !important +.has-text-primary { + color: $blue-500; +} .has-text-success { color: $green-500 !important; @@ -87,3 +97,7 @@ select.has-error-border { .has-text-danger { color: $red-500 !important; } + +.has-text-primary { + color: $blue; +} diff --git a/ui/app/styles/helper-classes/flexbox-and-grid.scss b/ui/app/styles/helper-classes/flexbox-and-grid.scss index e04eef362a..ad2a8f5bf4 100644 --- a/ui/app/styles/helper-classes/flexbox-and-grid.scss +++ b/ui/app/styles/helper-classes/flexbox-and-grid.scss @@ -41,6 +41,11 @@ align-items: center; } +.is-flex-align-start { + display: flex; + align-items: flex-start; +} + .is-flex-align-baseline { display: flex; align-items: baseline; diff --git a/ui/app/styles/helper-classes/general.scss b/ui/app/styles/helper-classes/general.scss index b4e0521a94..574d403779 100644 --- a/ui/app/styles/helper-classes/general.scss +++ b/ui/app/styles/helper-classes/general.scss @@ -116,6 +116,10 @@ border-radius: $radius; } +.border-radius-4 { + border-radius: $radius-large; +} + // border-spacing .is-border-spacing-revert { border-spacing: revert; diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index 66581cd963..f463ba8f5e 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -26,6 +26,10 @@ padding-right: $spacing-s; } +.has-padding-s { + padding: $spacing-s; +} + .has-padding-xxs { padding: $spacing-xxs; } @@ -37,6 +41,10 @@ padding: $spacing-l; } +.has-padding-l { + padding: $spacing-l; +} + .has-bottom-padding-s { padding-bottom: $spacing-s; } @@ -95,15 +103,22 @@ margin-bottom: -$spacing-m; } +.has-top-margin-negative-xxl { + margin-top: -$spacing-xxl; +} + .has-top-margin-xxs { margin: $spacing-xxs 0; } + .has-right-margin-xxs { margin-right: $spacing-xxs; } + .has-left-margin-xxs { margin-left: $spacing-xxs; } + .has-bottom-margin-xxs { margin-bottom: $spacing-xxs !important; } diff --git a/ui/app/templates/components/wizard/ad-engine.hbs b/ui/app/templates/components/wizard/ad-engine.hbs deleted file mode 100644 index a8da5282e7..0000000000 --- a/ui/app/templates/components/wizard/ad-engine.hbs +++ /dev/null @@ -1,16 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - - -

- The AD Secrets Engine rotates AD passwords dynamically, and is designed for a high-load environment where many instances - may be accessing a shared password simultaneously. -

-
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/secrets/backends.hbs b/ui/app/templates/vault/cluster/secrets/backends.hbs index f5295ee614..3aa7f3c79b 100644 --- a/ui/app/templates/vault/cluster/secrets/backends.hbs +++ b/ui/app/templates/vault/cluster/secrets/backends.hbs @@ -49,7 +49,7 @@
diff --git a/ui/docs/fetch-secrets-engine-config.md b/ui/docs/fetch-secrets-engine-config.md new file mode 100644 index 0000000000..3bdca22932 --- /dev/null +++ b/ui/docs/fetch-secrets-engine-config.md @@ -0,0 +1,92 @@ +# Fetch Secrets Engine Configuration Decorator + +The `fetch-secrets-engine-config` decorator is available in the core addon and can be used on a route that needs to be aware of the configuration details of a secrets engine prior to model hook execution. This is useful for conditionally displaying a call to action for the user to complete the configuration. + +## API + +The decorator accepts a single argument with the name of the Ember Data model to be fetched. + +- **modelName** [string] - name of the Ember Data model to fetch which is passed to the `queryRecord` method. + +With the provided model name, the decorator fetches the record using the store `queryRecord` method in the `beforeModel` route hook. Several properties are set on the route class based on the status of the request: + +- **configModel** [Model | null] - set on success with resolved Ember Data model. + +- **configError** [AdapterError | null] - set if the request errors with any status other than 404. + +- **promptConfig** [boolean] - set to `true` if the request returns a 404, otherwise set to `false`. This is for convenience since checking for `(!this.configModel && !this.configError)` would result in the same value. + +## Usage + +### Configure route + +```js +@withConfig('foo/config') +export default class FooConfigureRoute extends Route { + @service store; + @service secretMountPath; + + model() { + const backend = this.secretMountPath.currentPath; + return this.configModel || this.store.createRecord('foo/config', { backend }); + } +} +``` + +In the scenario of creating/updating the configuration, the model is used to populate the form if available, otherwise the form is presented in an empty state. Fetch errors are not a concern, nor is prompting the user to configure so only the `configModel` property is used. + +### Configuration route + +```js +@withConfig('foo/config') +export default class FooConfigurationRoute extends Route { + @service store; + @service secretMountPath; + + model() { + // the error could also be thrown to display the error template + // in this example a component is used to display the error + return { + configModel: this.configModel, + configError: this.configError, + }; + } +} +``` + +For configuration routes, the model and error properties may be used to determine what should be displayed to the user: + +`configuration.hbs` + +```hbs +{{#if @configModel}} + {{#each @configModel.fields as |field|}} + + {{/each}} +{{else if @configError}} + +{{else}} + +{{/if}} +``` + +### Other routes (overview etc.) + +This is the most basic usage where a route only needs to be aware of whether or not to show the config prompt: + +```js +@withConfig('foo/config') +export default class FooOverviewRoute extends Route { + @service store; + @service secretMountPath; + + model() { + const backend = this.secretMountPath.currentPath; + return hash({ + promptConfig: this.promptConfig, + roles: this.store.query('foo/role', { backend }).catch(() => []), + libraries: this.store.query('foo/library', { backend }).catch(() => []), + }); + } +} +``` diff --git a/ui/lib/core/addon/components/filter-input.hbs b/ui/lib/core/addon/components/filter-input.hbs new file mode 100644 index 0000000000..5b00156a3d --- /dev/null +++ b/ui/lib/core/addon/components/filter-input.hbs @@ -0,0 +1,12 @@ +
+

+ + +

+
\ No newline at end of file diff --git a/ui/lib/core/addon/components/filter-input.ts b/ui/lib/core/addon/components/filter-input.ts new file mode 100644 index 0000000000..57df994000 --- /dev/null +++ b/ui/lib/core/addon/components/filter-input.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { debounce } from '@ember/runloop'; + +import type { HTMLElementEvent } from 'vault/forms'; + +interface Args { + placeholder?: string; // defaults to Type to filter results + wait?: number; // defaults to 200 + onInput(value: string): void; +} + +export default class FilterInputComponent extends Component { + get placeholder() { + return this.args.placeholder || 'Type to filter results'; + } + + @action onInput(event: HTMLElementEvent) { + const callback = () => { + this.args.onInput(event.target.value); + }; + const wait = this.args.wait || 200; + // ts complains when trying to pass object of optional args to callback as 3rd arg to debounce + debounce(this, callback, wait); + } +} diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index 144edd9021..7c9e4b5af0 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -57,14 +57,6 @@ {{/each}}
- {{#if this.validationError}} - - {{/if}} {{/if}} {{else if (eq @attr.options.editType "searchSelect")}} @@ -86,9 +78,6 @@ class={{if this.validationError "dropdown-has-error-border"}} /> - {{#if this.validationError}} - - {{/if}} {{else if (eq @attr.options.editType "mountAccessor")}} {{else if (eq @attr.options.editType "stringArray")}} - {{#if this.validationError}} - - {{/if}} {{else if (or (eq @attr.type "number") (eq @attr.type "string"))}}
{{#if (eq @attr.options.editType "textarea")}} @@ -224,9 +212,6 @@ oninput={{this.onChangeWithEvent}} class="textarea {{if this.validationError 'has-error-border'}}" > - {{#if this.validationError}} - - {{/if}} {{else if (eq @attr.options.editType "password")}} {{#if @attr.options.allowReset}}
{{else if (or (eq @attr.type "boolean") (eq @attr.options.editType "boolean"))}} @@ -347,8 +323,27 @@ @value={{if (get @model this.valuePath) (stringify (get @model this.valuePath)) this.emptyData}} @valueUpdated={{fn this.codemirrorUpdated false}} @helpText={{@attr.options.helpText}} + @example={{@attr.options.example}} /> {{else if (eq @attr.options.editType "yield")}} {{yield}} {{/if}} + {{#if this.validationError}} + + {{/if}} + {{#if this.validationWarning}} + + {{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/json-editor.hbs b/ui/lib/core/addon/components/json-editor.hbs index 1aca88bf9b..c7e42ca51a 100644 --- a/ui/lib/core/addon/components/json-editor.hbs +++ b/ui/lib/core/addon/components/json-editor.hbs @@ -15,6 +15,18 @@ {{yield}} + {{#if @example}} + + {{/if}}

{{@cardTitle}}

diff --git a/ui/lib/core/addon/components/page/breadcrumbs.hbs b/ui/lib/core/addon/components/page/breadcrumbs.hbs index d466215500..8766b8b866 100644 --- a/ui/lib/core/addon/components/page/breadcrumbs.hbs +++ b/ui/lib/core/addon/components/page/breadcrumbs.hbs @@ -9,14 +9,16 @@
  • / {{#if breadcrumb.linkExternal}} - {{breadcrumb.label}} + + {{breadcrumb.label}} + {{else if breadcrumb.route}} {{#if breadcrumb.model}} - + {{breadcrumb.label}} {{else}} - + {{breadcrumb.label}} {{/if}} diff --git a/ui/lib/core/addon/components/secrets-engine-mount-config.hbs b/ui/lib/core/addon/components/secrets-engine-mount-config.hbs new file mode 100644 index 0000000000..a2c94f405c --- /dev/null +++ b/ui/lib/core/addon/components/secrets-engine-mount-config.hbs @@ -0,0 +1,17 @@ +
    + + {{#if this.showConfig}} + {{#each this.fields as |field|}} + + {{/each}} + {{! block for additional fields that may be engine specific }} + {{yield}} + {{/if}} +
    \ No newline at end of file diff --git a/ui/lib/core/addon/components/secrets-engine-mount-config.ts b/ui/lib/core/addon/components/secrets-engine-mount-config.ts new file mode 100644 index 0000000000..7cf538030b --- /dev/null +++ b/ui/lib/core/addon/components/secrets-engine-mount-config.ts @@ -0,0 +1,29 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import type SecretEngineModel from 'vault/models/secret-engine'; + +interface Args { + model: SecretEngineModel; +} +interface Field { + label: string; + value: string | boolean; +} + +export default class SecretsEngineMountConfigComponent extends Component { + @tracked showConfig = false; + + get fields(): Array { + const { model } = this.args; + return [ + { label: 'Secret Engine Type', value: model.engineType }, + { label: 'Path', value: model.path }, + { label: 'Accessor', value: model.accessor }, + { label: 'Local', value: model.local }, + { label: 'Seal Wrap', value: model.sealWrap }, + { label: 'Default Lease TTL', value: model.config.defaultLeaseTtl }, + { label: 'Max Lease TTL', value: model.config.maxLeaseTtl }, + ]; + } +} diff --git a/ui/lib/kubernetes/addon/decorators/fetch-config.js b/ui/lib/core/addon/decorators/fetch-secrets-engine-config.ts similarity index 54% rename from ui/lib/kubernetes/addon/decorators/fetch-config.js rename to ui/lib/core/addon/decorators/fetch-secrets-engine-config.ts index 887e77360c..c01a0df7eb 100644 --- a/ui/lib/kubernetes/addon/decorators/fetch-config.js +++ b/ui/lib/core/addon/decorators/fetch-secrets-engine-config.ts @@ -5,35 +5,48 @@ import Route from '@ember/routing/route'; +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type Transition from '@ember/routing/transition'; +import type Model from '@ember-data/model'; +import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports + /** - * the overview, configure, configuration and roles routes all need to be aware of the config for the engine + * for use in routes that need to be aware of the config for a secrets engine * if the user has not configured they are prompted to do so in each of the routes * decorate the necessary routes to perform the check in the beforeModel hook since that may change what is returned for the model */ -export function withConfig() { - return function decorator(SuperClass) { +interface BaseRoute extends Route { + store: Store; + secretMountPath: SecretMountPath; +} + +export function withConfig(modelName: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function BaseRoute>(SuperClass: RouteClass) { if (!Object.prototype.isPrototypeOf.call(Route, SuperClass)) { // eslint-disable-next-line console.error( - 'withConfig decorator must be used on an instance of ember Route class. Decorator not applied to returned class' + 'withConfig decorator must be used on an instance of Ember Route class. Decorator not applied to returned class' ); return SuperClass; } - return class FetchConfig extends SuperClass { - configModel = null; - configError = null; + + return class FetchSecretsEngineConfig extends SuperClass { + configModel: Model | null = null; + configError: AdapterError | null = null; promptConfig = false; - async beforeModel() { - super.beforeModel(...arguments); + async beforeModel(transition: Transition) { + super.beforeModel(transition); - const backend = this.secretMountPath.get(); + const backend = this.secretMountPath.currentPath; // check the store for record first - this.configModel = this.store.peekRecord('kubernetes/config', backend); + this.configModel = this.store.peekRecord(modelName, backend); if (!this.configModel) { return this.store - .queryRecord('kubernetes/config', { backend }) + .queryRecord(modelName, { backend }) .then((record) => { this.configModel = record; this.promptConfig = false; diff --git a/ui/app/helpers/jsonify.js b/ui/lib/core/addon/helpers/jsonify.js similarity index 100% rename from ui/app/helpers/jsonify.js rename to ui/lib/core/addon/helpers/jsonify.js diff --git a/ui/lib/core/addon/modifiers/code-mirror.js b/ui/lib/core/addon/modifiers/code-mirror.js index 7de623fc01..7aa2e86427 100644 --- a/ui/lib/core/addon/modifiers/code-mirror.js +++ b/ui/lib/core/addon/modifiers/code-mirror.js @@ -68,5 +68,9 @@ export default class CodeMirrorModifier extends Modifier { editor.on('focus', bind(this, this._onFocus, namedArgs)); this._editor = editor; + + if (namedArgs.onSetup) { + namedArgs.onSetup(editor); + } } } diff --git a/ui/lib/core/app/components/filter-input.js b/ui/lib/core/app/components/filter-input.js new file mode 100644 index 0000000000..99d5822bb6 --- /dev/null +++ b/ui/lib/core/app/components/filter-input.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'core/components/filter-input'; diff --git a/ui/lib/core/app/components/secrets-engine-mount-config.js b/ui/lib/core/app/components/secrets-engine-mount-config.js new file mode 100644 index 0000000000..bdc315a98a --- /dev/null +++ b/ui/lib/core/app/components/secrets-engine-mount-config.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'core/components/secrets-engine-mount-config'; diff --git a/ui/lib/core/app/helpers/jsonify.js b/ui/lib/core/app/helpers/jsonify.js new file mode 100644 index 0000000000..c71705f803 --- /dev/null +++ b/ui/lib/core/app/helpers/jsonify.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default, jsonify } from 'core/helpers/jsonify'; diff --git a/ui/lib/core/app/helpers/stringify.js b/ui/lib/core/app/helpers/stringify.js index a0957233a7..c3464b67a9 100644 --- a/ui/lib/core/app/helpers/stringify.js +++ b/ui/lib/core/app/helpers/stringify.js @@ -3,4 +3,4 @@ * SPDX-License-Identifier: MPL-2.0 */ -export { default } from 'core/helpers/stringify'; +export { default, stringify } from 'core/helpers/stringify'; diff --git a/ui/lib/kubernetes/addon/routes/configuration.js b/ui/lib/kubernetes/addon/routes/configuration.js index dfc3c444a4..37ed7576ac 100644 --- a/ui/lib/kubernetes/addon/routes/configuration.js +++ b/ui/lib/kubernetes/addon/routes/configuration.js @@ -5,9 +5,9 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { withConfig } from '../decorators/fetch-config'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; -@withConfig() +@withConfig('kubernetes/config') export default class KubernetesConfigureRoute extends Route { @service store; @service secretMountPath; diff --git a/ui/lib/kubernetes/addon/routes/configure.js b/ui/lib/kubernetes/addon/routes/configure.js index f35a2ce6d3..7e47900b03 100644 --- a/ui/lib/kubernetes/addon/routes/configure.js +++ b/ui/lib/kubernetes/addon/routes/configure.js @@ -5,15 +5,15 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { withConfig } from '../decorators/fetch-config'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; -@withConfig() +@withConfig('kubernetes/config') export default class KubernetesConfigureRoute extends Route { @service store; @service secretMountPath; async model() { - const backend = this.secretMountPath.get(); + const backend = this.secretMountPath.currentPath; return this.configModel || this.store.createRecord('kubernetes/config', { backend }); } diff --git a/ui/lib/kubernetes/addon/routes/overview.js b/ui/lib/kubernetes/addon/routes/overview.js index 266c8e9fab..c6bcbffd07 100644 --- a/ui/lib/kubernetes/addon/routes/overview.js +++ b/ui/lib/kubernetes/addon/routes/overview.js @@ -5,16 +5,16 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { withConfig } from 'kubernetes/decorators/fetch-config'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; import { hash } from 'rsvp'; -@withConfig() +@withConfig('kubernetes/config') export default class KubernetesOverviewRoute extends Route { @service store; @service secretMountPath; async model() { - const backend = this.secretMountPath.get(); + const backend = this.secretMountPath.currentPath; return hash({ promptConfig: this.promptConfig, backend: this.modelFor('application'), diff --git a/ui/lib/kubernetes/addon/routes/roles/create.js b/ui/lib/kubernetes/addon/routes/roles/create.js index eba3a96650..fe0efa71e4 100644 --- a/ui/lib/kubernetes/addon/routes/roles/create.js +++ b/ui/lib/kubernetes/addon/routes/roles/create.js @@ -11,7 +11,7 @@ export default class KubernetesRolesCreateRoute extends Route { @service secretMountPath; model() { - const backend = this.secretMountPath.get(); + const backend = this.secretMountPath.currentPath; return this.store.createRecord('kubernetes/role', { backend }); } diff --git a/ui/lib/kubernetes/addon/routes/roles/index.js b/ui/lib/kubernetes/addon/routes/roles/index.js index 357436322a..1b4b903988 100644 --- a/ui/lib/kubernetes/addon/routes/roles/index.js +++ b/ui/lib/kubernetes/addon/routes/roles/index.js @@ -5,10 +5,10 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { withConfig } from 'kubernetes/decorators/fetch-config'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; import { hash } from 'rsvp'; -@withConfig() +@withConfig('kubernetes/config') export default class KubernetesRolesRoute extends Route { @service store; @service secretMountPath; @@ -17,7 +17,7 @@ export default class KubernetesRolesRoute extends Route { // filter roles based on pageFilter value const { pageFilter } = transition.to.queryParams; const roles = this.store - .query('kubernetes/role', { backend: this.secretMountPath.get() }) + .query('kubernetes/role', { backend: this.secretMountPath.currentPath }) .then((models) => pageFilter ? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase())) diff --git a/ui/lib/kubernetes/addon/routes/roles/role/credentials.js b/ui/lib/kubernetes/addon/routes/roles/role/credentials.js index d0558d4222..312f9d086c 100644 --- a/ui/lib/kubernetes/addon/routes/roles/role/credentials.js +++ b/ui/lib/kubernetes/addon/routes/roles/role/credentials.js @@ -11,7 +11,7 @@ export default class KubernetesRoleCredentialsRoute extends Route { model() { return { roleName: this.paramsFor('roles.role').name, - backend: this.secretMountPath.get(), + backend: this.secretMountPath.currentPath, }; } diff --git a/ui/lib/kubernetes/addon/routes/roles/role/details.js b/ui/lib/kubernetes/addon/routes/roles/role/details.js index 36ce26ccb0..fdc86d5894 100644 --- a/ui/lib/kubernetes/addon/routes/roles/role/details.js +++ b/ui/lib/kubernetes/addon/routes/roles/role/details.js @@ -11,7 +11,7 @@ export default class KubernetesRoleDetailsRoute extends Route { @service secretMountPath; model() { - const backend = this.secretMountPath.get(); + const backend = this.secretMountPath.currentPath; const { name } = this.paramsFor('roles.role'); return this.store.queryRecord('kubernetes/role', { backend, name }); } diff --git a/ui/lib/kubernetes/addon/routes/roles/role/edit.js b/ui/lib/kubernetes/addon/routes/roles/role/edit.js index e917c46e72..324d891d5c 100644 --- a/ui/lib/kubernetes/addon/routes/roles/role/edit.js +++ b/ui/lib/kubernetes/addon/routes/roles/role/edit.js @@ -11,7 +11,7 @@ export default class KubernetesRoleEditRoute extends Route { @service secretMountPath; model() { - const backend = this.secretMountPath.get(); + const backend = this.secretMountPath.currentPath; const { name } = this.paramsFor('roles.role'); return this.store.queryRecord('kubernetes/role', { backend, name }); } diff --git a/ui/lib/ldap/addon/components/accounts-checked-out.hbs b/ui/lib/ldap/addon/components/accounts-checked-out.hbs new file mode 100644 index 0000000000..a9b50eb055 --- /dev/null +++ b/ui/lib/ldap/addon/components/accounts-checked-out.hbs @@ -0,0 +1,75 @@ + +
    + + {{#if this.filteredAccounts}} + + <:body as |Body|> + + {{Body.data.account}} + {{#if @showLibraryColumn}} + {{Body.data.library}} + {{/if}} + + + + + + + {{else}} + + {{/if}} +
    + +{{#if this.selectedStatus}} + + +
    + + +
    +
    +{{/if}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/accounts-checked-out.ts b/ui/lib/ldap/addon/components/accounts-checked-out.ts new file mode 100644 index 0000000000..0d4d880e4d --- /dev/null +++ b/ui/lib/ldap/addon/components/accounts-checked-out.ts @@ -0,0 +1,72 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; +import type AuthService from 'vault/services/auth'; +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type { LdapLibraryAccountStatus } from 'vault/adapters/ldap/library'; + +interface Args { + libraries: Array; + statuses: Array; + showLibraryColumn: boolean; +} + +export default class LdapAccountsCheckedOutComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + @service declare readonly auth: AuthService; + + @tracked selectedStatus: LdapLibraryAccountStatus | undefined; + + get columns() { + const columns = [{ label: 'Account' }, { label: 'Action' }]; + if (this.args.showLibraryColumn) { + columns.splice(1, 0, { label: 'Library' }); + } + return columns; + } + + get filteredAccounts() { + // filter status to only show checked out accounts associated to the current user + // if disable_check_in_enforcement is true on the library set then all checked out accounts are displayed + return this.args.statuses.filter((status) => { + const authEntityId = this.auth.authData?.entity_id; + const isRoot = !status.borrower_entity_id && !authEntityId; // root user will not have an entity id and it won't be populated on status + const isEntity = status.borrower_entity_id === authEntityId; + const library = this.findLibrary(status.library); + const enforcementDisabled = library.disable_check_in_enforcement === 'Disabled'; + + return !status.available && (enforcementDisabled || isEntity || isRoot); + }); + } + + disableCheckIn = (name: string) => { + return !this.findLibrary(name).canCheckIn; + }; + + findLibrary(name: string): LdapLibraryModel { + return this.args.libraries.find((library) => library.name === name) as LdapLibraryModel; + } + + @task + @waitFor + *checkIn() { + const { library, account } = this.selectedStatus as LdapLibraryAccountStatus; + try { + const libraryModel = this.findLibrary(library); + yield libraryModel.checkInAccount(account); + this.flashMessages.success(`Successfully checked in the account ${account}.`); + // transitioning to the current route to trigger the model hook so we can fetch the updated status + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details.accounts'); + } catch (error) { + this.selectedStatus = undefined; + this.flashMessages.danger(`Error checking in the account ${account}. \n ${errorMessage(error)}`); + } + } +} diff --git a/ui/lib/ldap/addon/components/config-cta.hbs b/ui/lib/ldap/addon/components/config-cta.hbs new file mode 100644 index 0000000000..ed4fb22e45 --- /dev/null +++ b/ui/lib/ldap/addon/components/config-cta.hbs @@ -0,0 +1,9 @@ + + + Configure LDAP + + \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/configuration.hbs b/ui/lib/ldap/addon/components/page/configuration.hbs new file mode 100644 index 0000000000..385cecb11f --- /dev/null +++ b/ui/lib/ldap/addon/components/page/configuration.hbs @@ -0,0 +1,39 @@ + + <:toolbarActions> + {{#if @configModel}} + + Rotate root + + {{/if}} + + {{if @configModel "Edit configuration" "Configure LDAP"}} + + + + +{{#if @configModel}} + {{#each this.defaultFields as |field|}} + + {{/each}} + +

    TLS Connection

    +
    + + {{#each this.connectionFields as |field|}} + + {{/each}} +{{else if @configError}} + +{{else}} + +{{/if}} + + \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/configuration.ts b/ui/lib/ldap/addon/components/page/configuration.ts new file mode 100644 index 0000000000..8d7a4b751b --- /dev/null +++ b/ui/lib/ldap/addon/components/page/configuration.ts @@ -0,0 +1,82 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapConfigModel from 'vault/models/ldap/config'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports +import type { Breadcrumb } from 'vault/vault/app-types'; +import type FlashMessageService from 'vault/services/flash-messages'; + +interface Args { + configModel: LdapConfigModel; + configError: AdapterError; + backendModel: SecretEngineModel; + breadcrumbs: Array; +} + +interface Field { + label: string; + value: any; // eslint-disable-line @typescript-eslint/no-explicit-any + formatTtl?: boolean; +} + +export default class LdapConfigurationPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + + get defaultFields(): Array { + const model = this.args.configModel; + const keys = [ + 'binddn', + 'url', + 'schema', + 'password_policy', + 'userdn', + 'userattr', + 'connection_timeout', + 'request_timeout', + ]; + return model.allFields.reduce>((filtered, field) => { + if (keys.includes(field.name)) { + const label = + { + schema: 'Schema', + password_policy: 'Password Policy', + }[field.name] || field.options.label; + filtered.splice(keys.indexOf(field.name), 0, { + label, + value: model[field.name as keyof typeof model], + formatTtl: field.name.includes('timeout'), + }); + } + return filtered; + }, []); + } + + get connectionFields(): Array { + const model = this.args.configModel; + const keys = ['certificate', 'starttls', 'insecure_tls', 'client_tls_cert', 'client_tls_key']; + return model.allFields.reduce>((filtered, field) => { + if (keys.includes(field.name)) { + filtered.splice(keys.indexOf(field.name), 0, { + label: field.options.label, + value: model[field.name as keyof typeof model], + }); + } + return filtered; + }, []); + } + + @task + @waitFor + *rotateRoot() { + try { + yield this.args.configModel.rotateRoot(); + this.flashMessages.success('Root password successfully rotated.'); + } catch (error) { + this.flashMessages.danger(`Error rotating root password \n ${errorMessage(error)}`); + } + } +} diff --git a/ui/lib/ldap/addon/components/page/configure.hbs b/ui/lib/ldap/addon/components/page/configure.hbs new file mode 100644 index 0000000000..088c69b440 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/configure.hbs @@ -0,0 +1,113 @@ + + + + + +

    Configure LDAP

    +
    +
    + +
    + +
    + + {{#each this.schemaOptions as |option|}} + + + {{option.title}} + {{option.description}} + + {{/each}} + + +
    + + +

    Schema Options

    +
    + + {{#if @model.schema}} +
    + +
    + {{else}} + + {{/if}} +
    + +
    + +
    + + + {{#if this.invalidFormMessage}} + + {{/if}} +
    +
    + +{{#if this.showRotatePrompt}} + + +
    + + +
    +
    +{{/if}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/configure.ts b/ui/lib/ldap/addon/components/page/configure.ts new file mode 100644 index 0000000000..4f1415a01e --- /dev/null +++ b/ui/lib/ldap/addon/components/page/configure.ts @@ -0,0 +1,113 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapConfigModel from 'vault/models/ldap/config'; +import { Breadcrumb, ValidationMap } from 'vault/vault/app-types'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; + +interface Args { + model: LdapConfigModel; + breadcrumbs: Array; +} +interface SchemaOption { + title: string; + icon: string; + description: string; + value: string; +} + +export default class LdapConfigurePageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + @tracked showRotatePrompt = false; + @tracked modelValidations: ValidationMap | null = null; + @tracked invalidFormMessage = ''; + @tracked error = ''; + + get schemaOptions(): Array { + return [ + { + title: 'OpenLDAP', + icon: 'folder', + description: + 'OpenLDAP is one of the most popular open source directory service developed by the OpenLDAP Project.', + value: 'openldap', + }, + { + title: 'AD', + icon: 'microsoft', + description: + 'Active Directory is a directory service developed by Microsoft for Windows domain networks.', + value: 'ad', + }, + { + title: 'RACF', + icon: 'users', + description: + "For managing IBM's Resource Access Control Facility (RACF) security system, the generated passwords must be 8 characters or less.", + value: 'racf', + }, + ]; + } + + leave(route: string) { + this.router.transitionTo(`vault.cluster.secrets.backend.ldap.${route}`); + } + + validate() { + const { isValid, state, invalidFormMessage } = this.args.model.validate(); + this.modelValidations = isValid ? null : state; + this.invalidFormMessage = isValid ? '' : invalidFormMessage; + return isValid; + } + + async rotateRoot() { + try { + await this.args.model.rotateRoot(); + } catch (error) { + // since config save was successful at this point we only want to show the error in a flash message + this.flashMessages.danger(`Error rotating root password \n ${errorMessage(error)}`); + } + } + + @task + @waitFor + *save(event: Event | null, rotate: boolean) { + if (event) { + event.preventDefault(); + } + const isValid = this.validate(); + // show rotate creds prompt for new models when form state is valid + this.showRotatePrompt = isValid && this.args.model.isNew && !this.showRotatePrompt; + + if (isValid && !this.showRotatePrompt) { + try { + yield this.args.model.save(); + // if save was triggered from confirm action in rotate password prompt we need to make an additional request + if (rotate) { + yield this.rotateRoot(); + } + this.flashMessages.success('Successfully configured LDAP engine'); + this.leave('configuration'); + } catch (error) { + this.error = errorMessage(error, 'Error saving configuration. Please try again or contact support.'); + } + } + } + + @action + cancel() { + const { model } = this.args; + const transitionRoute = model.isNew ? 'overview' : 'configuration'; + const cleanupMethod = model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + model[cleanupMethod](); + this.leave(transitionRoute); + } +} diff --git a/ui/lib/ldap/addon/components/page/libraries.hbs b/ui/lib/ldap/addon/components/page/libraries.hbs new file mode 100644 index 0000000000..40553b1156 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/libraries.hbs @@ -0,0 +1,91 @@ + + <:toolbarFilters> + {{#if (and (not @promptConfig) @libraries)}} + + {{/if}} + + <:toolbarActions> + {{#if @promptConfig}} + + Configure LDAP + + {{else}} + + Create library + + {{/if}} + + + +{{#if @promptConfig}} + +{{else if (not this.filteredLibraries)}} + {{#if this.filterValue}} + + {{else}} + + + Create library + + + {{/if}} +{{else}} +
    + {{#each this.filteredLibraries as |library|}} + + + + {{library.name}} + + + {{#if library.libraryPath.isLoading}} +
  • + +
  • + {{else}} +
  • + + Edit + +
  • +
  • + + Details + +
  • + {{#if library.canDelete}} +
  • + +
  • + {{/if}} + {{/if}} + + + {{/each}} +
    +{{/if}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/libraries.ts b/ui/lib/ldap/addon/components/page/libraries.ts new file mode 100644 index 0000000000..2e177234e0 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/libraries.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types'; + +interface Args { + libraries: Array; + promptConfig: boolean; + backendModel: SecretEngineModel; + breadcrumbs: Array; +} + +export default class LdapLibrariesPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + + @tracked filterValue = ''; + + get mountPoint(): string { + const owner = getOwner(this) as EngineOwner; + return owner.mountPoint; + } + + get filteredLibraries() { + const { libraries } = this.args; + return this.filterValue + ? libraries.filter((library) => library.name.toLowerCase().includes(this.filterValue.toLowerCase())) + : libraries; + } + + @action + async onDelete(model: LdapLibraryModel) { + try { + const message = `Successfully deleted library ${model.name}.`; + await model.destroyRecord(); + this.args.libraries.removeObject(model); + this.flashMessages.success(message); + } catch (error) { + this.flashMessages.danger(`Error deleting library \n ${errorMessage(error)}`); + } + } +} diff --git a/ui/lib/ldap/addon/components/page/library/check-out.hbs b/ui/lib/ldap/addon/components/page/library/check-out.hbs new file mode 100644 index 0000000000..407f44d2cd --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/check-out.hbs @@ -0,0 +1,50 @@ + + + + + +

    + Check-out +

    +
    +
    + +
    + + + Warning + + You won’t be able to access these credentials later, so please copy them now. + + + +
    + + + + + + + +
    + + + {{if @credentials.renewable "True" "False"}} + +
    +
    +
    + +
    + +
    \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/library/create-and-edit.hbs b/ui/lib/ldap/addon/components/page/library/create-and-edit.hbs new file mode 100644 index 0000000000..734f7c5bcb --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/create-and-edit.hbs @@ -0,0 +1,46 @@ + + + + + +

    + {{if @model.isNew "Create Library" "Edit Library"}} +

    +
    +
    + +
    + +
    + + + {{#each @model.formFields as |field|}} + + {{/each}} + +
    + +
    + + + {{#if this.invalidFormMessage}} + + {{/if}} +
    + \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/library/create-and-edit.ts b/ui/lib/ldap/addon/components/page/library/create-and-edit.ts new file mode 100644 index 0000000000..210e14646f --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/create-and-edit.ts @@ -0,0 +1,55 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; +import { Breadcrumb, ValidationMap } from 'vault/vault/app-types'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; + +interface Args { + model: LdapLibraryModel; + breadcrumbs: Array; +} + +export default class LdapCreateAndEditLibraryPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + @tracked modelValidations: ValidationMap | null = null; + @tracked invalidFormMessage = ''; + @tracked error = ''; + + @task + @waitFor + *save(event: Event) { + event.preventDefault(); + + const { model } = this.args; + const { isValid, state, invalidFormMessage } = model.validate(); + + this.modelValidations = isValid ? null : state; + this.invalidFormMessage = isValid ? '' : invalidFormMessage; + + if (isValid) { + try { + const action = model.isNew ? 'created' : 'updated'; + yield model.save(); + this.flashMessages.success(`Successfully ${action} the library ${model.name}.`); + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details', model.name); + } catch (error) { + this.error = errorMessage(error, 'Error saving library. Please try again or contact support.'); + } + } + } + + @action + cancel() { + this.args.model.rollbackAttributes(); + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries'); + } +} diff --git a/ui/lib/ldap/addon/components/page/library/details.hbs b/ui/lib/ldap/addon/components/page/library/details.hbs new file mode 100644 index 0000000000..96950bb9c6 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/details.hbs @@ -0,0 +1,37 @@ + + + + + +

    + {{@model.name}} +

    +
    +
    + +
    + +
    + + + + {{#if @model.canDelete}} + + Delete library + + {{#if @model.canEdit}} +
    + {{/if}} + {{/if}} + {{#if @model.canEdit}} + + Edit library + + {{/if}} +
    +
    \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/library/details.ts b/ui/lib/ldap/addon/components/page/library/details.ts new file mode 100644 index 0000000000..a9d1d29527 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/details.ts @@ -0,0 +1,31 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; +import { Breadcrumb } from 'vault/vault/app-types'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; + +interface Args { + model: LdapLibraryModel; + breadcrumbs: Array; +} + +export default class LdapLibraryDetailsPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + @action + async delete() { + try { + await this.args.model.destroyRecord(); + this.flashMessages.success('Library deleted successfully.'); + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries'); + } catch (error) { + const message = errorMessage(error, 'Unable to delete library. Please try again or contact support.'); + this.flashMessages.danger(message); + } + } +} diff --git a/ui/lib/ldap/addon/components/page/library/details/accounts.hbs b/ui/lib/ldap/addon/components/page/library/details/accounts.hbs new file mode 100644 index 0000000000..150cf3e97e --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/details/accounts.hbs @@ -0,0 +1,90 @@ +
    + +
    +

    All accounts

    + {{#if @library.canCheckOut}} + + {{/if}} +
    + +

    The accounts within this library

    +
    + + + <:body as |Body|> + + {{Body.data.account}} + + + + + + +
    + +
    + + + +
    + {{this.cliCommand}} + + Copy + + +
    +
    +
    +
    + +{{#if this.showCheckOutPrompt}} + + +
    + + +
    +
    +{{/if}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/library/details/accounts.ts b/ui/lib/ldap/addon/components/page/library/details/accounts.ts new file mode 100644 index 0000000000..559adc9a70 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/details/accounts.ts @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library'; +import { TtlEvent } from 'vault/vault/app-types'; + +interface Args { + library: LdapLibraryModel; + statuses: Array; +} + +export default class LdapLibraryDetailsAccountsPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + @tracked showCheckOutPrompt = false; + @tracked checkOutTtl: string | null = null; + + get cliCommand() { + return `vault lease renew ad/library/${this.args.library.name}/check-out/:lease_id`; + } + @action + setTtl(data: TtlEvent) { + this.checkOutTtl = data.timeString; + } + @action + checkOut() { + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.check-out', { + queryParams: { ttl: this.checkOutTtl }, + }); + } +} diff --git a/ui/lib/ldap/addon/components/page/library/details/configuration.hbs b/ui/lib/ldap/addon/components/page/library/details/configuration.hbs new file mode 100644 index 0000000000..a0e70f35d7 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/library/details/configuration.hbs @@ -0,0 +1,21 @@ +{{#each @model.displayFields as |field|}} + {{#let (get @model field.name) as |value|}} + {{#if (eq field.name "disable_check_in_enforcement")}} + + + {{value}} + + {{else}} + + {{/if}} + {{/let}} +{{/each}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/overview.hbs b/ui/lib/ldap/addon/components/page/overview.hbs new file mode 100644 index 0000000000..6505cde8e2 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/overview.hbs @@ -0,0 +1,69 @@ + + <:toolbarActions> + {{#if @promptConfig}} + + Configure LDAP + + {{/if}} + + + +{{#if @promptConfig}} + +{{else}} +
    + +

    + {{or @roles.length "None"}} +

    +
    + +

    + {{or @libraries.length "None"}} +

    +
    +
    +
    + + +
    + +
    + + +
    +
    +
    +
    +{{/if}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/overview.ts b/ui/lib/ldap/addon/components/page/overview.ts new file mode 100644 index 0000000000..eb732a209f --- /dev/null +++ b/ui/lib/ldap/addon/components/page/overview.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type RouterService from '@ember/routing/router-service'; +import type { Breadcrumb } from 'vault/vault/app-types'; +import LdapRoleModel from 'vault/models/ldap/role'; +import { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library'; + +interface Args { + roles: Array; + libraries: Array; + librariesStatus: Array; + promptConfig: boolean; + backendModel: SecretEngineModel; + breadcrumbs: Array; +} + +export default class LdapLibrariesPageComponent extends Component { + @service declare readonly router: RouterService; + + @tracked selectedRole: LdapRoleModel | undefined; + + @action + selectRole([roleName]: Array) { + const model = this.args.roles.find((role) => role.name === roleName); + this.selectedRole = model; + } + + @action + generateCredentials() { + const { type, name } = this.selectedRole as LdapRoleModel; + this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles.role.credentials', type, name); + } +} diff --git a/ui/lib/ldap/addon/components/page/role/create-and-edit.hbs b/ui/lib/ldap/addon/components/page/role/create-and-edit.hbs new file mode 100644 index 0000000000..1a013afe73 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/role/create-and-edit.hbs @@ -0,0 +1,70 @@ + + + + + +

    + {{if @model.isNew "Create Role" "Edit Role"}} +

    +
    +
    + +
    + +
    + + + + + {{#each this.roleTypeOptions as |option|}} + + + {{option.title}} + {{option.description}} + + {{/each}} + + + {{#each @model.formFields as |field|}} + {{! display section heading ahead of ldif fields }} + {{#if field.options.sectionHeading}} +
    +

    {{field.options.sectionHeading}}

    + {{/if}} + + {{/each}} + +
    + +
    + + + {{#if this.invalidFormMessage}} + + {{/if}} +
    + \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/role/create-and-edit.ts b/ui/lib/ldap/addon/components/page/role/create-and-edit.ts new file mode 100644 index 0000000000..96ce354bae --- /dev/null +++ b/ui/lib/ldap/addon/components/page/role/create-and-edit.ts @@ -0,0 +1,82 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapRoleModel from 'vault/models/ldap/role'; +import { Breadcrumb, ValidationMap } from 'vault/vault/app-types'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; + +interface Args { + model: LdapRoleModel; + breadcrumbs: Array; +} +interface RoleTypeOption { + title: string; + icon: string; + description: string; + value: string; +} + +export default class LdapCreateAndEditRolePageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + @tracked modelValidations: ValidationMap | null = null; + @tracked invalidFormMessage = ''; + @tracked error = ''; + + get roleTypeOptions(): Array { + return [ + { + title: 'Static role', + icon: 'user', + description: 'Static roles map to existing users in an LDAP system.', + value: 'static', + }, + { + title: 'Dynamic role', + icon: 'folder-users', + description: 'Dynamic roles allow Vault to create and delete a user in an LDAP system.', + value: 'dynamic', + }, + ]; + } + + @task + @waitFor + *save(event: Event) { + event.preventDefault(); + + const { model } = this.args; + const { isValid, state, invalidFormMessage } = model.validate(); + + this.modelValidations = isValid ? null : state; + this.invalidFormMessage = isValid ? '' : invalidFormMessage; + + if (isValid) { + try { + const action = model.isNew ? 'created' : 'updated'; + yield model.save(); + this.flashMessages.success(`Successfully ${action} the role ${model.name}`); + this.router.transitionTo( + 'vault.cluster.secrets.backend.ldap.roles.role.details', + model.type, + model.name + ); + } catch (error) { + this.error = errorMessage(error, 'Error saving role. Please try again or contact support.'); + } + } + } + + @action + cancel() { + this.args.model.rollbackAttributes(); + this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles'); + } +} diff --git a/ui/lib/ldap/addon/components/page/role/credentials.hbs b/ui/lib/ldap/addon/components/page/role/credentials.hbs new file mode 100644 index 0000000000..c03f3b988f --- /dev/null +++ b/ui/lib/ldap/addon/components/page/role/credentials.hbs @@ -0,0 +1,64 @@ + + + + + +

    + Credentials +

    +
    +
    + +
    + +{{#if (eq @credentials.type "dynamic")}} + + Warning + + You won’t be able to access these credentials later, so please copy them now. + + +{{/if}} + +
    + {{#each this.fields as |field|}} + {{#let (get @credentials field.key) as |value|}} + {{#if field.hasBlock}} + + {{#if (eq field.hasBlock "masked")}} + + {{else if (eq field.hasBlock "check")}} +
    + + + {{if value "True" "False"}} + +
    + {{/if}} +
    + {{else}} + + {{/if}} + {{/let}} + {{/each}} +
    + +
    + +
    \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/role/credentials.ts b/ui/lib/ldap/addon/components/page/role/credentials.ts new file mode 100644 index 0000000000..3c9f7115f7 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/role/credentials.ts @@ -0,0 +1,33 @@ +import Component from '@glimmer/component'; + +import type { + LdapStaticRoleCredentials, + LdapDynamicRoleCredentials, +} from 'ldap/routes/roles/role/credentials'; +import { Breadcrumb } from 'vault/vault/app-types'; + +interface Args { + credentials: LdapStaticRoleCredentials | LdapDynamicRoleCredentials; + breadcrumbs: Array; +} + +export default class LdapRoleCredentialsPageComponent extends Component { + staticFields = [ + { label: 'Last Vault rotation', key: 'last_vault_rotation', formatDate: 'MMM d yyyy, h:mm:ss aaa' }, + { label: 'Password', key: 'password', hasBlock: 'masked' }, + { label: 'Username', key: 'username' }, + { label: 'Rotation period', key: 'rotation_period', formatTtl: true }, + { label: 'Time remaining', key: 'ttl', formatTtl: true }, + ]; + dynamicFields = [ + { label: 'Distinguished Name', key: 'distinguished_names' }, + { label: 'Username', key: 'username', hasBlock: 'masked' }, + { label: 'Password', key: 'password', hasBlock: 'masked' }, + { label: 'Lease ID', key: 'lease_id' }, + { label: 'Lease duration', key: 'lease_duration', formatTtl: true }, + { label: 'Lease renewable', key: 'renewable', hasBlock: 'check' }, + ]; + get fields() { + return this.args.credentials.type === 'dynamic' ? this.dynamicFields : this.staticFields; + } +} diff --git a/ui/lib/ldap/addon/components/page/role/details.hbs b/ui/lib/ldap/addon/components/page/role/details.hbs new file mode 100644 index 0000000000..4fb0773f41 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/role/details.hbs @@ -0,0 +1,54 @@ + + + + + +

    + {{@model.name}} +

    +
    +
    + + + + {{#if @model.canDelete}} + + Delete role + +
    + {{/if}} + {{#if @model.canReadCreds}} + + Get credentials + + {{/if}} + {{#if @model.canRotateStaticCreds}} + + Rotate credentials + + {{/if}} + {{#if @model.canEdit}} + + Edit role + + {{/if}} +
    +
    + +{{#each @model.displayFields as |field|}} + {{#let (get @model field.name) as |value|}} + + {{/let}} +{{/each}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/role/details.ts b/ui/lib/ldap/addon/components/page/role/details.ts new file mode 100644 index 0000000000..e802415aa8 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/role/details.ts @@ -0,0 +1,44 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import errorMessage from 'vault/utils/error-message'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; + +import type LdapRoleModel from 'vault/models/ldap/role'; +import { Breadcrumb } from 'vault/vault/app-types'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; + +interface Args { + model: LdapRoleModel; + breadcrumbs: Array; +} + +export default class LdapRoleDetailsPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + @action + async delete() { + try { + await this.args.model.destroyRecord(); + this.flashMessages.success('Role deleted successfully.'); + this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles'); + } catch (error) { + const message = errorMessage(error, 'Unable to delete role. Please try again or contact support.'); + this.flashMessages.danger(message); + } + } + + @task + @waitFor + *rotateCredentials() { + try { + yield this.args.model.rotateStaticPassword(); + this.flashMessages.success('Credentials successfully rotated.'); + } catch (error) { + this.flashMessages.danger(`Error rotating credentials \n ${errorMessage(error)}`); + } + } +} diff --git a/ui/lib/ldap/addon/components/page/roles.hbs b/ui/lib/ldap/addon/components/page/roles.hbs new file mode 100644 index 0000000000..4d78d0b0b1 --- /dev/null +++ b/ui/lib/ldap/addon/components/page/roles.hbs @@ -0,0 +1,117 @@ + + <:toolbarFilters> + {{#if (and (not @promptConfig) @roles)}} + + {{/if}} + + <:toolbarActions> + {{#if @promptConfig}} + + Configure LDAP + + {{else}} + + Create role + + {{/if}} + + + +{{#if @promptConfig}} + +{{else if (not this.filteredRoles)}} + {{#if this.filterValue}} + + {{else}} + + + Create role + + + {{/if}} +{{else}} +
    + {{#each this.filteredRoles as |role|}} + + + + {{role.name}} + + + + {{#if role.rolePath.isLoading}} +
  • + +
  • + {{else}} +
  • + + Edit + +
  • +
  • + + Get credentials + +
  • + {{#if role.canRotateStaticCreds}} +
  • + +
  • + {{/if}} +
  • + + Details + +
  • + {{#if role.canDelete}} +
  • + +
  • + {{/if}} + {{/if}} +
    +
    + {{/each}} +
    +{{/if}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/components/page/roles.ts b/ui/lib/ldap/addon/components/page/roles.ts new file mode 100644 index 0000000000..4bf4fe2d0c --- /dev/null +++ b/ui/lib/ldap/addon/components/page/roles.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import errorMessage from 'vault/utils/error-message'; + +import type LdapRoleModel from 'vault/models/ldap/role'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types'; + +interface Args { + roles: Array; + promptConfig: boolean; + backendModel: SecretEngineModel; + breadcrumbs: Array; +} + +export default class LdapRolesPageComponent extends Component { + @service declare readonly flashMessages: FlashMessageService; + + @tracked filterValue = ''; + + get mountPoint(): string { + const owner = getOwner(this) as EngineOwner; + return owner.mountPoint; + } + + get filteredRoles() { + const { roles } = this.args; + return this.filterValue + ? roles.filter((role) => role.name.toLowerCase().includes(this.filterValue.toLowerCase())) + : roles; + } + + @action + async onRotate(model: LdapRoleModel) { + try { + const message = `Successfully rotated credentials for ${model.name}.`; + await model.rotateStaticPassword(); + this.flashMessages.success(message); + } catch (error) { + this.flashMessages.danger(`Error rotating credentials \n ${errorMessage(error)}`); + } + } + + @action + async onDelete(model: LdapRoleModel) { + try { + const message = `Successfully deleted role ${model.name}.`; + await model.destroyRecord(); + this.args.roles.removeObject(model); + this.flashMessages.success(message); + } catch (error) { + this.flashMessages.danger(`Error deleting role \n ${errorMessage(error)}`); + } + } +} diff --git a/ui/lib/ldap/addon/components/tab-page-header.hbs b/ui/lib/ldap/addon/components/tab-page-header.hbs new file mode 100644 index 0000000000..680a125739 --- /dev/null +++ b/ui/lib/ldap/addon/components/tab-page-header.hbs @@ -0,0 +1,31 @@ + + + + + +

    + + {{@model.id}} +

    +
    +
    + +
    + +
    + + + + {{yield to="toolbarFilters"}} + + + {{yield to="toolbarActions"}} + + \ No newline at end of file diff --git a/ui/lib/ldap/addon/engine.js b/ui/lib/ldap/addon/engine.js new file mode 100644 index 0000000000..6f2153712c --- /dev/null +++ b/ui/lib/ldap/addon/engine.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Engine from 'ember-engines/engine'; +import loadInitializers from 'ember-load-initializers'; +import Resolver from 'ember-resolver'; +import config from './config/environment'; + +const { modulePrefix } = config; + +export default class LdapEngine extends Engine { + modulePrefix = modulePrefix; + Resolver = Resolver; + dependencies = { + services: ['router', 'store', 'secret-mount-path', 'flash-messages', 'auth'], + externalRoutes: ['secrets'], + }; +} + +loadInitializers(LdapEngine, modulePrefix); diff --git a/ui/lib/ldap/addon/routes.js b/ui/lib/ldap/addon/routes.js new file mode 100644 index 0000000000..cecd2af313 --- /dev/null +++ b/ui/lib/ldap/addon/routes.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import buildRoutes from 'ember-engines/routes'; + +export default buildRoutes(function () { + this.route('overview'); + this.route('roles', function () { + this.route('create'); + this.route('role', { path: '/:type/:name' }, function () { + this.route('details'); + this.route('edit'); + this.route('credentials'); + }); + }); + this.route('libraries', function () { + this.route('create'); + this.route('library', { path: '/:name' }, function () { + this.route('details', function () { + this.route('accounts'); + this.route('configuration'); + }); + this.route('edit'); + this.route('check-out'); + }); + }); + this.route('configure'); + this.route('configuration'); +}); diff --git a/ui/lib/ldap/addon/routes/configuration.ts b/ui/lib/ldap/addon/routes/configuration.ts new file mode 100644 index 0000000000..160f227b8d --- /dev/null +++ b/ui/lib/ldap/addon/routes/configuration.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type Transition from '@ember/routing/transition'; +import type LdapConfigModel from 'vault/models/ldap/config'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type Controller from '@ember/controller'; +import type { Breadcrumb } from 'vault/vault/app-types'; +import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports + +interface LdapConfigurationRouteModel { + backendModel: SecretEngineModel; + configModel: LdapConfigModel; + configError: AdapterError; +} +interface LdapConfigurationController extends Controller { + breadcrumbs: Array; + model: LdapConfigurationRouteModel; +} + +@withConfig('ldap/config') +export default class LdapConfigurationRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + declare configModel: LdapConfigModel; + declare configError: AdapterError; + + model() { + return { + backendModel: this.modelFor('application'), + configModel: this.configModel, + configError: this.configError, + }; + } + + setupController( + controller: LdapConfigurationController, + resolvedModel: LdapConfigurationRouteModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: resolvedModel.backendModel.id }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/configure.ts b/ui/lib/ldap/addon/routes/configure.ts new file mode 100644 index 0000000000..0286ea687b --- /dev/null +++ b/ui/lib/ldap/addon/routes/configure.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type Transition from '@ember/routing/transition'; +import type LdapConfigModel from 'vault/models/ldap/config'; +import type Controller from '@ember/controller'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapConfigureController extends Controller { + breadcrumbs: Array; +} + +@withConfig('ldap/config') +export default class LdapConfigureRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + declare configModel: LdapConfigModel; + + model() { + const backend = this.secretMountPath.currentPath; + return this.configModel || this.store.createRecord('ldap/config', { backend }); + } + + setupController( + controller: LdapConfigureController, + resolvedModel: LdapConfigModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: 'Secrets', route: 'secrets', linkExternal: true }, + { label: resolvedModel.backend, route: 'overview' }, + { label: 'Configure' }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/error.ts b/ui/lib/ldap/addon/routes/error.ts new file mode 100644 index 0000000000..c4e9e3ba66 --- /dev/null +++ b/ui/lib/ldap/addon/routes/error.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type Transition from '@ember/routing/transition'; +import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports +import type SecretEngineModel from 'vault/models/secret-engine'; +import type { Breadcrumb } from 'vault/vault/app-types'; +import type Controller from '@ember/controller'; +import type SecretMountPath from 'vault/services/secret-mount-path'; + +interface LdapErrorController extends Controller { + breadcrumbs: Array; + backend: SecretEngineModel; +} + +export default class LdapErrorRoute extends Route { + @service declare readonly secretMountPath: SecretMountPath; + + setupController(controller: LdapErrorController, resolvedModel: AdapterError, transition: Transition) { + super.setupController(controller, resolvedModel, transition); + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: this.secretMountPath.currentPath, route: 'overview' }, + ]; + controller.backend = this.modelFor('application') as SecretEngineModel; + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/create.ts b/ui/lib/ldap/addon/routes/libraries/create.ts new file mode 100644 index 0000000000..d8acfa534c --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/create.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapLibrariesCreateController extends Controller { + breadcrumbs: Array; + model: LdapLibraryModel; +} + +export default class LdapLibrariesCreateRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + model() { + const backend = this.secretMountPath.currentPath; + return this.store.createRecord('ldap/library', { backend }); + } + + setupController( + controller: LdapLibrariesCreateController, + resolvedModel: LdapLibraryModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: resolvedModel.backend, route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: 'create' }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/index.ts b/ui/lib/ldap/addon/routes/libraries/index.ts new file mode 100644 index 0000000000..5303677912 --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/index.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; +import { hash } from 'rsvp'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type Transition from '@ember/routing/transition'; +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type Controller from '@ember/controller'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapLibrariesRouteModel { + backendModel: SecretEngineModel; + promptConfig: boolean; + libraries: Array; +} +interface LdapLibrariesController extends Controller { + breadcrumbs: Array; + model: LdapLibrariesRouteModel; +} + +@withConfig('ldap/config') +export default class LdapLibrariesRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + declare promptConfig: boolean; + + model() { + const backendModel = this.modelFor('application') as SecretEngineModel; + return hash({ + backendModel, + promptConfig: this.promptConfig, + libraries: this.store.query('ldap/library', { backend: backendModel.id }), + }); + } + + setupController( + controller: LdapLibrariesController, + resolvedModel: LdapLibrariesRouteModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: resolvedModel.backendModel.id }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library.ts b/ui/lib/ldap/addon/routes/libraries/library.ts new file mode 100644 index 0000000000..85e0d1ac74 --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; + +interface LdapLibraryRouteParams { + name: string; +} + +export default class LdapLibraryRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + model(params: LdapLibraryRouteParams) { + const backend = this.secretMountPath.currentPath; + const { name } = params; + return this.store.queryRecord('ldap/library', { backend, name }); + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library/check-out.ts b/ui/lib/ldap/addon/routes/libraries/library/check-out.ts new file mode 100644 index 0000000000..455a1a0553 --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library/check-out.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import errorMessage from 'vault/utils/error-message'; + +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; +import { LdapLibraryCheckOutCredentials } from 'vault/vault/adapters/ldap/library'; +import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports + +interface LdapLibraryCheckOutController extends Controller { + breadcrumbs: Array; + model: LdapLibraryCheckOutCredentials; +} + +export default class LdapLibraryCheckOutRoute extends Route { + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + + accountsRoute = 'vault.cluster.secrets.backend.ldap.libraries.library.details.accounts'; + + beforeModel(transition: Transition) { + // transition must be from the details.accounts route to ensure it was initiated by the check-out action + if (transition.from?.name !== this.accountsRoute) { + this.router.replaceWith(this.accountsRoute); + } + } + model(_params: object, transition: Transition) { + const { ttl } = transition.to.queryParams; + const library = this.modelFor('libraries.library') as LdapLibraryModel; + return library.checkOutAccount(ttl); + } + setupController( + controller: LdapLibraryCheckOutController, + resolvedModel: LdapLibraryCheckOutCredentials, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + const library = this.modelFor('libraries.library') as LdapLibraryModel; + controller.breadcrumbs = [ + { label: library.backend, route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: library.name, route: 'libraries.library' }, + { label: 'check-out' }, + ]; + } + + @action + error(error: AdapterError) { + // if check-out fails, return to library details route + const message = errorMessage(error, 'Error checking out account. Please try again or contact support.'); + this.flashMessages.danger(message); + this.router.replaceWith(this.accountsRoute); + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library/details.ts b/ui/lib/ldap/addon/routes/libraries/library/details.ts new file mode 100644 index 0000000000..61b24d3b29 --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library/details.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapLibraryDetailsController extends Controller { + breadcrumbs: Array; + model: LdapLibraryModel; +} + +export default class LdapLibraryDetailsRoute extends Route { + setupController( + controller: LdapLibraryDetailsController, + resolvedModel: LdapLibraryModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: resolvedModel.backend, route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: resolvedModel.name }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library/details/accounts.ts b/ui/lib/ldap/addon/routes/libraries/library/details/accounts.ts new file mode 100644 index 0000000000..129451470f --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library/details/accounts.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { hash } from 'rsvp'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; + +export default class LdapLibraryRoute extends Route { + model() { + const model = this.modelFor('libraries.library') as LdapLibraryModel; + return hash({ + library: model, + statuses: model.fetchStatus(), + }); + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library/details/index.ts b/ui/lib/ldap/addon/routes/libraries/library/details/index.ts new file mode 100644 index 0000000000..a3a3c92585 --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library/details/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type RouterService from '@ember/routing/router-service'; + +export default class LdapLibraryRoute extends Route { + @service declare readonly router: RouterService; + + redirect() { + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details.accounts'); + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library/edit.ts b/ui/lib/ldap/addon/routes/libraries/library/edit.ts new file mode 100644 index 0000000000..c64b45dd8d --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library/edit.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; + +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapLibraryEditController extends Controller { + breadcrumbs: Array; + model: LdapLibraryModel; +} + +export default class LdapLibraryEditRoute extends Route { + setupController( + controller: LdapLibraryEditController, + resolvedModel: LdapLibraryModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: resolvedModel.backend, route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: resolvedModel.name, route: 'libraries.library.details' }, + { label: 'edit' }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/libraries/library/index.ts b/ui/lib/ldap/addon/routes/libraries/library/index.ts new file mode 100644 index 0000000000..61dd0122d8 --- /dev/null +++ b/ui/lib/ldap/addon/routes/libraries/library/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type RouterService from '@ember/routing/router-service'; + +export default class LdapLibraryRoute extends Route { + @service declare readonly router: RouterService; + + redirect() { + this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries.library.details'); + } +} diff --git a/ui/lib/ldap/addon/routes/overview.ts b/ui/lib/ldap/addon/routes/overview.ts new file mode 100644 index 0000000000..cf774c453c --- /dev/null +++ b/ui/lib/ldap/addon/routes/overview.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; +import { hash } from 'rsvp'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type Transition from '@ember/routing/transition'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type LdapRoleModel from 'vault/models/ldap/role'; +import type LdapLibraryModel from 'vault/models/ldap/library'; +import type Controller from '@ember/controller'; +import type { Breadcrumb } from 'vault/vault/app-types'; +import { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library'; + +interface LdapOverviewController extends Controller { + breadcrumbs: Array; +} +interface LdapOverviewRouteModel { + backendModel: SecretEngineModel; + promptConfig: boolean; + roles: Array; + libraries: Array; + librariesStatus: Array; +} + +@withConfig('ldap/config') +export default class LdapOverviewRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + declare promptConfig: boolean; + + async fetchLibrariesStatus(libraries: Array): Promise> { + const allStatuses: Array = []; + + for (const library of libraries) { + try { + const statuses = await library.fetchStatus(); + allStatuses.push(...statuses); + } catch (error) { + // suppressing error + } + } + return allStatuses; + } + + async fetchLibraries(backend: string) { + return this.store.query('ldap/library', { backend }).catch(() => []); + } + + async model() { + const backend = this.secretMountPath.currentPath; + const libraries = await this.fetchLibraries(backend); + return hash({ + promptConfig: this.promptConfig, + backendModel: this.modelFor('application'), + roles: this.store.query('ldap/role', { backend }).catch(() => []), + libraries, + librariesStatus: this.fetchLibrariesStatus(libraries as Array), + }); + } + + setupController( + controller: LdapOverviewController, + resolvedModel: LdapOverviewRouteModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: resolvedModel.backendModel.id }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/roles/create.ts b/ui/lib/ldap/addon/routes/roles/create.ts new file mode 100644 index 0000000000..7026b66e77 --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/create.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type LdapRoleModel from 'vault/models/ldap/role'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapRolesCreateController extends Controller { + breadcrumbs: Array; + model: LdapRoleModel; +} + +export default class LdapRolesCreateRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + model() { + const backend = this.secretMountPath.currentPath; + return this.store.createRecord('ldap/role', { backend }); + } + + setupController( + controller: LdapRolesCreateController, + resolvedModel: LdapRoleModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: resolvedModel.backend, route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: 'create' }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/roles/index.ts b/ui/lib/ldap/addon/routes/roles/index.ts new file mode 100644 index 0000000000..6d4818a986 --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/index.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; +import { hash } from 'rsvp'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type Transition from '@ember/routing/transition'; +import type LdapRoleModel from 'vault/models/ldap/role'; +import type SecretEngineModel from 'vault/models/secret-engine'; +import type Controller from '@ember/controller'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapRolesRouteModel { + backendModel: SecretEngineModel; + promptConfig: boolean; + roles: Array; +} +interface LdapRolesController extends Controller { + breadcrumbs: Array; + model: LdapRolesRouteModel; +} + +@withConfig('ldap/config') +export default class LdapRolesRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + declare promptConfig: boolean; + + model() { + const backendModel = this.modelFor('application') as SecretEngineModel; + return hash({ + backendModel, + promptConfig: this.promptConfig, + roles: this.store.query( + 'ldap/role', + { backend: backendModel.id }, + { adapterOptions: { showPartialError: true } } + ), + }); + } + + setupController( + controller: LdapRolesController, + resolvedModel: LdapRolesRouteModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: resolvedModel.backendModel.id }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/roles/role.ts b/ui/lib/ldap/addon/routes/roles/role.ts new file mode 100644 index 0000000000..238cdf01f2 --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/role.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type Store from '@ember-data/store'; +import type SecretMountPath from 'vault/services/secret-mount-path'; + +interface LdapRoleRouteParams { + name: string; + type: string; +} + +export default class LdapRoleRoute extends Route { + @service declare readonly store: Store; + @service declare readonly secretMountPath: SecretMountPath; + + model(params: LdapRoleRouteParams) { + const backend = this.secretMountPath.currentPath; + const { name, type } = params; + return this.store.queryRecord('ldap/role', { backend, name, type }); + } +} diff --git a/ui/lib/ldap/addon/routes/roles/role/credentials.ts b/ui/lib/ldap/addon/routes/roles/role/credentials.ts new file mode 100644 index 0000000000..f0c91641fa --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/role/credentials.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type Store from '@ember-data/store'; +import type LdapRoleModel from 'vault/models/ldap/role'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapRoleCredentialsController extends Controller { + breadcrumbs: Array; + model: LdapRoleModel; +} +export interface LdapStaticRoleCredentials { + dn: string; + last_vault_rotation: string; + password: string; + last_password: string; + rotation_period: number; + ttl: number; + username: string; + type: string; +} +export interface LdapDynamicRoleCredentials { + distinguished_names: Array; + password: string; + username: string; + lease_id: string; + lease_duration: string; + renewable: boolean; + type: string; +} + +export default class LdapRoleCredentialsRoute extends Route { + @service declare readonly store: Store; + + model() { + const role = this.modelFor('roles.role') as LdapRoleModel; + return role.fetchCredentials(); + } + setupController( + controller: LdapRoleCredentialsController, + resolvedModel: LdapStaticRoleCredentials | LdapDynamicRoleCredentials, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + const role = this.modelFor('roles.role') as LdapRoleModel; + controller.breadcrumbs = [ + { label: role.backend, route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: role.name, route: 'roles.role' }, + { label: 'credentials' }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/roles/role/details.ts b/ui/lib/ldap/addon/routes/roles/role/details.ts new file mode 100644 index 0000000000..278e3f053f --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/role/details.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; + +import type LdapRoleModel from 'vault/models/ldap/role'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapRoleDetailsController extends Controller { + breadcrumbs: Array; + model: LdapRoleModel; +} + +export default class LdapRoleEditRoute extends Route { + setupController( + controller: LdapRoleDetailsController, + resolvedModel: LdapRoleModel, + transition: Transition + ) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: resolvedModel.backend, route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: resolvedModel.name }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/roles/role/edit.ts b/ui/lib/ldap/addon/routes/roles/role/edit.ts new file mode 100644 index 0000000000..b4fff55ddf --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/role/edit.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; + +import type LdapRoleModel from 'vault/models/ldap/role'; +import type Controller from '@ember/controller'; +import type Transition from '@ember/routing/transition'; +import type { Breadcrumb } from 'vault/vault/app-types'; + +interface LdapRoleEditController extends Controller { + breadcrumbs: Array; + model: LdapRoleModel; +} + +export default class LdapRoleEditRoute extends Route { + setupController(controller: LdapRoleEditController, resolvedModel: LdapRoleModel, transition: Transition) { + super.setupController(controller, resolvedModel, transition); + + controller.breadcrumbs = [ + { label: resolvedModel.backend, route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: resolvedModel.name, route: 'roles.role' }, + { label: 'edit' }, + ]; + } +} diff --git a/ui/lib/ldap/addon/routes/roles/role/index.ts b/ui/lib/ldap/addon/routes/roles/role/index.ts new file mode 100644 index 0000000000..5133dc9066 --- /dev/null +++ b/ui/lib/ldap/addon/routes/roles/role/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +import type RouterService from '@ember/routing/router-service'; + +export default class LdapRoleRoute extends Route { + @service declare readonly router: RouterService; + + redirect() { + this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles.role.details'); + } +} diff --git a/ui/lib/ldap/addon/templates/configuration.hbs b/ui/lib/ldap/addon/templates/configuration.hbs new file mode 100644 index 0000000000..d79318f09d --- /dev/null +++ b/ui/lib/ldap/addon/templates/configuration.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/configure.hbs b/ui/lib/ldap/addon/templates/configure.hbs new file mode 100644 index 0000000000..ade9f95d59 --- /dev/null +++ b/ui/lib/ldap/addon/templates/configure.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/error.hbs b/ui/lib/ldap/addon/templates/error.hbs new file mode 100644 index 0000000000..ec31dbdc45 --- /dev/null +++ b/ui/lib/ldap/addon/templates/error.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/create.hbs b/ui/lib/ldap/addon/templates/libraries/create.hbs new file mode 100644 index 0000000000..6d6a9b60f1 --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/create.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/index.hbs b/ui/lib/ldap/addon/templates/libraries/index.hbs new file mode 100644 index 0000000000..6a7b8c357e --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/index.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/library/check-out.hbs b/ui/lib/ldap/addon/templates/libraries/library/check-out.hbs new file mode 100644 index 0000000000..df9b3dd0ca --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/library/check-out.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/library/details.hbs b/ui/lib/ldap/addon/templates/libraries/library/details.hbs new file mode 100644 index 0000000000..3facec177d --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/library/details.hbs @@ -0,0 +1,3 @@ + + +{{outlet}} \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/library/details/accounts.hbs b/ui/lib/ldap/addon/templates/libraries/library/details/accounts.hbs new file mode 100644 index 0000000000..f4a0737bac --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/library/details/accounts.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/library/details/configuration.hbs b/ui/lib/ldap/addon/templates/libraries/library/details/configuration.hbs new file mode 100644 index 0000000000..5bb8dc5833 --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/library/details/configuration.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/libraries/library/edit.hbs b/ui/lib/ldap/addon/templates/libraries/library/edit.hbs new file mode 100644 index 0000000000..6d6a9b60f1 --- /dev/null +++ b/ui/lib/ldap/addon/templates/libraries/library/edit.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/overview.hbs b/ui/lib/ldap/addon/templates/overview.hbs new file mode 100644 index 0000000000..78e8ad99f1 --- /dev/null +++ b/ui/lib/ldap/addon/templates/overview.hbs @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/roles/create.hbs b/ui/lib/ldap/addon/templates/roles/create.hbs new file mode 100644 index 0000000000..a5770ab85a --- /dev/null +++ b/ui/lib/ldap/addon/templates/roles/create.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/roles/index.hbs b/ui/lib/ldap/addon/templates/roles/index.hbs new file mode 100644 index 0000000000..694d1818d8 --- /dev/null +++ b/ui/lib/ldap/addon/templates/roles/index.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/roles/role/credentials.hbs b/ui/lib/ldap/addon/templates/roles/role/credentials.hbs new file mode 100644 index 0000000000..3918b7c16e --- /dev/null +++ b/ui/lib/ldap/addon/templates/roles/role/credentials.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/roles/role/details.hbs b/ui/lib/ldap/addon/templates/roles/role/details.hbs new file mode 100644 index 0000000000..e7ad643c75 --- /dev/null +++ b/ui/lib/ldap/addon/templates/roles/role/details.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/addon/templates/roles/role/edit.hbs b/ui/lib/ldap/addon/templates/roles/role/edit.hbs new file mode 100644 index 0000000000..a5770ab85a --- /dev/null +++ b/ui/lib/ldap/addon/templates/roles/role/edit.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/ldap/config/environment.js b/ui/lib/ldap/config/environment.js new file mode 100644 index 0000000000..4368f39225 --- /dev/null +++ b/ui/lib/ldap/config/environment.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/* eslint-env node */ +'use strict'; + +module.exports = function (environment) { + const ENV = { + modulePrefix: 'ldap', + environment, + }; + + return ENV; +}; diff --git a/ui/lib/ldap/index.js b/ui/lib/ldap/index.js new file mode 100644 index 0000000000..cade4e0e02 --- /dev/null +++ b/ui/lib/ldap/index.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/* eslint-env node */ +/* eslint-disable n/no-extraneous-require */ +'use strict'; + +const { buildEngine } = require('ember-engines/lib/engine-addon'); + +module.exports = buildEngine({ + name: 'ldap', + + lazyLoading: Object.freeze({ + enabled: false, + }), + + isDevelopingAddon() { + return true; + }, +}); diff --git a/ui/lib/ldap/package.json b/ui/lib/ldap/package.json new file mode 100644 index 0000000000..5288e352ef --- /dev/null +++ b/ui/lib/ldap/package.json @@ -0,0 +1,29 @@ +{ + "name": "ldap", + "keywords": [ + "ember-addon", + "ember-engine" + ], + "dependencies": { + "@hashicorp/design-system-components": "*", + "ember-cli-htmlbars": "*", + "ember-cli-babel": "*", + "ember-concurrency": "*", + "@ember/test-waiters": "*", + "ember-cli-typescript": "*", + "@types/ember": "latest", + "@types/ember-data": "latest", + "@types/ember-data__store": "latest", + "@types/ember__array": "latest", + "@types/ember__component": "latest", + "@types/ember__controller": "latest", + "@types/ember__engine": "latest", + "@types/ember__routing": "latest", + "@types/rsvp": "latest" + }, + "ember-addon": { + "paths": [ + "../core" + ] + } +} diff --git a/ui/mirage/factories/ldap-config.js b/ui/mirage/factories/ldap-config.js new file mode 100644 index 0000000000..4b2db19895 --- /dev/null +++ b/ui/mirage/factories/ldap-config.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory } from 'ember-cli-mirage'; + +export default Factory.extend({ + backend: 'ldap-test', + binddn: 'cn=vault,ou=Users,dc=hashicorp,dc=com', + bindpass: 'pa$$w0rd', + url: 'ldaps://127.0.0.11', + password_policy: 'default', + schema: 'openldap', + starttls: false, + insecure_tls: false, + certificate: + '-----BEGIN CERTIFICATE-----\nMIIDNTCCAh2gApGgAwIBAgIULNEk+01LpkDeJujfsAgIULNEkAgIULNEckApGgAwIBAg+01LpkDeJuj\n-----END CERTIFICATE-----', + client_tls_cert: + '-----BEGIN CERTIFICATE-----\nMIIDNTCCAh2gApGgAwIBAgIULNEk+01LpkDeJujfsAgIULNEkAgIULNEckApGgAwIBAg+01LpkDeJuj\n-----END CERTIFICATE-----', + client_tls_key: '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', + userdn: 'ou=Users,dc=hashicorp,dc=com', + userattr: 'cn', + upndomain: 'vault@hashicorp.com', + connection_timeout: 90, + request_timeout: 30, +}); diff --git a/ui/mirage/factories/ldap-credential.js b/ui/mirage/factories/ldap-credential.js new file mode 100644 index 0000000000..ce3d6dbd6a --- /dev/null +++ b/ui/mirage/factories/ldap-credential.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory, trait } from 'ember-cli-mirage'; + +export default Factory.extend({ + // static props + static: trait({ + last_vault_rotation: '2023-07-31T10:32:49.744033-06:00', + password: 'fQ428N5JVeB2MbINwBCIbPh2ffhkJP0jZT3SfopZO0xRmbOaKRa6bwtAw3d2m4DR', + username: 'foobar', + rotation_period: 86400, + ttl: 71365, + type: 'static', + }), + + // dynamic props + dynamic: trait({ + distinguished_names: [ + 'cn=v_userpass-test_dynamic-role_mrx3r26XIj_1690836430,ou=users,dc=learn,dc=example', + ], + username: 'v_userpass-test_dynamic-role_mrx3r26XIj_1690836430', + password: 'YE2qe1vpiBtEvjCSr7BmI0NhSPPmrizngNYxa3lEebMFvAussxHf3PWfDVJPxXj1', + lease_id: 'ldap/creds/dynamic-role/SZN8HcuieCbdDobD7jTb6V9X', + lease_duration: 3600, + renewable: true, + type: 'dynamic', + }), +}); diff --git a/ui/mirage/factories/ldap-library.js b/ui/mirage/factories/ldap-library.js new file mode 100644 index 0000000000..dd22c92d2a --- /dev/null +++ b/ui/mirage/factories/ldap-library.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory } from 'ember-cli-mirage'; + +export default Factory.extend({ + name: (i) => `library-${i}`, + service_account_names: () => ['fizz@example.com', 'buzz@example.com'], + ttl: '10h', + max_ttl: '20h', + disable_check_in_enforcement: false, +}); diff --git a/ui/mirage/factories/ldap-role.js b/ui/mirage/factories/ldap-role.js new file mode 100644 index 0000000000..e92a8a1968 --- /dev/null +++ b/ui/mirage/factories/ldap-role.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory, trait } from 'ember-cli-mirage'; + +export default Factory.extend({ + name: (i) => `role-${i}`, + + // static props + static: trait({ + dn: 'cn=hashicorp,ou=Users,dc=hashicorp,dc=com', + rotation_period: 10, + username: 'hashicorp', + type: 'static', + }), + + // dynamic props + dynamic: trait({ + creation_ldif: `dn: cn={{.Username}},ou=users,dc=learn,dc=example + objectClass: person + objectClass: top + cn: learn + sn: {{.Password | utf16le | base64}} + memberOf: cn=dev,ou=groups,dc=learn,dc=example + userPassword: {{.Password}} + `, + deletion_ldif: `dn: cn={{.Username}},ou=users,dc=learn,dc=example + changetype: delete + `, + rollback_ldif: `dn: cn={{.Username}},ou=users,dc=learn,dc=example + changetype: delete + `, + username_template: '{{.DisplayName}}_{{.RoleName}}', + default_ttl: 3600, + max_ttl: 86400, + type: 'dynamic', + }), +}); diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js index 65377c3568..5da1be58be 100644 --- a/ui/mirage/handlers/index.js +++ b/ui/mirage/handlers/index.js @@ -14,5 +14,6 @@ import mfaLogin from './mfa-login'; import oidcConfig from './oidc-config'; import hcpLink from './hcp-link'; import kubernetes from './kubernetes'; +import ldap from './ldap'; -export { base, clients, db, kms, mfaConfig, mfaLogin, oidcConfig, hcpLink, kubernetes }; +export { base, clients, db, kms, mfaConfig, mfaLogin, oidcConfig, hcpLink, kubernetes, ldap }; diff --git a/ui/mirage/handlers/ldap.js b/ui/mirage/handlers/ldap.js new file mode 100644 index 0000000000..afa56d921e --- /dev/null +++ b/ui/mirage/handlers/ldap.js @@ -0,0 +1,74 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Response } from 'miragejs'; + +export default function (server) { + const query = (req) => { + const { name, backend } = req.params; + return name ? { name } : { backend }; + }; + const getRecord = (schema, req, dbKey) => { + const record = schema.db[dbKey].findBy(query(req)); + if (record) { + delete record.id; + delete record.name; + delete record.backend; + delete record.type; + return { data: record }; + } + return new Response(404, {}, { errors: [] }); + }; + const createOrUpdateRecord = (schema, req, dbKey) => { + const data = JSON.parse(req.requestBody); + const dbCollection = schema.db[dbKey]; + dbCollection.firstOrCreate(query(req), data); + dbCollection.update(query(req), data); + return new Response(204); + }; + const listRecords = (schema, dbKey, query = {}) => { + const records = schema.db[dbKey].where(query); + return { + data: { keys: records.map((record) => record.name) }, + }; + }; + + // mount + server.post('/sys/mounts/:path', () => new Response(204)); + server.get('/sys/internal/ui/mounts/:path', () => ({ + data: { + accessor: 'ldap_ade94329', + type: 'ldap', + path: 'ldap-test/', + uuid: '35e9119d-5708-4b6b-58d2-f913e27f242d', + config: {}, + }, + })); + // config + server.post('/:backend/config', (schema, req) => createOrUpdateRecord(schema, req, 'ldapConfigs')); + server.get('/:backend/config', (schema, req) => getRecord(schema, req, 'ldapConfigs')); + // roles + server.post('/:backend/static-role/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapRoles')); + server.post('/:backend/role/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapRoles')); + server.get('/:backend/static-role/:name', (schema, req) => getRecord(schema, req, 'ldapRoles', 'static')); + server.get('/:backend/role/:name', (schema, req) => getRecord(schema, req, 'ldapRoles', 'dynamic')); + server.get('/:backend/static-role', (schema) => listRecords(schema, 'ldapRoles', { type: 'static' })); + server.get('/:backend/role', (schema) => listRecords(schema, 'ldapRoles', { type: 'dynamic' })); + // role credentials + server.get('/:backend/static-cred/:name', (schema) => ({ + data: schema.db.ldapCredentials.firstOrCreate({ type: 'static' }), + })); + server.get('/:backend/creds/:name', (schema) => ({ + data: schema.db.ldapCredentials.firstOrCreate({ type: 'dynamic' }), + })); + // libraries + server.post('/:backend/library/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapLibraries')); + server.get('/:backend/library/:name', (schema, req) => getRecord(schema, req, 'ldapLibraries')); + server.get('/:backend/library', (schema) => listRecords(schema, 'ldapLibraries')); + server.get('/:backend/library/:name/status', () => ({ + 'bob.johnson': { available: false, borrower_client_token: '8b80c305eb3a7dbd161ef98f10ea60a116ce0910' }, + 'mary.smith': { available: true }, + })); +} diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 72963c52f1..56a74a5405 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -5,13 +5,13 @@ import ENV from 'vault/config/environment'; const { handler } = ENV['ember-cli-mirage']; -import kubernetesScenario from './kubernetes'; +import scenarios from './index'; export default function (server) { server.create('clients/config'); server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] }); - if (handler === 'kubernetes') { - kubernetesScenario(server); + if (handler in scenarios) { + scenarios[handler](server); } } diff --git a/ui/mirage/scenarios/index.js b/ui/mirage/scenarios/index.js new file mode 100644 index 0000000000..c7adfcd73b --- /dev/null +++ b/ui/mirage/scenarios/index.js @@ -0,0 +1,4 @@ +import kubernetes from './kubernetes'; +import ldap from './ldap'; + +export { kubernetes, ldap }; diff --git a/ui/mirage/scenarios/ldap.js b/ui/mirage/scenarios/ldap.js new file mode 100644 index 0000000000..19b6b61f4c --- /dev/null +++ b/ui/mirage/scenarios/ldap.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export default function (server) { + server.create('ldap-config', { path: 'kubernetes' }); + server.create('ldap-role', 'static', { name: 'static-role' }); + server.create('ldap-role', 'dynamic', { name: 'dynamic-role' }); + server.create('ldap-library', { name: 'test-library' }); +} diff --git a/ui/package.json b/ui/package.json index be7cad9e41..273b8016e9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -240,6 +240,7 @@ "lib/keep-gitkeep", "lib/kmip", "lib/kubernetes", + "lib/ldap", "lib/kv", "lib/open-api-explorer", "lib/pki", diff --git a/ui/tests/acceptance/secrets/backend/database/secret-test.js b/ui/tests/acceptance/secrets/backend/database/secret-test.js index 5276c9d020..ccf018459c 100644 --- a/ui/tests/acceptance/secrets/backend/database/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/database/secret-test.js @@ -33,7 +33,7 @@ const newConnection = async (backend, plugin = 'mongodb-database-plugin') => { const navToConnection = async (backend, connection) => { await visit('/vault/secrets'); - await click(`[data-test-auth-backend-link="${backend}"]`); + await click(`[data-test-secrets-backend-link="${backend}"]`); await click('[data-test-secret-list-tab="Connections"]'); await click(`[data-test-secret-link="${connection}"]`); return; @@ -454,7 +454,7 @@ module('Acceptance | secrets/database/*', function (hooks) { // Check with restricted permissions await authPage.login(token); await click('[data-test-sidebar-nav-link="Secrets engines"]'); - assert.dom(`[data-test-auth-backend-link="${backend}"]`).exists('Shows backend on secret list page'); + assert.dom(`[data-test-secrets-backend-link="${backend}"]`).exists('Shows backend on secret list page'); await navToConnection(backend, connection); assert.strictEqual( currentURL(), diff --git a/ui/tests/acceptance/secrets/backend/engines-test.js b/ui/tests/acceptance/secrets/backend/engines-test.js index ed976aad13..79b657b77c 100644 --- a/ui/tests/acceptance/secrets/backend/engines-test.js +++ b/ui/tests/acceptance/secrets/backend/engines-test.js @@ -65,7 +65,7 @@ module('Acceptance | secret-engine list view', function (hooks) { await backendsPage.visit(); await settled(); - const rows = document.querySelectorAll('[data-test-auth-backend-link]'); + const rows = document.querySelectorAll('[data-test-secrets-backend-link]'); const rowUnsupported = Array.from(rows).filter((row) => row.innerText.includes('nomad')); const rowSupported = Array.from(rows).filter((row) => row.innerText.includes('cubbyhole')); assert @@ -93,7 +93,7 @@ module('Acceptance | secret-engine list view', function (hooks) { await clickTrigger('#filter-by-engine-type'); await searchSelect.options.objectAt(0).click(); - const rows = document.querySelectorAll('[data-test-auth-backend-link]'); + const rows = document.querySelectorAll('[data-test-secrets-backend-link]'); const rowsAws = Array.from(rows).filter((row) => row.innerText.includes('aws')); assert.strictEqual(rows.length, rowsAws.length, 'all rows returned are aws'); @@ -101,12 +101,12 @@ module('Acceptance | secret-engine list view', function (hooks) { await clickTrigger('#filter-by-engine-name'); const firstItemToSelect = searchSelect.options.objectAt(0).text; await searchSelect.options.objectAt(0).click(); - const singleRow = document.querySelectorAll('[data-test-auth-backend-link]'); + const singleRow = document.querySelectorAll('[data-test-secrets-backend-link]'); assert.strictEqual(singleRow.length, 1, 'returns only one row'); assert.dom(singleRow[0]).includesText(firstItemToSelect, 'shows the filtered by name engine'); // clear filter by engine name await searchSelect.deleteButtons.objectAt(1).click(); - const rowsAgain = document.querySelectorAll('[data-test-auth-backend-link]'); + const rowsAgain = document.querySelectorAll('[data-test-secrets-backend-link]'); assert.ok(rowsAgain.length > 1, 'filter has been removed'); // cleanup diff --git a/ui/tests/acceptance/secrets/backend/kv/secret-test.js b/ui/tests/acceptance/secrets/backend/kv/secret-test.js index fbf49af435..4fcc69ce53 100644 --- a/ui/tests/acceptance/secrets/backend/kv/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/secret-test.js @@ -712,7 +712,7 @@ module('Acceptance | secrets/secret/create, read, delete', function (hooks) { // on login users are directed to dashboard, so we would need to visit the vault secrets page to click on an engine await visit('vault/secrets'); // test if metadata tab there with no read access message and no ability to edit. - await click(`[data-test-auth-backend-link=${enginePath}]`); + await click(`[data-test-secrets-backend-link=${enginePath}]`); assert .dom('[data-test-get-credentials]') .exists( diff --git a/ui/tests/acceptance/secrets/backend/ldap/libraries-test.js b/ui/tests/acceptance/secrets/backend/ldap/libraries-test.js new file mode 100644 index 0000000000..10bc31f23e --- /dev/null +++ b/ui/tests/acceptance/secrets/backend/ldap/libraries-test.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import ldapMirageScenario from 'vault/mirage/scenarios/ldap'; +import ENV from 'vault/config/environment'; +import authPage from 'vault/tests/pages/auth'; +import { click } from '@ember/test-helpers'; +import { isURL, visitURL } from 'vault/tests/helpers/ldap'; + +module('Acceptance | ldap | libraries', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.before(function () { + ENV['ember-cli-mirage'].handler = 'ldap'; + }); + + hooks.beforeEach(async function () { + ldapMirageScenario(this.server); + await authPage.login(); + return visitURL('libraries'); + }); + + hooks.after(function () { + ENV['ember-cli-mirage'].handler = null; + }); + + test('it should transition to create library route on toolbar link click', async function (assert) { + await click('[data-test-toolbar-action="library"]'); + assert.true(isURL('libraries/create'), 'Transitions to library create route on toolbar link click'); + }); + + test('it should transition to library details route on list item click', async function (assert) { + await click('[data-test-list-item-link] a'); + assert.true( + isURL('libraries/test-library/details/accounts'), + 'Transitions to library details accounts route on list item click' + ); + }); + + test('it should transition to routes from list item action menu', async function (assert) { + assert.expect(2); + + for (const action of ['edit', 'details']) { + await click('[data-test-popup-menu-trigger]'); + await click(`[data-test-${action}]`); + const uri = action === 'details' ? 'details/accounts' : action; + assert.true( + isURL(`libraries/test-library/${uri}`), + `Transitions to ${action} route on list item action menu click` + ); + await click('[data-test-breadcrumb="libraries"]'); + } + }); + + test('it should transition to details routes from tab links', async function (assert) { + await click('[data-test-list-item-link] a'); + await click('[data-test-tab="config"]'); + assert.true( + isURL('libraries/test-library/details/configuration'), + 'Transitions to configuration route on tab click' + ); + + await click('[data-test-tab="accounts"]'); + assert.true( + isURL('libraries/test-library/details/accounts'), + 'Transitions to accounts route on tab click' + ); + }); + + test('it should transition to routes from library details toolbar links', async function (assert) { + await click('[data-test-list-item-link] a'); + await click('[data-test-edit]'); + assert.true(isURL('libraries/test-library/edit'), 'Transitions to credentials route from toolbar link'); + }); +}); diff --git a/ui/tests/acceptance/secrets/backend/ldap/overview-test.js b/ui/tests/acceptance/secrets/backend/ldap/overview-test.js new file mode 100644 index 0000000000..ba1a12ee22 --- /dev/null +++ b/ui/tests/acceptance/secrets/backend/ldap/overview-test.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import ldapMirageScenario from 'vault/mirage/scenarios/ldap'; +import ENV from 'vault/config/environment'; +import authPage from 'vault/tests/pages/auth'; +import { click, fillIn, visit } from '@ember/test-helpers'; +import { selectChoose } from 'ember-power-select/test-support'; +import { isURL, visitURL } from 'vault/tests/helpers/ldap'; + +module('Acceptance | ldap | overview', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.before(function () { + ENV['ember-cli-mirage'].handler = 'ldap'; + }); + + hooks.beforeEach(async function () { + return authPage.login(); + }); + + hooks.after(function () { + ENV['ember-cli-mirage'].handler = null; + }); + + test('it should transition to ldap overview on mount success', async function (assert) { + await visit('/vault/secrets'); + await click('[data-test-enable-engine]'); + await click('[data-test-mount-type="ldap"]'); + await click('[data-test-mount-next]'); + await fillIn('[data-test-input="path"]', 'ldap-test'); + await click('[data-test-mount-submit]'); + assert.true(isURL('overview'), 'Transitions to ldap overview route on mount success'); + }); + + test('it should transition to routes on tab link click', async function (assert) { + assert.expect(4); + + await visitURL('overview'); + + for (const tab of ['roles', 'libraries', 'config', 'overview']) { + await click(`[data-test-tab="${tab}"]`); + const route = tab === 'config' ? 'configuration' : tab; + assert.true(isURL(route), `Transitions to ${route} route on tab link click`); + } + }); + + test('it should transition to configuration route when engine is not configured', async function (assert) { + await visitURL('overview'); + await click('[data-test-config-cta] a'); + assert.true(isURL('configure'), 'Transitions to configure route on cta link click'); + + await click('[data-test-breadcrumb="ldap-test"]'); + await click('[data-test-toolbar-action="config"]'); + assert.true(isURL('configure'), 'Transitions to configure route on toolbar link click'); + }); + // including a test for the configuration route here since it is the only one needed + test('it should transition to configuration edit on toolbar link click', async function (assert) { + ldapMirageScenario(this.server); + await visitURL('overview'); + await click('[data-test-tab="config"]'); + await click('[data-test-toolbar-config-action]'); + assert.true(isURL('configure'), 'Transitions to configure route on toolbar link click'); + }); + + test('it should transition to create role route on card action link click', async function (assert) { + ldapMirageScenario(this.server); + await visitURL('overview'); + await click('[data-test-overview-card="Roles"] a'); + assert.true(isURL('roles/create'), 'Transitions to role create route on card action link click'); + }); + + test('it should transition to create library route on card action link click', async function (assert) { + ldapMirageScenario(this.server); + await visitURL('overview'); + await click('[data-test-overview-card="Libraries"] a'); + assert.true(isURL('libraries/create'), 'Transitions to library create route on card action link click'); + }); + + test('it should transition to role credentials route on generate credentials action', async function (assert) { + ldapMirageScenario(this.server); + await visitURL('overview'); + await selectChoose('.search-select', 'static-role'); + await click('[data-test-generate-credential-button]'); + assert.true( + isURL('roles/static/static-role/credentials'), + 'Transitions to role credentials route on generate credentials action' + ); + + await click('[data-test-breadcrumb="ldap-test"]'); + await selectChoose('.search-select', 'dynamic-role'); + await click('[data-test-generate-credential-button]'); + assert.true( + isURL('roles/dynamic/dynamic-role/credentials'), + 'Transitions to role credentials route on generate credentials action' + ); + }); +}); diff --git a/ui/tests/acceptance/secrets/backend/ldap/roles-test.js b/ui/tests/acceptance/secrets/backend/ldap/roles-test.js new file mode 100644 index 0000000000..ce0c659962 --- /dev/null +++ b/ui/tests/acceptance/secrets/backend/ldap/roles-test.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import ldapMirageScenario from 'vault/mirage/scenarios/ldap'; +import ENV from 'vault/config/environment'; +import authPage from 'vault/tests/pages/auth'; +import { click } from '@ember/test-helpers'; +import { isURL, visitURL } from 'vault/tests/helpers/ldap'; + +module('Acceptance | ldap | roles', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.before(function () { + ENV['ember-cli-mirage'].handler = 'ldap'; + }); + + hooks.beforeEach(async function () { + ldapMirageScenario(this.server); + await authPage.login(); + return visitURL('roles'); + }); + + hooks.after(function () { + ENV['ember-cli-mirage'].handler = null; + }); + + test('it should transition to create role route on toolbar link click', async function (assert) { + await click('[data-test-toolbar-action="role"]'); + assert.true(isURL('roles/create'), 'Transitions to role create route on toolbar link click'); + }); + + test('it should transition to role details route on list item click', async function (assert) { + await click('[data-test-list-item-link]:nth-of-type(1) a'); + assert.true( + isURL('roles/dynamic/dynamic-role/details'), + 'Transitions to role details route on list item click' + ); + + await click('[data-test-breadcrumb="roles"]'); + await click('[data-test-list-item-link]:nth-of-type(2) a'); + assert.true( + isURL('roles/static/static-role/details'), + 'Transitions to role details route on list item click' + ); + }); + + test('it should transition to routes from list item action menu', async function (assert) { + assert.expect(3); + + for (const action of ['edit', 'get-creds', 'details']) { + await click('[data-test-popup-menu-trigger]'); + await click(`[data-test-${action}]`); + const uri = action === 'get-creds' ? 'credentials' : action; + assert.true( + isURL(`roles/dynamic/dynamic-role/${uri}`), + `Transitions to ${uri} route on list item action menu click` + ); + await click('[data-test-breadcrumb="roles"]'); + } + }); + + test('it should transition to routes from role details toolbar links', async function (assert) { + await click('[data-test-list-item-link]:nth-of-type(1) a'); + await click('[data-test-get-credentials]'); + assert.true( + isURL('roles/dynamic/dynamic-role/credentials'), + 'Transitions to credentials route from toolbar link' + ); + + await click('[data-test-breadcrumb="dynamic-role"]'); + await click('[data-test-edit]'); + assert.true(isURL('roles/dynamic/dynamic-role/edit'), 'Transitions to edit route from toolbar link'); + }); +}); diff --git a/ui/tests/acceptance/settings/mount-secret-backend-test.js b/ui/tests/acceptance/settings/mount-secret-backend-test.js index af1f77006a..c7ca2fc6db 100644 --- a/ui/tests/acceptance/settings/mount-secret-backend-test.js +++ b/ui/tests/acceptance/settings/mount-secret-backend-test.js @@ -133,7 +133,7 @@ module('Acceptance | settings/mount-secret-backend', function (hooks) { await page.secretList(); await settled(); assert - .dom(`[data-test-auth-backend-link=${path}]`) + .dom(`[data-test-secrets-backend-link=${path}]`) .exists({ count: 1 }, 'renders only one instance of the engine'); }); diff --git a/ui/tests/helpers/ldap.js b/ui/tests/helpers/ldap.js new file mode 100644 index 0000000000..9edcc86494 --- /dev/null +++ b/ui/tests/helpers/ldap.js @@ -0,0 +1,35 @@ +import { visit, currentURL } from '@ember/test-helpers'; + +export const createSecretsEngine = (store) => { + store.pushPayload('secret-engine', { + modelName: 'secret-engine', + data: { + accessor: 'ldap_7e838627', + path: 'ldap-test/', + type: 'ldap', + }, + }); + return store.peekRecord('secret-engine', 'ldap-test'); +}; + +export const generateBreadcrumbs = (backend, childRoute) => { + const breadcrumbs = [{ label: 'secrets', route: 'secrets', linkExternal: true }]; + const root = { label: backend }; + if (childRoute) { + root.route = 'overview'; + breadcrumbs.push({ label: childRoute }); + } + breadcrumbs.splice(1, 0, root); + return breadcrumbs; +}; + +const baseURL = '/vault/secrets/ldap-test/ldap/'; +const stripLeadingSlash = (uri) => (uri.charAt(0) === '/' ? uri.slice(1) : uri); + +export const isURL = (uri) => { + return currentURL() === `${baseURL}${stripLeadingSlash(uri)}`; +}; + +export const visitURL = (uri) => { + return visit(`${baseURL}${stripLeadingSlash(uri)}`); +}; diff --git a/ui/tests/integration/components/filter-input-test.js b/ui/tests/integration/components/filter-input-test.js new file mode 100644 index 0000000000..fddd655675 --- /dev/null +++ b/ui/tests/integration/components/filter-input-test.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | filter-input', function (hooks) { + setupRenderingTest(hooks); + + test('it should render placeholder and send input event', async function (assert) { + assert.expect(2); + + this.onInput = (value) => { + assert.strictEqual(value, 'foo', 'onInput event sent with value'); + }; + + await render(hbs``); + + assert + .dom('[data-test-filter-input]') + .hasAttribute('placeholder', 'Filter roles', 'Placeholder set on input element'); + + await fillIn('[data-test-filter-input]', 'foo'); + }); +}); diff --git a/ui/tests/integration/components/json-editor-test.js b/ui/tests/integration/components/json-editor-test.js index 9ae8007c74..f38e17f8ae 100644 --- a/ui/tests/integration/components/json-editor-test.js +++ b/ui/tests/integration/components/json-editor-test.js @@ -6,7 +6,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { create } from 'ember-cli-page-object'; -import { render, fillIn, find, waitUntil } from '@ember/test-helpers'; +import { render, fillIn, find, waitUntil, click } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; import jsonEditor from '../../pages/components/json-editor'; import sinon from 'sinon'; @@ -78,4 +78,26 @@ module('Integration | Component | json-editor', function (hooks) { }); assert.dom('.CodeMirror-linenumber').doesNotExist('on readOnly does not show line numbers'); }); + + test('it should render example and restore it', async function (assert) { + this.value = null; + this.example = 'this is a test example'; + + await render(hbs` + + `); + + assert.dom('.CodeMirror-code').hasText(`1${this.example}`, 'Example renders when there is no value'); + assert.dom('[data-test-restore-example]').isDisabled('Restore button disabled when showing example'); + await fillIn('textarea', ''); + await fillIn('textarea', 'adding a value should allow the example to be restored'); + await click('[data-test-restore-example]'); + assert.dom('.CodeMirror-code').hasText(`1${this.example}`, 'Example is restored'); + assert.strictEqual(this.value, null, 'Value is cleared on restore example'); + }); }); diff --git a/ui/tests/integration/components/ldap/accounts-checked-out-test.js b/ui/tests/integration/components/ldap/accounts-checked-out-test.js new file mode 100644 index 0000000000..4f8e4512c4 --- /dev/null +++ b/ui/tests/integration/components/ldap/accounts-checked-out-test.js @@ -0,0 +1,147 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | AccountsCheckedOut', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + + this.store = this.owner.lookup('service:store'); + this.authStub = sinon.stub(this.owner.lookup('service:auth'), 'authData'); + + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + ...this.server.create('ldap-library', { name: 'test-library' }), + }); + this.library = this.store.peekRecord('ldap/library', 'test-library'); + this.statuses = [ + { + account: 'foo.bar', + available: false, + library: 'test-library', + borrower_client_token: '123', + borrower_entity_id: '456', + }, + { account: 'bar.baz', available: false, library: 'test-library' }, + { account: 'checked.in', available: true, library: 'test-library' }, + ]; + this.renderComponent = () => { + return render( + hbs` + + + `, + { + owner: this.engine, + } + ); + }; + }); + + test('it should render empty state when no accounts are checked out', async function (assert) { + this.statuses = [ + { account: 'foo', available: true, library: 'test-library' }, + { account: 'bar', available: true, library: 'test-library' }, + ]; + + await this.renderComponent(); + + assert + .dom('[data-test-empty-state-title]') + .hasText('No accounts checked out yet', 'Empty state title renders'); + assert + .dom('[data-test-empty-state-message]') + .hasText('There is no account that is currently in use.', 'Empty state message renders'); + }); + + test('it should filter accounts for root user', async function (assert) { + this.authStub.value({}); + + await this.renderComponent(); + + assert.dom('[data-test-checked-out-account]').exists({ count: 1 }, 'Correct number of accounts render'); + assert + .dom('[data-test-checked-out-account="bar.baz"]') + .hasText('bar.baz', 'Account renders that was checked out by root user'); + }); + + test('it should filter accounts for non root user', async function (assert) { + this.authStub.value({ entity_id: '456' }); + + await this.renderComponent(); + + assert.dom('[data-test-checked-out-account]').exists({ count: 1 }, 'Correct number of accounts render'); + assert + .dom('[data-test-checked-out-account="foo.bar"]') + .hasText('foo.bar', 'Account renders that was checked out by non root user'); + }); + + test('it should display all accounts when check-in enforcement is disabled on library', async function (assert) { + this.library.disable_check_in_enforcement = 'Disabled'; + + await this.renderComponent(); + + assert.dom('[data-test-checked-out-account]').exists({ count: 2 }, 'Correct number of accounts render'); + assert + .dom('[data-test-checked-out-account="checked.in"]') + .doesNotExist('checked.in', 'Checked in accounts do not render'); + }); + + test('it should display details in table', async function (assert) { + this.authStub.value({ entity_id: '456' }); + + await this.renderComponent(); + + assert.dom('[data-test-checked-out-account="foo.bar"]').hasText('foo.bar', 'Account renders'); + assert.dom('[data-test-checked-out-library="foo.bar"]').doesNotExist('Library column is hidden'); + assert + .dom('[data-test-checked-out-account-action="foo.bar"]') + .includesText('Check-in', 'Check-in action renders'); + + this.showLibraryColumn = true; + await this.renderComponent(); + + assert.dom('[data-test-checked-out-library="foo.bar"]').hasText('test-library', 'Library column renders'); + }); + + test('it should check in account', async function (assert) { + assert.expect(2); + + const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + this.library.disable_check_in_enforcement = 'Disabled'; + + this.server.post('/ldap-test/library/test-library/check-in', (schema, req) => { + const json = JSON.parse(req.requestBody); + assert.deepEqual( + json.service_account_names, + ['foo.bar'], + 'Check-in request made with correct account names' + ); + }); + + await this.renderComponent(); + + await click('[data-test-checked-out-account-action="foo.bar"]'); + await click('[data-test-check-in-confirm]'); + + const didTransition = transitionStub.calledWith( + 'vault.cluster.secrets.backend.ldap.libraries.library.details.accounts' + ); + assert.true(didTransition, 'Transitions to accounts route on check-in success'); + }); +}); diff --git a/ui/tests/integration/components/ldap/config-cta-test.js b/ui/tests/integration/components/ldap/config-cta-test.js new file mode 100644 index 0000000000..ff977603cd --- /dev/null +++ b/ui/tests/integration/components/ldap/config-cta-test.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | ldap | ConfigCta', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + test('it should render message and action', async function (assert) { + await render(hbs``, { owner: this.engine }); + assert.dom('[data-test-empty-state-title]').hasText('LDAP not configured', 'Title renders'); + assert + .dom('[data-test-empty-state-message]') + .hasText('Get started by setting up the connection with your existing LDAP system.', 'Message renders'); + assert.dom('[data-test-config-cta] a').hasText('Configure LDAP', 'Action renders'); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/configuration-test.js b/ui/tests/integration/components/ldap/page/configuration-test.js new file mode 100644 index 0000000000..5e75635edd --- /dev/null +++ b/ui/tests/integration/components/ldap/page/configuration-test.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { duration } from 'core/helpers/format-duration'; +import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap'; + +const selectors = { + rotateAction: '[data-test-toolbar-rotate-action] button', + confirmRotate: '[data-test-confirm-button]', + configAction: '[data-test-toolbar-config-action]', + configCta: '[data-test-config-cta]', + mountConfig: '[data-test-mount-config]', + pageError: '[data-test-page-error]', + fieldValue: (label) => `[data-test-value-div="${label}"]`, +}; + +module('Integration | Component | ldap | Page::Configuration', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + + this.backend = createSecretsEngine(this.store); + this.breadcrumbs = generateBreadcrumbs(this.backend.id); + + this.store.pushPayload('ldap/config', { + modelName: 'ldap/config', + backend: 'ldap-test', + ...this.server.create('ldap-config'), + }); + this.config = this.store.peekRecord('ldap/config', 'ldap-test'); + + this.renderComponent = () => { + return render( + hbs``, + { + owner: this.engine, + } + ); + }; + }); + + test('it should render tab page header, config cta and mount config', async function (assert) { + this.config = null; + + await this.renderComponent(); + + assert.dom('.title svg').hasClass('flight-icon-folder-users', 'LDAP icon renders in title'); + assert.dom('.title').hasText('ldap-test', 'Mount path renders in title'); + assert + .dom(selectors.rotateAction) + .doesNotExist('Rotate root action is hidden when engine is not configured'); + assert.dom(selectors.configAction).hasText('Configure LDAP', 'Toolbar action has correct text'); + assert.dom(selectors.configCta).exists('Config cta renders'); + assert.dom(selectors.mountConfig).exists('Mount config renders'); + }); + + test('it should render config fetch error', async function (assert) { + this.config = null; + this.error = { httpStatus: 403, message: 'Permission denied' }; + + await this.renderComponent(); + + assert.dom(selectors.pageError).exists('Config fetch error is rendered'); + }); + + test('it should render display fields', async function (assert) { + await this.renderComponent(); + + assert.dom(selectors.fieldValue('Administrator Distinguished Name')).hasText(this.config.binddn); + assert.dom(selectors.fieldValue('URL')).hasText(this.config.url); + assert.dom(selectors.fieldValue('Schema')).hasText(this.config.schema); + assert.dom(selectors.fieldValue('Password Policy')).hasText(this.config.password_policy); + assert.dom(selectors.fieldValue('Userdn')).hasText(this.config.userdn); + assert.dom(selectors.fieldValue('Userattr')).hasText(this.config.userattr); + assert + .dom(selectors.fieldValue('Connection Timeout')) + .hasText(duration([this.config.connection_timeout])); + assert.dom(selectors.fieldValue('Request Timeout')).hasText(duration([this.config.request_timeout])); + assert.dom(selectors.fieldValue('CA Certificate')).hasText(this.config.certificate); + assert.dom(selectors.fieldValue('Start TLS')).includesText('No'); + assert.dom(selectors.fieldValue('Insecure TLS')).includesText('No'); + assert.dom(selectors.fieldValue('Client TLS Certificate')).hasText(this.config.client_tls_cert); + assert.dom(selectors.fieldValue('Client TLS Key')).hasText(this.config.client_tls_key); + }); + + test('it should rotate root password', async function (assert) { + assert.expect(1); + + this.server.post(`/${this.config.backend}/rotate-root`, () => { + assert.ok(true, 'Request made to rotate root password'); + }); + + await this.renderComponent(); + await click(selectors.rotateAction); + await click(selectors.confirmRotate); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/configure-test.js b/ui/tests/integration/components/ldap/page/configure-test.js new file mode 100644 index 0000000000..7cdebf58a8 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/configure-test.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { Response } from 'miragejs'; +import sinon from 'sinon'; +import { generateBreadcrumbs } from 'vault/tests/helpers/ldap'; + +const selectors = { + radioCard: '[data-test-radio-card="OpenLDAP"]', + save: '[data-test-config-save]', + binddn: '[data-test-field="binddn"] input', + bindpass: '[data-test-field="bindpass"] input', +}; + +module('Integration | Component | ldap | Page::Configure', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + const fillAndSubmit = async (rotate) => { + await click(selectors.radioCard); + await fillIn(selectors.binddn, 'foo'); + await fillIn(selectors.bindpass, 'bar'); + await click(selectors.save); + await click(`[data-test-save-${rotate}-rotate]`); + }; + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.newModel = this.store.createRecord('ldap/config', { backend: 'ldap-new' }); + this.existingConfig = { + schema: 'openldap', + binddn: 'cn=vault,ou=Users,dc=hashicorp,dc=com', + bindpass: 'foobar', + }; + this.store.pushPayload('ldap/config', { + modelName: 'ldap/config', + backend: 'ldap-edit', + ...this.existingConfig, + }); + this.editModel = this.store.peekRecord('ldap/config', 'ldap-edit'); + this.breadcrumbs = generateBreadcrumbs('ldap', 'configure'); + this.model = this.newModel; // most of the tests use newModel but set this to editModel when needed + this.renderComponent = () => { + return render( + hbs``, + { + owner: this.engine, + } + ); + }; + this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + }); + + test('it should render empty state when schema is not selected', async function (assert) { + await this.renderComponent(); + + assert.dom('[data-test-empty-state-title]').hasText('Choose an option', 'Empty state title renders'); + assert + .dom('[data-test-empty-state-message]') + .hasText('Pick an option above to see available configuration options', 'Empty state title renders'); + assert.dom(selectors.save).isDisabled('Save button is disabled when schema is not selected'); + + await click(selectors.radioCard); + assert + .dom('[data-test-component="empty-state"]') + .doesNotExist('Empty state is hidden when schema is selected'); + }); + + test('it should render validation messages for invalid form', async function (assert) { + await this.renderComponent(); + + await click(selectors.radioCard); + await click(selectors.save); + + assert + .dom('[data-test-field="binddn"] [data-test-inline-error-message]') + .hasText('Administrator distinguished name is required.', 'Validation message renders for binddn'); + assert + .dom('[data-test-field="bindpass"] [data-test-inline-error-message]') + .hasText('Administrator password is required.', 'Validation message renders for bindpass'); + assert + .dom('[data-test-invalid-form-message] p') + .hasText('There are 2 errors with this form.', 'Invalid form message renders'); + }); + + test('it should save new configuration without rotating root password', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-new/config', () => { + assert.ok(true, 'POST request made to save config'); + return new Response(204, {}); + }); + + await this.renderComponent(); + await fillAndSubmit('without'); + + assert.ok( + this.transitionStub.calledWith('vault.cluster.secrets.backend.ldap.configuration'), + 'Transitions to configuration route on save success' + ); + }); + + test('it should save new configuration and rotate root password', async function (assert) { + assert.expect(3); + + this.server.post('/ldap-new/config', () => { + assert.ok(true, 'POST request made to save config'); + return new Response(204, {}); + }); + this.server.post('/ldap-new/rotate-root', () => { + assert.ok(true, 'POST request made to rotate root password'); + return new Response(204, {}); + }); + + await this.renderComponent(); + await fillAndSubmit('with'); + + assert.ok( + this.transitionStub.calledWith('vault.cluster.secrets.backend.ldap.configuration'), + 'Transitions to configuration route on save success' + ); + }); + + test('it should populate fields when editing form', async function (assert) { + this.model = this.editModel; + + await this.renderComponent(); + + assert.dom(selectors.radioCard).isChecked('Correct radio card is checked for schema value'); + assert.dom(selectors.binddn).hasValue(this.existingConfig.binddn, 'binddn value renders'); + + await fillIn(selectors.binddn, 'foobar'); + await click('[data-test-config-cancel]'); + + assert.strictEqual(this.model.binddn, this.existingConfig.binddn, 'Model is rolled back on cancel'); + assert.ok( + this.transitionStub.calledWith('vault.cluster.secrets.backend.ldap.configuration'), + 'Transitions to configuration route on save success' + ); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/libraries-test.js b/ui/tests/integration/components/ldap/page/libraries-test.js new file mode 100644 index 0000000000..1b8ee8abaf --- /dev/null +++ b/ui/tests/integration/components/ldap/page/libraries-test.js @@ -0,0 +1,115 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap'; + +module('Integration | Component | ldap | Page::Libraries', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + + this.store = this.owner.lookup('service:store'); + this.backend = createSecretsEngine(this.store); + this.breadcrumbs = generateBreadcrumbs(this.backend.id); + + for (const name of ['foo', 'bar']) { + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + ...this.server.create('ldap-library', { name }), + }); + } + this.libraries = this.store.peekAll('ldap/library'); + this.promptConfig = false; + + this.renderComponent = () => { + return render( + hbs``, + { owner: this.engine } + ); + }; + }); + + test('it should render tab page header and config cta', async function (assert) { + this.promptConfig = true; + + await this.renderComponent(); + + assert.dom('.title svg').hasClass('flight-icon-folder-users', 'LDAP icon renders in title'); + assert.dom('.title').hasText('ldap-test', 'Mount path renders in title'); + assert + .dom('[data-test-toolbar-action="config"]') + .hasText('Configure LDAP', 'Correct toolbar action renders'); + assert.dom('[data-test-config-cta]').exists('Config cta renders'); + }); + + test('it should render create libraries cta', async function (assert) { + this.libraries = null; + + await this.renderComponent(); + + assert + .dom('[data-test-toolbar-action="library"]') + .hasText('Create library', 'Toolbar action has correct text'); + assert + .dom('[data-test-toolbar-action="library"] svg') + .hasClass('flight-icon-plus', 'Toolbar action has correct icon'); + assert + .dom('[data-test-filter-input]') + .doesNotExist('Libraries filter input is hidden when libraries have not been created'); + assert.dom('[data-test-empty-state-title]').hasText('No libraries created yet', 'Title renders'); + assert + .dom('[data-test-empty-state-message]') + .hasText( + 'Use libraries to manage a set of highly privileged accounts that can be shared among a team.', + 'Message renders' + ); + assert.dom('[data-test-empty-state-actions] a').hasText('Create library', 'Action renders'); + }); + + test('it should render libraries list', async function (assert) { + await this.renderComponent(); + + assert.dom('[data-test-list-item-content] svg').hasClass('flight-icon-folder', 'List item icon renders'); + assert.dom('[data-test-library]').hasText(this.libraries.firstObject.name, 'List item name renders'); + + await click('[data-test-popup-menu-trigger]'); + assert.dom('[data-test-edit]').hasText('Edit', 'Edit link renders in menu'); + assert.dom('[data-test-details]').hasText('Details', 'Details link renders in menu'); + assert.dom('[data-test-delete]').hasText('Delete', 'Details link renders in menu'); + }); + + test('it should filter libraries', async function (assert) { + await this.renderComponent(); + + await fillIn('[data-test-filter-input]', 'baz'); + assert + .dom('[data-test-empty-state-title]') + .hasText('There are no libraries matching "baz"', 'Filter message renders'); + + await fillIn('[data-test-filter-input]', 'foo'); + assert.dom('[data-test-list-item-content]').exists({ count: 1 }, 'List is filtered with correct results'); + + await fillIn('[data-test-filter-input]', ''); + assert + .dom('[data-test-list-item-content]') + .exists({ count: 2 }, 'All libraries are displayed when filter is cleared'); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/library/check-out-test.js b/ui/tests/integration/components/ldap/page/library/check-out-test.js new file mode 100644 index 0000000000..3dacda591e --- /dev/null +++ b/ui/tests/integration/components/ldap/page/library/check-out-test.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | Page::Library::CheckOut', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.creds = { + account: 'foo.bar', + password: 'password', + lease_id: 'ldap/library/test/check-out/123', + lease_duration: 86400, + renewable: true, + }; + this.breadcrumbs = [ + { label: 'ldap-test', route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: 'test-library', route: 'libraries.library' }, + { label: 'check-out' }, + ]; + + this.renderComponent = () => { + return render( + hbs``, + { owner: this.engine } + ); + }; + }); + + test('it should render page title and breadcrumbs', async function (assert) { + await this.renderComponent(); + + assert.dom('[data-test-header-title]').hasText('Check-out', 'Page title renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(1)') + .containsText('ldap-test', 'Overview breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(2) a') + .containsText('libraries', 'Libraries breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(3)') + .containsText('test-library', 'Library breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(4)') + .containsText('check-out', 'Check-out breadcrumb renders'); + }); + + test('it should render check out information and credentials', async function (assert) { + const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + + await this.renderComponent(); + + assert + .dom('[data-test-alert-description]') + .hasText( + 'You won’t be able to access these credentials later, so please copy them now.', + 'Warning alert renders' + ); + assert.dom('[data-test-row-value="Account name"]').hasText('foo.bar', 'Account name renders'); + await click('[data-test-button="toggle-masked"]'); + assert.dom('[data-test-value-div="Password"] .masked-value').hasText('password', 'Password renders'); + assert + .dom('[data-test-row-value="Lease ID"]') + .hasText('ldap/library/test/check-out/123', 'Lease ID renders'); + assert + .dom('[data-test-value-div="Lease renewable"] svg') + .hasClass('flight-icon-check-circle', 'Lease renewable true icon renders'); + assert + .dom('[data-test-value-div="Lease renewable"] svg') + .hasClass('has-text-success', 'Lease renewable true icon color renders'); + assert.dom('[data-test-value-div="Lease renewable"] span').hasText('True', 'Lease renewable renders'); + + this.creds.renewable = false; + await this.renderComponent(); + assert + .dom('[data-test-value-div="Lease renewable"] svg') + .hasClass('flight-icon-x-circle', 'Lease renewable false icon renders'); + assert + .dom('[data-test-value-div="Lease renewable"] svg') + .hasClass('has-text-danger', 'Lease renewable false icon color renders'); + assert.dom('[data-test-value-div="Lease renewable"] span').hasText('False', 'Lease renewable renders'); + + await click('[data-test-done]'); + const didTransition = transitionStub.calledWith( + 'vault.cluster.secrets.backend.ldap.libraries.library.details.accounts' + ); + assert.true(didTransition, 'Transitions to accounts route on done'); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/library/create-and-edit-test.js b/ui/tests/integration/components/ldap/page/library/create-and-edit-test.js new file mode 100644 index 0000000000..eeef481a20 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/library/create-and-edit-test.js @@ -0,0 +1,158 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | Page::Library::CreateAndEdit', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + const router = this.owner.lookup('service:router'); + const routerStub = sinon.stub(router, 'transitionTo'); + this.transitionCalledWith = (routeName, name) => { + const route = `vault.cluster.secrets.backend.ldap.${routeName}`; + const args = name ? [route, name] : [route]; + return routerStub.calledWith(...args); + }; + + this.store = this.owner.lookup('service:store'); + this.newModel = this.store.createRecord('ldap/library', { backend: 'ldap-test' }); + + this.libraryData = this.server.create('ldap-library', { name: 'test-library' }); + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + ...this.libraryData, + }); + + this.breadcrumbs = [ + { label: 'ldap', route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: 'create' }, + ]; + + this.renderComponent = () => { + return render( + hbs``, + { owner: this.engine } + ); + }; + }); + + test('it should populate form when editing', async function (assert) { + this.model = this.store.peekRecord('ldap/library', this.libraryData.name); + + await this.renderComponent(); + + assert.dom('[data-test-input="name"]').hasValue(this.libraryData.name, 'Name renders'); + [0, 1].forEach((index) => { + assert + .dom(`[data-test-string-list-input="${index}"]`) + .hasValue(this.libraryData.service_account_names[index], 'Service account renders'); + }); + assert.dom('[data-test-ttl-value="Default lease TTL"]').hasAnyValue('Default lease ttl renders'); + assert.dom('[data-test-ttl-value="Max lease TTL"]').hasAnyValue('Max lease ttl renders'); + const checkInValue = this.libraryData.disable_check_in_enforcement ? 'Disabled' : 'Enabled'; + assert + .dom(`[data-test-input="disable_check_in_enforcement"] input#${checkInValue}`) + .isChecked('Correct radio is checked for check-in enforcement'); + }); + + test('it should go back to list route and clean up model on cancel', async function (assert) { + this.model = this.store.peekRecord('ldap/library', this.libraryData.name); + const spy = sinon.spy(this.model, 'rollbackAttributes'); + + await this.renderComponent(); + await click('[data-test-cancel]'); + + assert.ok(spy.calledOnce, 'Model is rolled back on cancel'); + assert.ok(this.transitionCalledWith('libraries'), 'Transitions to libraries list route on cancel'); + }); + + test('it should validate form fields', async function (assert) { + this.model = this.newModel; + + await this.renderComponent(); + await click('[data-test-save]'); + + assert + .dom('[data-test-field-validation="name"] p') + .hasText('Library name is required.', 'Name validation error renders'); + assert + .dom('[data-test-field-validation="service_account_names"] p') + .hasText('At least one service account is required.', 'Service account name validation error renders'); + assert + .dom('[data-test-invalid-form-message] p') + .hasText('There are 2 errors with this form.', 'Invalid form message renders'); + }); + + test('it should create new library', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-test/library/new-library', (schema, req) => { + const data = JSON.parse(req.requestBody); + const expected = { + service_account_names: 'foo@bar.com,bar@baz.com', + ttl: '24h', + max_ttl: '24h', + disable_check_in_enforcement: true, + }; + assert.deepEqual(data, expected, 'POST request made with correct properties when creating library'); + }); + + this.model = this.newModel; + + await this.renderComponent(); + + await fillIn('[data-test-input="name"]', 'new-library'); + await fillIn('[data-test-string-list-input="0"]', 'foo@bar.com'); + await click('[data-test-string-list-button="add"]'); + await fillIn('[data-test-string-list-input="1"]', 'bar@baz.com'); + await click('[data-test-string-list-button="add"]'); + await click('[data-test-input="disable_check_in_enforcement"] input#Disabled'); + await click('[data-test-save]'); + + assert.ok( + this.transitionCalledWith('libraries.library.details', 'new-library'), + 'Transitions to library details route on save success' + ); + }); + + test('it should save edited library with correct properties', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-test/library/test-library', (schema, req) => { + const data = JSON.parse(req.requestBody); + const expected = { + service_account_names: this.libraryData.service_account_names[1], + ttl: this.libraryData.ttl, + max_ttl: this.libraryData.max_ttl, + disable_check_in_enforcement: true, + }; + assert.deepEqual(expected, data, 'POST request made to save library with correct properties'); + }); + + this.model = this.store.peekRecord('ldap/library', this.libraryData.name); + + await this.renderComponent(); + + await click('[data-test-string-list-button="delete"]'); + await click('[data-test-input="disable_check_in_enforcement"] input#Disabled'); + await click('[data-test-save]'); + + assert.ok( + this.transitionCalledWith('libraries.library.details', 'test-library'), + 'Transitions to library details route on save success' + ); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/library/details-test.js b/ui/tests/integration/components/ldap/page/library/details-test.js new file mode 100644 index 0000000000..c4573978d7 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/library/details-test.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | Page::Library::Details', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', () => ({ + data: { + capabilities: ['root'], + }, + })); + + this.store = this.owner.lookup('service:store'); + + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + ...this.server.create('ldap-library', { name: 'test-library' }), + }); + this.model = this.store.peekRecord('ldap/library', 'test-library'); + + this.breadcrumbs = [ + { label: 'ldap-test', route: 'overview' }, + { label: 'libraries', route: 'libraries' }, + { label: 'test-library' }, + ]; + }); + + test('it should render page header, tabs and toolbar actions', async function (assert) { + assert.expect(10); + + this.server.delete(`/${this.model.backend}/library/${this.model.name}`, () => { + assert.ok(true, 'Request made to delete library'); + return; + }); + + await render(hbs``, { + owner: this.engine, + }); + + assert.dom('[data-test-header-title]').hasText(this.model.name, 'Library name renders in header'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(1)') + .containsText(this.model.backend, 'Overview breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(2) a') + .containsText('libraries', 'Libraries breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(3)') + .containsText(this.model.name, 'Library breadcrumb renders'); + + assert.dom('[data-test-tab="accounts"]').hasText('Accounts', 'Accounts tab renders'); + assert.dom('[data-test-tab="config"]').hasText('Configuration', 'Configuration tab renders'); + + assert.dom('[data-test-delete] button').hasText('Delete library', 'Delete action renders'); + assert.dom('[data-test-edit]').hasText('Edit library', 'Edit action renders'); + + const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + await click('[data-test-delete] button'); + await click('[data-test-confirm-button]'); + assert.ok( + transitionStub.calledWith('vault.cluster.secrets.backend.ldap.libraries'), + 'Transitions to libraries route on delete success' + ); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/library/details/accounts-test.js b/ui/tests/integration/components/ldap/page/library/details/accounts-test.js new file mode 100644 index 0000000000..eb7670bc98 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/library/details/accounts-test.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | Page::Library::Details::Accounts', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + + this.store = this.owner.lookup('service:store'); + + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + ...this.server.create('ldap-library', { name: 'test-library' }), + }); + this.model = this.store.peekRecord('ldap/library', 'test-library'); + this.statuses = [ + { + account: 'foo.bar', + available: false, + library: 'test-library', + borrower_client_token: '123', + borrower_entity_id: '456', + }, + { account: 'bar.baz', available: true, library: 'test-library' }, + ]; + this.renderComponent = () => { + return render( + hbs` + + + `, + { + owner: this.engine, + } + ); + }; + }); + + test('it should render account cards', async function (assert) { + const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + + await this.renderComponent(); + + assert.dom('[data-test-account-name="foo.bar"]').hasText('foo.bar', 'Account name renders'); + assert + .dom('[data-test-account-status="foo.bar"]') + .hasText('Unavailable', 'Correct badge renders for checked out account'); + assert + .dom('[data-test-account-status="bar.baz"]') + .hasText('Available', 'Correct badge renders for available account'); + + await click('[data-test-check-out]'); + await fillIn('[data-test-ttl-value="TTL"]', 4); + await click('[data-test-check-out="save"]'); + + const didTransition = transitionStub.calledWith( + 'vault.cluster.secrets.backend.ldap.libraries.library.check-out', + { queryParams: { ttl: '4h' } } + ); + assert.true(didTransition, 'Transitions to check out route on action click'); + + assert.dom('[data-test-checked-out-card]').exists('Accounts checked out card renders'); + + assert + .dom('[data-test-cli-command]') + .hasText('vault lease renew ad/library/test-library/check-out/:lease_id', 'Renew cli command renders'); + assert.dom(`[data-test-cli-command-copy]`).exists('Renew cli command copy button renders'); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/library/details/configuration-test.js b/ui/tests/integration/components/ldap/page/library/details/configuration-test.js new file mode 100644 index 0000000000..c703379b28 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/library/details/configuration-test.js @@ -0,0 +1,70 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { duration } from 'core/helpers/format-duration'; + +module('Integration | Component | ldap | Page::Library::Details::Configuration', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + ...this.server.create('ldap-library', { name: 'test-library' }), + }); + this.model = this.store.peekRecord('ldap/library', 'test-library'); + this.renderComponent = () => { + return render(hbs``, { + owner: this.engine, + }); + }; + }); + + test('it should render configuration details', async function (assert) { + await this.renderComponent(); + + const fields = [ + { label: 'Library name', key: 'name' }, + { label: 'TTL', key: 'ttl' }, + { label: 'Max TTL', key: 'max_ttl' }, + { label: 'Check-in enforcement', key: 'disable_check_in_enforcement' }, + ]; + fields.forEach((field) => { + const { label, key } = field; + const value = label.includes('TTL') ? duration([this.model[key]]) : this.model[key]; + const method = key === 'disable_check_in_enforcement' ? 'includesText' : 'hasText'; + + assert.dom(`[data-test-row-label="${label}"]`).hasText(label, `${label} info row label renders`); + assert.dom(`[data-test-value-div="${label}"]`)[method](value, `${label} info row label renders`); + }); + + assert + .dom('[data-test-check-in-icon]') + .hasClass('flight-icon-check-circle', 'Correct icon renders for enabled check in enforcement'); + assert + .dom('[data-test-check-in-icon]') + .hasClass('icon-true', 'Correct class renders for enabled check in enforcement'); + + this.model.disable_check_in_enforcement = 'Disabled'; + await this.renderComponent(); + + assert + .dom('[data-test-check-in-icon]') + .hasClass('flight-icon-x-square', 'Correct icon renders for disabled check in enforcement'); + assert + .dom('[data-test-check-in-icon]') + .hasClass('icon-false', 'Correct class renders for disabled check in enforcement'); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/overview-test.js b/ui/tests/integration/components/ldap/page/overview-test.js new file mode 100644 index 0000000000..c5771f8a5a --- /dev/null +++ b/ui/tests/integration/components/ldap/page/overview-test.js @@ -0,0 +1,93 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | Page::Overview', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + + this.backendModel = createSecretsEngine(this.store); + this.breadcrumbs = generateBreadcrumbs(this.backendModel.id); + + const pushPayload = (type) => { + this.store.pushPayload(`ldap/${type}`, { + modelName: `ldap/${type}`, + backend: 'ldap-test', + ...this.server.create(`ldap-${type}`), + }); + }; + + ['role', 'library'].forEach((type) => { + pushPayload(type); + if (type === 'role') { + pushPayload(type); + } + const key = type === 'role' ? 'roles' : 'libraries'; + this[key] = this.store.peekAll(`ldap/${type}`); + }); + + this.renderComponent = () => { + return render( + hbs``, + { + owner: this.engine, + } + ); + }; + }); + + test('it should render tab page header and config cta', async function (assert) { + this.promptConfig = true; + + await this.renderComponent(); + + assert.dom('.title svg').hasClass('flight-icon-folder-users', 'LDAP icon renders in title'); + assert.dom('.title').hasText('ldap-test', 'Mount path renders in title'); + assert.dom('[data-test-toolbar-action="config"]').hasText('Configure LDAP', 'Toolbar action renders'); + assert.dom('[data-test-config-cta]').exists('Config cta renders'); + }); + + test('it should render overview cards', async function (assert) { + const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + + await this.renderComponent(); + + assert.dom('[data-test-roles-count]').hasText('2', 'Roles card renders with correct count'); + assert.dom('[data-test-libraries-count]').hasText('1', 'Libraries card renders with correct count'); + assert + .dom('[data-test-overview-card-container="Accounts checked-out"]') + .exists('Accounts checked-out card renders'); + + await click('[data-test-component="search-select"] .ember-power-select-trigger'); + await click('.ember-power-select-option'); + await click('[data-test-generate-credential-button]'); + + const didTransition = transitionStub.calledWith( + 'vault.cluster.secrets.backend.ldap.roles.role.credentials', + this.roles[0].type, + this.roles[0].name + ); + assert.true(didTransition, 'Transitions to credentials route when generating credentials'); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/role/create-and-edit-test.js b/ui/tests/integration/components/ldap/page/role/create-and-edit-test.js new file mode 100644 index 0000000000..0df2016283 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/role/create-and-edit-test.js @@ -0,0 +1,197 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; + +module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + const router = this.owner.lookup('service:router'); + const routerStub = sinon.stub(router, 'transitionTo'); + this.transitionCalledWith = (routeName, name) => { + const route = `vault.cluster.secrets.backend.ldap.${routeName}`; + const args = name ? [route, name] : [route]; + return routerStub.calledWith(...args); + }; + + this.store = this.owner.lookup('service:store'); + this.newModel = this.store.createRecord('ldap/role', { backend: 'ldap-test' }); + + ['static', 'dynamic'].forEach((type) => { + this[`${type}RoleData`] = this.server.create('ldap-role', type, { name: `${type}-role` }); + this.store.pushPayload('ldap/role', { + modelName: 'ldap/role', + backend: 'ldap-test', + type, + ...this[`${type}RoleData`], + }); + }); + + this.breadcrumbs = [ + { label: 'ldap', route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: 'create' }, + ]; + + this.renderComponent = () => { + return render( + hbs``, + { owner: this.engine } + ); + }; + }); + + test('it should display different form fields based on type', async function (assert) { + assert.expect(12); + + this.model = this.newModel; + await this.renderComponent(); + + assert.dom('[data-test-radio-card="static"]').isChecked('Static role type selected by default'); + + const checkFields = (fields) => { + fields.forEach((field) => { + assert + .dom(`[data-test-field="${field}"]`) + .exists(`${field} field renders when static type is selected`); + }); + }; + + checkFields(['name', 'dn', 'username', 'rotation_period']); + await click('[data-test-radio-card="dynamic"]'); + checkFields([ + 'name', + 'default_ttl', + 'max_ttl', + 'username_template', + 'creation_ldif', + 'deletion_ldif', + 'rollback_ldif', + ]); + }); + + test('it should populate form and disable type cards when editing', async function (assert) { + assert.expect(12); + + const checkFields = (fields, element = 'input:last-child') => { + fields.forEach((field) => { + const isLdif = field.includes('ldif'); + const method = isLdif ? 'includesText' : 'hasValue'; + const value = isLdif ? 'dn: cn={{.Username}},ou=users,dc=learn,dc=example' : this.model[field]; + assert.dom(`[data-test-field="${field}"] ${element}`)[method](value, `${field} field value renders`); + }); + }; + const checkTtl = (fields) => { + fields.forEach((field) => { + assert + .dom(`[data-test-field="${field}"] [data-test-ttl-inputs] input`) + .hasAnyValue(`${field} field ttl value renders`); + }); + }; + + this.model = this.store.peekRecord('ldap/role', 'static-role'); + await this.renderComponent(); + assert.dom('[data-test-radio-card="static"]').isDisabled('Type selection is disabled when editing'); + checkFields(['name', 'dn', 'username']); + checkTtl(['rotation_period']); + + this.model = this.store.peekRecord('ldap/role', 'dynamic-role'); + await this.renderComponent(); + checkFields(['name', 'username_template']); + checkTtl(['default_ttl', 'max_ttl']); + checkFields(['creation_ldif', 'deletion_ldif', 'rollback_ldif'], '.CodeMirror-code'); + }); + + test('it should go back to list route and clean up model on cancel', async function (assert) { + this.model = this.store.peekRecord('ldap/role', 'static-role'); + const spy = sinon.spy(this.model, 'rollbackAttributes'); + + await this.renderComponent(); + await click('[data-test-cancel]'); + + assert.ok(spy.calledOnce, 'Model is rolled back on cancel'); + assert.ok(this.transitionCalledWith('roles'), 'Transitions to roles list route on cancel'); + }); + + test('it should validate form fields', async function (assert) { + const renderAndAssert = async (fields) => { + await this.renderComponent(); + await click('[data-test-save]'); + + fields.forEach((field) => { + assert + .dom(`[data-test-field="${field}"] [data-test-inline-error-message]`) + .exists('Validation message renders'); + }); + + assert + .dom('[data-test-invalid-form-message]') + .hasText(`There are ${fields.length} errors with this form.`); + }; + + this.model = this.newModel; + await renderAndAssert(['name', 'username', 'rotation_period']); + + await click('[data-test-radio-card="dynamic"]'); + await renderAndAssert(['name', 'creation_ldif', 'deletion_ldif']); + }); + + test('it should create new role', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-test/static-role/test-role', (schema, req) => { + const data = JSON.parse(req.requestBody); + const expected = { dn: 'foo', username: 'bar', rotation_period: '5s' }; + assert.deepEqual(data, expected, 'POST request made with correct properties when creating role'); + }); + + this.model = this.newModel; + await this.renderComponent(); + + await fillIn('[data-test-input="name"]', 'test-role'); + await fillIn('[data-test-input="dn"]', 'foo'); + await fillIn('[data-test-input="username"]', 'bar'); + await fillIn('[data-test-ttl-value="Rotation period"]', 5); + await click('[data-test-save]'); + + assert.ok( + this.transitionCalledWith('roles.role.details', 'static', 'test-role'), + 'Transitions to role details route on save success' + ); + }); + + test('it should save edited role with correct properties', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-test/static-role/:name', (schema, req) => { + const data = JSON.parse(req.requestBody); + const expected = { dn: 'foo', username: 'bar', rotation_period: '30s' }; + assert.deepEqual(expected, data, 'POST request made to save role with correct properties'); + }); + + this.model = this.store.peekRecord('ldap/role', 'static-role'); + await this.renderComponent(); + + await fillIn('[data-test-input="name"]', 'test-role'); + await fillIn('[data-test-input="dn"]', 'foo'); + await fillIn('[data-test-input="username"]', 'bar'); + await fillIn('[data-test-ttl-value="Rotation period"]', 30); + await click('[data-test-save]'); + + assert.ok( + this.transitionCalledWith('roles.role.details', 'static', 'test-role'), + 'Transitions to role details route on save success' + ); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/role/credentials-test.js b/ui/tests/integration/components/ldap/page/role/credentials-test.js new file mode 100644 index 0000000000..779793b75b --- /dev/null +++ b/ui/tests/integration/components/ldap/page/role/credentials-test.js @@ -0,0 +1,131 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import { duration } from 'core/helpers/format-duration'; +import { dateFormat } from 'core/helpers/date-format'; + +module('Integration | Component | ldap | Page::Role::Credentials', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.breadcrumbs = [ + { label: 'ldap-test', route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: 'test-role', route: 'roles.role' }, + { label: 'credentials' }, + ]; + this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + }); + + test('it should render page title and breadcrumbs', async function (assert) { + this.creds = []; + await render( + hbs``, + { owner: this.engine } + ); + + assert.dom('[data-test-header-title]').hasText('Credentials', 'Page title renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(1)') + .containsText('ldap-test', 'Overview breadcrumb renders'); + assert.dom('[data-test-breadcrumbs] li:nth-child(2) a').containsText('roles', 'Roles breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(3)') + .containsText('test-role', 'Role breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(4)') + .containsText('credentials', 'Credentials breadcrumb renders'); + }); + + test('it should render fields for static role', async function (assert) { + const fields = [ + { + label: 'Last Vault rotation', + value: () => dateFormat([this.creds.last_vault_rotation, 'MMM d yyyy, h:mm:ss aaa'], {}), + }, + { label: 'Password', key: 'password', isMasked: true }, + { label: 'Username', key: 'username' }, + { label: 'Rotation period', value: () => duration([this.creds.rotation_period]) }, + { label: 'Time remaining', value: () => duration([this.creds.ttl]) }, + ]; + this.creds = this.server.create('ldap-credential', 'static'); + + await render( + hbs``, + { owner: this.engine } + ); + + for (const field of fields) { + assert + .dom(`[data-test-row-label="${field.label}"]`) + .hasText(field.label, `${field.label} label renders`); + + if (field.isMasked) { + await click(`[data-test-value-div="${field.label}"] [data-test-button="toggle-masked"]`); + } + + const value = field.value ? field.value() : this.creds[field.key]; + assert.dom(`[data-test-value-div="${field.label}"]`).hasText(value, `${field.label} value renders`); + } + + await click('[data-test-done]'); + assert.true( + this.transitionStub.calledOnceWith('vault.cluster.secrets.backend.ldap.roles.role.details'), + 'Transitions to correct route on done' + ); + }); + + test('it should render fields for dynamic role', async function (assert) { + const fields = [ + { label: 'Distinguished Name', value: () => this.creds.distinguished_names.join(', ') }, + { label: 'Username', key: 'username', isMasked: true }, + { label: 'Password', key: 'password', isMasked: true }, + { label: 'Lease ID', key: 'lease_id' }, + { label: 'Lease duration', value: () => duration([this.creds.lease_duration]) }, + { label: 'Lease renewable', value: () => (this.creds.renewable ? 'True' : 'False') }, + ]; + this.creds = this.server.create('ldap-credential', 'dynamic'); + + await render( + hbs``, + { owner: this.engine } + ); + + assert + .dom('[data-test-alert-description]') + .hasText( + 'You won’t be able to access these credentials later, so please copy them now.', + 'Alert renders for dynamic roles' + ); + + for (const field of fields) { + assert + .dom(`[data-test-row-label="${field.label}"]`) + .hasText(field.label, `${field.label} label renders`); + + if (field.isMasked) { + await click(`[data-test-value-div="${field.label}"] [data-test-button="toggle-masked"]`); + } + + const value = field.value ? field.value() : this.creds[field.key]; + assert.dom(`[data-test-value-div="${field.label}"]`).hasText(value, `${field.label} value renders`); + } + + await click('[data-test-done]'); + assert.true( + this.transitionStub.calledOnceWith('vault.cluster.secrets.backend.ldap.roles.role.details'), + 'Transitions to correct route on done' + ); + }); +}); diff --git a/ui/tests/integration/components/ldap/page/role/details-test.js b/ui/tests/integration/components/ldap/page/role/details-test.js new file mode 100644 index 0000000000..6a0ad36cf3 --- /dev/null +++ b/ui/tests/integration/components/ldap/page/role/details-test.js @@ -0,0 +1,121 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import { duration } from 'core/helpers/format-duration'; + +module('Integration | Component | ldap | Page::Role::Details', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', () => ({ + data: { + capabilities: ['root'], + }, + })); + this.renderComponent = (type) => { + const data = this.server.create('ldap-role', type); + const store = this.owner.lookup('service:store'); + store.pushPayload('ldap/role', { + modelName: 'ldap/role', + backend: 'ldap-test', + type, + ...data, + }); + this.model = store.peekRecord('ldap/role', data.name); + this.breadcrumbs = [ + { label: this.model.backend, route: 'overview' }, + { label: 'roles', route: 'roles' }, + { label: this.model.name }, + ]; + return render(hbs``, { + owner: this.engine, + }); + }; + }); + + test('it should render header with role name and breadcrumbs', async function (assert) { + await this.renderComponent('static'); + assert.dom('[data-test-header-title]').hasText(this.model.name, 'Role name renders in header'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(1)') + .containsText(this.model.backend, 'Overview breadcrumb renders'); + assert.dom('[data-test-breadcrumbs] li:nth-child(2) a').containsText('roles', 'Roles breadcrumb renders'); + assert + .dom('[data-test-breadcrumbs] li:nth-child(3)') + .containsText(this.model.name, 'Role breadcrumb renders'); + }); + + test('it should render toolbar actions', async function (assert) { + assert.expect(7); + + await this.renderComponent('static'); + + assert.dom('[data-test-delete] button').hasText('Delete role', 'Delete action renders'); + assert.dom('[data-test-get-credentials]').hasText('Get credentials', 'Get credentials action renders'); + assert.dom('[data-test-rotate-credentials]').exists('Rotate credentials action renders for static role'); + assert.dom('[data-test-edit]').hasText('Edit role', 'Edit action renders'); + + await this.renderComponent('dynamic'); + // defined after render so this.model is defined + this.server.delete(`/${this.model.backend}/role/${this.model.name}`, () => { + assert.ok(true, 'Request made to delete role'); + return; + }); + const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + + assert + .dom('[data-test-rotate-credentials]') + .doesNotExist('Rotate credentials action is hidden for dynamic role'); + + await click('[data-test-delete] button'); + await click('[data-test-confirm-button]'); + assert.ok( + transitionStub.calledWith('vault.cluster.secrets.backend.ldap.roles'), + 'Transitions to roles route on delete success' + ); + }); + + test('it should render details fields', async function (assert) { + assert.expect(26); + + const fields = [ + { label: 'Role name', key: 'name' }, + { label: 'Role type', key: 'type' }, + { label: 'Distinguished name', key: 'dn', type: 'static' }, + { label: 'Username', key: 'username', type: 'static' }, + { label: 'Rotation period', key: 'rotation_period', type: 'static' }, + { label: 'TTL', key: 'default_ttl', type: 'dynamic' }, + { label: 'Max TTL', key: 'max_ttl', type: 'dynamic' }, + { label: 'Username template', key: 'username_template', type: 'dynamic' }, + { label: 'Creation LDIF', key: 'creation_ldif', type: 'dynamic' }, + { label: 'Deletion LDIF', key: 'deletion_ldif', type: 'dynamic' }, + { label: 'Rollback LDIF', key: 'rollback_ldif', type: 'dynamic' }, + ]; + + for (const type of ['static', 'dynamic']) { + await this.renderComponent(type); + + const typeFields = fields.filter((field) => !field.type || field.type === type); + typeFields.forEach((field) => { + assert + .dom(`[data-test-row-label="${field.label}"]`) + .hasText(field.label, `${field.label} label renders`); + const modelValue = this.model[field.key]; + const isDuration = ['TTL', 'Max TTL', 'Rotation period'].includes(field.label); + const value = isDuration ? duration([modelValue]) : modelValue; + assert.dom(`[data-test-row-value="${field.label}"]`).hasText(value, `${field.label} value renders`); + }); + } + }); +}); diff --git a/ui/tests/integration/components/ldap/page/roles-test.js b/ui/tests/integration/components/ldap/page/roles-test.js new file mode 100644 index 0000000000..402458f66b --- /dev/null +++ b/ui/tests/integration/components/ldap/page/roles-test.js @@ -0,0 +1,127 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render, click, fillIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap'; + +module('Integration | Component | ldap | Page::Roles', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + + this.store = this.owner.lookup('service:store'); + this.backend = createSecretsEngine(this.store); + this.breadcrumbs = generateBreadcrumbs(this.backend.id); + + for (const type of ['static', 'dynamic']) { + this.store.pushPayload('ldap/role', { + modelName: 'ldap/role', + backend: 'ldap-test', + type, + ...this.server.create('ldap-role', type, { name: `${type}-test` }), + }); + } + this.backend = this.store.peekRecord('secret-engine', 'ldap-test'); + this.roles = this.store.peekAll('ldap/role'); + this.promptConfig = false; + + this.renderComponent = () => { + return render( + hbs``, + { owner: this.engine } + ); + }; + }); + + test('it should render tab page header and config cta', async function (assert) { + this.promptConfig = true; + + await this.renderComponent(); + + assert.dom('.title svg').hasClass('flight-icon-folder-users', 'LDAP icon renders in title'); + assert.dom('.title').hasText('ldap-test', 'Mount path renders in title'); + assert + .dom('[data-test-toolbar-action="config"]') + .hasText('Configure LDAP', 'Correct toolbar action renders'); + assert.dom('[data-test-config-cta]').exists('Config cta renders'); + }); + + test('it should render create roles cta', async function (assert) { + this.roles = null; + + await this.renderComponent(); + + assert.dom('[data-test-toolbar-action="role"]').hasText('Create role', 'Toolbar action has correct text'); + assert + .dom('[data-test-toolbar-action="role"] svg') + .hasClass('flight-icon-plus', 'Toolbar action has correct icon'); + assert + .dom('[data-test-filter-input]') + .doesNotExist('Roles filter input is hidden when roles have not been created'); + assert.dom('[data-test-empty-state-title]').hasText('No roles created yet', 'Title renders'); + assert + .dom('[data-test-empty-state-message]') + .hasText( + 'Roles in Vault will allow you to manage LDAP credentials. Create a role to get started.', + 'Message renders' + ); + assert.dom('[data-test-empty-state-actions] a').hasText('Create role', 'Action renders'); + }); + + test('it should render roles list', async function (assert) { + await this.renderComponent(); + + assert.dom('[data-test-list-item-content] svg').hasClass('flight-icon-user', 'List item icon renders'); + assert + .dom('[data-test-role="static-test"]') + .hasText(this.roles.firstObject.name, 'List item name renders'); + assert + .dom('[data-test-role-type-badge="static-test"]') + .hasText(this.roles.firstObject.type, 'List item type badge renders'); + + await click('[data-test-popup-menu-trigger]'); + assert.dom('[data-test-edit]').hasText('Edit', 'Edit link renders in menu'); + assert.dom('[data-test-get-creds]').hasText('Get credentials', 'Get credentials link renders in menu'); + assert + .dom('[data-test-rotate-creds]') + .hasText('Rotate credentials', 'Rotate credentials link renders in menu'); + assert.dom('[data-test-details]').hasText('Details', 'Details link renders in menu'); + assert.dom('[data-test-delete]').hasText('Delete', 'Details link renders in menu'); + + await click('[data-test-popup-menu-trigger]:last-of-type'); + assert.dom('[data-test-rotate-creds]').doesNotExist('Rotate credentials link is hidden for dynamic type'); + }); + + test('it should filter roles', async function (assert) { + await this.renderComponent(); + + await fillIn('[data-test-filter-input]', 'foo'); + assert + .dom('[data-test-empty-state-title]') + .hasText('There are no roles matching "foo"', 'Filter message renders'); + + await fillIn('[data-test-filter-input]', 'static'); + assert.dom('[data-test-list-item-content]').exists({ count: 1 }, 'List is filtered with correct results'); + + await fillIn('[data-test-filter-input]', ''); + assert + .dom('[data-test-list-item-content]') + .exists({ count: 2 }, 'All roles are displayed when filter is cleared'); + }); +}); diff --git a/ui/tests/integration/components/ldap/tab-page-header-test.js b/ui/tests/integration/components/ldap/tab-page-header-test.js new file mode 100644 index 0000000000..ead50b4d91 --- /dev/null +++ b/ui/tests/integration/components/ldap/tab-page-header-test.js @@ -0,0 +1,86 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | ldap | TabPageHeader', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'ldap'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.store.pushPayload('secret-engine', { + modelName: 'secret-engine', + data: { + accessor: 'ldap_64e858b1', + path: 'ldap-test/', + type: 'ldap', + }, + }); + this.model = this.store.peekRecord('secret-engine', 'ldap-test'); + this.mount = this.model.path.slice(0, -1); + this.breadcrumbs = [{ label: 'secrets', route: 'secrets', linkExternal: true }, { label: this.mount }]; + }); + + test('it should render breadcrumbs', async function (assert) { + await render(hbs``, { + owner: this.engine, + }); + assert.dom('[data-test-breadcrumbs] li:nth-child(1) a').hasText('secrets', 'Secrets breadcrumb renders'); + + assert + .dom('[data-test-breadcrumbs] li:nth-child(2)') + .containsText(this.mount, 'Mount path breadcrumb renders'); + }); + + test('it should render title', async function (assert) { + await render(hbs``, { + owner: this.engine, + }); + assert + .dom('[data-test-header-title] svg') + .hasClass('flight-icon-folder-users', 'Correct icon renders in title'); + assert.dom('[data-test-header-title]').hasText(this.mount, 'Mount path renders in title'); + }); + + test('it should render tabs', async function (assert) { + await render(hbs``, { + owner: this.engine, + }); + assert.dom('[data-test-tab="overview"]').hasText('Overview', 'Overview tab renders'); + assert.dom('[data-test-tab="roles"]').hasText('Roles', 'Roles tab renders'); + assert.dom('[data-test-tab="libraries"]').hasText('Libraries', 'Libraries tab renders'); + assert.dom('[data-test-tab="config"]').hasText('Configuration', 'Configuration tab renders'); + }); + + test('it should yield toolbar blocks', async function (assert) { + await render( + hbs` + + <:toolbarFilters> + Toolbar filters + + <:toolbarActions> + Toolbar actions + + + `, + { owner: this.engine } + ); + + assert + .dom('.toolbar-filters [data-test-filters]') + .hasText('Toolbar filters', 'Block is yielded for toolbar filters'); + assert + .dom('.toolbar-actions [data-test-actions]') + .hasText('Toolbar actions', 'Block is yielded for toolbar actions'); + }); +}); diff --git a/ui/tests/integration/components/secrets-engine-mount-config-test.js b/ui/tests/integration/components/secrets-engine-mount-config-test.js new file mode 100644 index 0000000000..b8a9309471 --- /dev/null +++ b/ui/tests/integration/components/secrets-engine-mount-config-test.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +const selectors = { + toggle: '[data-test-mount-config-toggle]', + field: '[data-test-mount-config-field]', + rowValue: (label) => `[data-test-value-div="${label}"]`, +}; + +module('Integration | Component | secrets-engine-mount-config', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + const store = this.owner.lookup('service:store'); + store.pushPayload('secret-engine', { + modelName: 'secret-engine', + data: { + path: 'ldap-test/', + type: 'ldap', + accessor: 'ldap_7e838627', + local: false, + seal_wrap: true, + config: { + id: 'foo', + default_lease_ttl: 0, + max_lease_ttl: 10000, + }, + }, + }); + this.model = store.peekRecord('secret-engine', 'ldap-test'); + }); + + test('it should toggle config fields visibility', async function (assert) { + await render(hbs``); + + assert + .dom(selectors.toggle) + .hasText('Show mount configuration', 'Correct toggle copy renders when closed'); + assert.dom(selectors.field).doesNotExist('Mount config fields are hidden'); + + await click(selectors.toggle); + + assert.dom(selectors.toggle).hasText('Hide mount configuration', 'Correct toggle copy renders when open'); + assert.dom(selectors.field).exists('Mount config fields are visible'); + }); + + test('it should render correct config fields', async function (assert) { + await render(hbs``); + await click(selectors.toggle); + + assert + .dom(selectors.rowValue('Secret Engine Type')) + .hasText(this.model.engineType, 'Secret engine type renders'); + assert.dom(selectors.rowValue('Path')).hasText(this.model.path, 'Path renders'); + assert.dom(selectors.rowValue('Accessor')).hasText(this.model.accessor, 'Accessor renders'); + assert.dom(selectors.rowValue('Local')).includesText('No', 'Local renders'); + assert.dom(selectors.rowValue('Seal Wrap')).includesText('Yes', 'Seal wrap renders'); + assert.dom(selectors.rowValue('Default Lease TTL')).includesText('0', 'Default Lease TTL renders'); + assert.dom(selectors.rowValue('Max Lease TTL')).includesText('10000', 'Max Lease TTL renders'); + }); + + test('it should yield block for additional fields', async function (assert) { + await render(hbs` + + It Yields! + + `); + + await click(selectors.toggle); + assert.dom('[data-test-yield]').hasText('It Yields!', 'Component yields block for additional fields'); + }); +}); diff --git a/ui/tests/pages/secrets/backends.js b/ui/tests/pages/secrets/backends.js index 135e638d18..c19d8005bf 100644 --- a/ui/tests/pages/secrets/backends.js +++ b/ui/tests/pages/secrets/backends.js @@ -9,7 +9,7 @@ import uiPanel from 'vault/tests/pages/components/console/ui-panel'; export default create({ consoleToggle: clickable('[data-test-console-toggle]'), visit: visitable('/vault/secrets'), - rows: collection('[data-test-auth-backend-link]', { + rows: collection('[data-test-secrets-backend-link]', { path: text('[data-test-secret-path]'), menu: clickable('[data-test-popup-menu-trigger]'), }), diff --git a/ui/tests/unit/adapters/kubernetes/config-test.js b/ui/tests/unit/adapters/kubernetes/config-test.js index edc73f7667..7ccf6dd88b 100644 --- a/ui/tests/unit/adapters/kubernetes/config-test.js +++ b/ui/tests/unit/adapters/kubernetes/config-test.js @@ -58,4 +58,14 @@ module('Unit | Adapter | kubernetes/config', function (hooks) { const record = this.store.peekRecord('kubernetes/config', 'kubernetes-test'); await record.destroyRecord(); }); + + test('it should check the config vars endpoint', async function (assert) { + assert.expect(1); + + this.server.get('/kubernetes-test/check', () => { + assert.ok('GET request made to config vars check endpoint'); + }); + + await this.store.adapterFor('kubernetes/config').checkConfigVars('kubernetes-test'); + }); }); diff --git a/ui/tests/unit/adapters/ldap/config-test.js b/ui/tests/unit/adapters/ldap/config-test.js new file mode 100644 index 0000000000..cb320c0d1c --- /dev/null +++ b/ui/tests/unit/adapters/ldap/config-test.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Unit | Adapter | ldap/config', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.store.unloadAll('ldap/config'); + }); + + test('it should make request to correct endpoint when querying record', async function (assert) { + assert.expect(1); + this.server.get('/ldap-test/config', () => { + assert.ok('GET request made to correct endpoint when querying record'); + }); + await this.store.queryRecord('ldap/config', { backend: 'ldap-test' }); + }); + + test('it should make request to correct endpoint when creating new record', async function (assert) { + assert.expect(1); + this.server.post('/ldap-test/config', () => { + assert.ok('POST request made to correct endpoint when creating new record'); + }); + const record = this.store.createRecord('ldap/config', { backend: 'ldap-test' }); + await record.save(); + }); + + test('it should make request to correct endpoint when updating record', async function (assert) { + assert.expect(1); + this.server.post('/ldap-test/config', () => { + assert.ok('POST request made to correct endpoint when updating record'); + }); + this.store.pushPayload('ldap/config', { + modelName: 'ldap/config', + backend: 'ldap-test', + }); + const record = this.store.peekRecord('ldap/config', 'ldap-test'); + await record.save(); + }); + + test('it should make request to correct endpoint when deleting record', async function (assert) { + assert.expect(1); + this.server.delete('/ldap-test/config', () => { + assert.ok('DELETE request made to correct endpoint when deleting record'); + }); + this.store.pushPayload('ldap/config', { + modelName: 'ldap/config', + backend: 'ldap-test', + }); + const record = this.store.peekRecord('ldap/config', 'ldap-test'); + await record.destroyRecord(); + }); +}); diff --git a/ui/tests/unit/adapters/ldap/library-test.js b/ui/tests/unit/adapters/ldap/library-test.js new file mode 100644 index 0000000000..bebff25108 --- /dev/null +++ b/ui/tests/unit/adapters/ldap/library-test.js @@ -0,0 +1,125 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Unit | Adapter | ldap/library', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.adapter = this.store.adapterFor('ldap/library'); + }); + + test('it should make request to correct endpoint when listing records', async function (assert) { + assert.expect(1); + + this.server.get('/ldap-test/library', (schema, req) => { + assert.ok(req.queryParams.list, 'GET request made to correct endpoint when listing records'); + return { data: { keys: ['test-library'] } }; + }); + + await this.store.query('ldap/library', { backend: 'ldap-test' }); + }); + + test('it should make request to correct endpoint when querying record', async function (assert) { + assert.expect(1); + + this.server.get('/ldap-test/library/test-library', () => { + assert.ok('GET request made to correct endpoint when querying record'); + }); + + await this.store.queryRecord('ldap/library', { backend: 'ldap-test', name: 'test-library' }); + }); + + test('it should make request to correct endpoint when creating new record', async function (assert) { + assert.expect(1); + + this.server.post('/ldap-test/library/test-library', () => { + assert.ok('POST request made to correct endpoint when creating new record'); + }); + + await this.store.createRecord('ldap/library', { backend: 'ldap-test', name: 'test-library' }).save(); + }); + + test('it should make request to correct endpoint when updating record', async function (assert) { + assert.expect(1); + + this.server.post('/ldap-test/library/test-library', () => { + assert.ok('POST request made to correct endpoint when updating record'); + }); + + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + name: 'test-library', + }); + + await this.store.peekRecord('ldap/library', 'test-library').save(); + }); + + test('it should make request to correct endpoint when deleting record', async function (assert) { + assert.expect(1); + + this.server.delete('/ldap-test/library/test-library', () => { + assert.ok('DELETE request made to correct endpoint when deleting record'); + }); + + this.store.pushPayload('ldap/library', { + modelName: 'ldap/library', + backend: 'ldap-test', + name: 'test-library', + }); + + await this.store.peekRecord('ldap/library', 'test-library').destroyRecord(); + }); + + test('it should make request to correct endpoint when fetching check-out status', async function (assert) { + assert.expect(1); + + this.server.get('/ldap-test/library/test-library/status', () => { + assert.ok('GET request made to correct endpoint when fetching check-out status'); + }); + + await this.adapter.fetchStatus('ldap-test', 'test-library'); + }); + + test('it should make request to correct endpoint when checking out library', async function (assert) { + assert.expect(1); + + this.server.post('/ldap-test/library/test-library/check-out', (schema, req) => { + const json = JSON.parse(req.requestBody); + assert.strictEqual(json.ttl, '1h', 'POST request made to correct endpoint when checking out library'); + return { + data: { password: 'test', service_account_name: 'foo@bar.com' }, + }; + }); + + await this.adapter.checkOutAccount('ldap-test', 'test-library', '1h'); + }); + + test('it should make request to correct endpoint when checking in service accounts', async function (assert) { + assert.expect(1); + + this.server.post('/ldap-test/library/test-library/check-in', (schema, req) => { + const json = JSON.parse(req.requestBody); + assert.deepEqual( + json.service_account_names, + ['foo@bar.com'], + 'POST request made to correct endpoint when checking in service accounts' + ); + return { + data: { + 'check-ins': ['foo@bar.com'], + }, + }; + }); + + await this.adapter.checkInAccount('ldap-test', 'test-library', ['foo@bar.com']); + }); +}); diff --git a/ui/tests/unit/adapters/ldap/role-test.js b/ui/tests/unit/adapters/ldap/role-test.js new file mode 100644 index 0000000000..4c073094e2 --- /dev/null +++ b/ui/tests/unit/adapters/ldap/role-test.js @@ -0,0 +1,227 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { Response } from 'miragejs'; +import sinon from 'sinon'; + +module('Unit | Adapter | ldap/role', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.adapter = this.store.adapterFor('ldap/role'); + this.path = 'role'; + }); + + test('it should make request to correct endpoints when listing records', async function (assert) { + assert.expect(6); + + const assertRequest = (schema, req) => { + assert.ok(req.queryParams.list, 'list query param sent when listing roles'); + const name = req.params.path === 'static-role' ? 'static-test' : 'dynamic-test'; + return { data: { keys: [name] } }; + }; + + this.server.get('/ldap-test/static-role', assertRequest); + this.server.get('/ldap-test/role', assertRequest); + + this.models = await this.store.query('ldap/role', { backend: 'ldap-test' }); + + const model = this.models.firstObject; + assert.strictEqual(this.models.length, 2, 'Returns responses from both endpoints'); + assert.strictEqual(model.backend, 'ldap-test', 'Backend value is set on records returned from query'); + // sorted alphabetically by name so dynamic should be first + assert.strictEqual(model.type, 'dynamic', 'Type value is set on records returned from query'); + assert.strictEqual(model.name, 'dynamic-test', 'Name value is set on records returned from query'); + }); + + test('it should conditionally trigger info level flash message for single endpoint error from query', async function (assert) { + const flashMessages = this.owner.lookup('service:flashMessages'); + const flashSpy = sinon.spy(flashMessages, 'info'); + + this.server.get('/ldap-test/static-role', () => { + return new Response(403, {}, { errors: ['permission denied'] }); + }); + this.server.get('/ldap-test/role', () => ({ data: { keys: ['dynamic-test'] } })); + + await this.store.query('ldap/role', { backend: 'ldap-test' }); + await this.store.query( + 'ldap/role', + { backend: 'ldap-test' }, + { adapterOptions: { showPartialError: true } } + ); + + assert.true( + flashSpy.calledOnceWith('Error fetching roles from /v1/ldap-test/static-role: permission denied'), + 'Partial error info only displays when adapter option is passed' + ); + }); + + test('it should throw error for query when requests to both endpoints fail', async function (assert) { + assert.expect(1); + + this.server.get('/ldap-test/:path', (schema, req) => { + const errors = { + 'static-role': ['permission denied'], + role: ['server error'], + }[req.params.path]; + return new Response(req.params.path === 'static-role' ? 403 : 500, {}, { errors }); + }); + + try { + await this.store.query('ldap/role', { backend: 'ldap-test' }); + } catch (error) { + assert.deepEqual( + error, + { + message: 'Error fetching roles:', + errors: ['/v1/ldap-test/static-role: permission denied', '/v1/ldap-test/role: server error'], + }, + 'Error is thrown with correct payload from query' + ); + } + }); + + test('it should make request to correct endpoints when querying record', async function (assert) { + assert.expect(5); + + this.server.get('/ldap-test/:path/test-role', (schema, req) => { + assert.strictEqual( + req.params.path, + this.path, + 'GET request made to correct endpoint when querying record' + ); + }); + + for (const type of ['dynamic', 'static']) { + this.model = await this.store.queryRecord('ldap/role', { + backend: 'ldap-test', + type, + name: 'test-role', + }); + this.path = 'static-role'; + } + + assert.strictEqual( + this.model.backend, + 'ldap-test', + 'Backend value is set on records returned from query' + ); + assert.strictEqual(this.model.type, 'static', 'Type value is set on records returned from query'); + assert.strictEqual(this.model.name, 'test-role', 'Name value is set on records returned from query'); + }); + + test('it should make request to correct endpoints when creating new record', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-test/:path/test-role', (schema, req) => { + assert.strictEqual( + req.params.path, + this.path, + 'POST request made to correct endpoint when creating new record' + ); + }); + + const getModel = (type) => { + return this.store.createRecord('ldap/role', { + backend: 'ldap-test', + name: 'test-role', + type, + }); + }; + + for (const type of ['dynamic', 'static']) { + const model = getModel(type); + await model.save(); + this.path = 'static-role'; + } + }); + + test('it should make request to correct endpoints when updating record', async function (assert) { + assert.expect(2); + + this.server.post('/ldap-test/:path/test-role', (schema, req) => { + assert.strictEqual( + req.params.path, + this.path, + 'POST request made to correct endpoint when updating record' + ); + }); + + this.store.pushPayload('ldap/role', { + modelName: 'ldap/role', + backend: 'ldap-test', + name: 'test-role', + }); + const record = this.store.peekRecord('ldap/role', 'test-role'); + + for (const type of ['dynamic', 'static']) { + record.type = type; + await record.save(); + this.path = 'static-role'; + } + }); + + test('it should make request to correct endpoints when deleting record', async function (assert) { + assert.expect(2); + + this.server.delete('/ldap-test/:path/test-role', (schema, req) => { + assert.strictEqual( + req.params.path, + this.path, + 'DELETE request made to correct endpoint when deleting record' + ); + }); + + const getModel = () => { + this.store.pushPayload('ldap/role', { + modelName: 'ldap/role', + backend: 'ldap-test', + name: 'test-role', + }); + return this.store.peekRecord('ldap/role', 'test-role'); + }; + + for (const type of ['dynamic', 'static']) { + const record = getModel(); + record.type = type; + await record.destroyRecord(); + this.path = 'static-role'; + } + }); + + test('it should make request to correct endpoints when fetching credentials', async function (assert) { + assert.expect(2); + + this.path = 'creds'; + + this.server.get('/ldap-test/:path/test-role', (schema, req) => { + assert.strictEqual( + req.params.path, + this.path, + 'GET request made to correct endpoint when fetching credentials' + ); + }); + + for (const type of ['dynamic', 'static']) { + await this.adapter.fetchCredentials('ldap-test', type, 'test-role'); + this.path = 'static-cred'; + } + }); + + test('it should make request to correct endpoint when rotating static role password', async function (assert) { + assert.expect(1); + + this.server.post('/ldap-test/rotate-role/test-role', () => { + assert.ok('GET request made to correct endpoint when rotating static role password'); + }); + + await this.adapter.rotateStaticPassword('ldap-test', 'test-role'); + }); +}); diff --git a/ui/tests/unit/decorators/fetch-secrets-engine-config-test.js b/ui/tests/unit/decorators/fetch-secrets-engine-config-test.js new file mode 100644 index 0000000000..b9a3cc9aeb --- /dev/null +++ b/ui/tests/unit/decorators/fetch-secrets-engine-config-test.js @@ -0,0 +1,114 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import sinon from 'sinon'; +import Route from '@ember/routing/route'; +import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { inject as service } from '@ember/service'; +import { Response } from 'miragejs'; + +module('Unit | Decorators | fetch-secrets-engine-config', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.spy = sinon.spy(console, 'error'); + this.store = this.owner.lookup('service:store'); + this.backend = 'test-path'; + this.owner.lookup('service:secretMountPath').update(this.backend); + + this.createClass = () => { + @withConfig('ldap/config') + class Foo extends Route { + @service store; + @service secretMountPath; + } + // service injection will fail if class is not instantiated with an owner + return new Foo(this.owner); + }; + }); + hooks.afterEach(function () { + this.spy.restore(); + }); + + test('it should warn when applying decorator to class that does not extend Route', function (assert) { + @withConfig() + class Foo {} // eslint-disable-line + const message = + 'withConfig decorator must be used on an instance of Ember Route class. Decorator not applied to returned class'; + assert.ok(this.spy.calledWith(message), 'Error is printed to console'); + }); + + test('it should return cached record from store if it exists', async function (assert) { + this.store.pushPayload('ldap/config', { + modelName: 'ldap/config', + backend: this.backend, + }); + const peekSpy = sinon.spy(this.store, 'peekRecord'); + const route = this.createClass(); + + await route.beforeModel(); + assert.true(peekSpy.calledWith('ldap/config', this.backend), 'peekRecord called for config model'); + assert.strictEqual(route.configModel.backend, this.backend, 'config model set on class'); + assert.strictEqual(route.configError, null, 'error is unset when model is found'); + assert.false(route.promptConfig, 'promptConfig is false when model is found'); + }); + + test('it should fetch record when not in the store', async function (assert) { + assert.expect(4); + + this.server.get('/test-path/config', () => { + assert.ok(true, 'fetch request is made'); + return {}; + }); + + const route = this.createClass(); + await route.beforeModel(); + + assert.strictEqual(route.configModel.backend, this.backend, 'config model set on class'); + assert.strictEqual(route.configError, null, 'error is unset when model is found'); + assert.false(route.promptConfig, 'promptConfig is false when model is found'); + }); + + test('it should set prompt value when fetch returns a 404', async function (assert) { + assert.expect(4); + + this.server.get('/test-path/config', () => { + assert.ok(true, 'fetch request is made'); + return new Response(404, {}, { errors: [] }); + }); + + const route = this.createClass(); + await route.beforeModel(); + + assert.strictEqual(route.configModel, null, 'config is not set when error is returned'); + assert.strictEqual(route.configError, null, 'error is unset when 404 is returned'); + assert.true(route.promptConfig, 'promptConfig is true when 404 is returned'); + }); + + test('it should set error value when fetch returns error other than 404', async function (assert) { + assert.expect(4); + + const error = { errors: ['Permission denied'] }; + this.server.get('/test-path/config', () => { + assert.ok(true, 'fetch request is made'); + return new Response(403, {}, error); + }); + + const route = this.createClass(); + await route.beforeModel(); + + assert.strictEqual(route.configModel, null, 'config is not set when error is returned'); + assert.deepEqual( + route.configError.errors, + error.errors, + 'error is set when error other than 404 is returned' + ); + assert.false(route.promptConfig, 'promptConfig is false when error other than 404 is returned'); + }); +}); diff --git a/ui/tests/unit/machines/secrets-machine-test.js b/ui/tests/unit/machines/secrets-machine-test.js index 5bf2358a7a..a41bcad6ff 100644 --- a/ui/tests/unit/machines/secrets-machine-test.js +++ b/ui/tests/unit/machines/secrets-machine-test.js @@ -386,75 +386,6 @@ module('Unit | Machine | secrets-machine', function () { ], }, }, - { - currentState: 'enable', - event: 'CONTINUE', - params: 'ad', - expectedResults: { - value: 'list', - actions: [ - { type: 'render', level: 'step', component: 'wizard/secrets-list' }, - { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, - ], - }, - }, - { - currentState: 'list', - event: 'CONTINUE', - params: 'ad', - expectedResults: { - value: 'display', - actions: [ - { component: 'wizard/secrets-display', level: 'step', type: 'render' }, - { component: 'wizard/mounts-wizard', level: 'feature', type: 'render' }, - ], - }, - }, - { - currentState: 'display', - event: 'RESET', - params: 'ad', - expectedResults: { - value: 'idle', - actions: [ - { - params: ['vault.cluster.settings.mount-secret-backend'], - type: 'routeTransition', - }, - { - component: 'wizard/mounts-wizard', - level: 'feature', - type: 'render', - }, - { - component: 'wizard/secrets-idle', - level: 'step', - type: 'render', - }, - ], - }, - }, - { - currentState: 'display', - event: 'DONE', - params: 'ad', - expectedResults: { - value: 'complete', - actions: ['completeFeature'], - }, - }, - { - currentState: 'display', - event: 'ERROR', - params: 'ad', - expectedResults: { - value: 'error', - actions: [ - { component: 'wizard/tutorial-error', level: 'step', type: 'render' }, - { component: 'wizard/mounts-wizard', level: 'feature', type: 'render' }, - ], - }, - }, { currentState: 'enable', event: 'CONTINUE', diff --git a/ui/tests/unit/serializers/ldap/library-test.js b/ui/tests/unit/serializers/ldap/library-test.js new file mode 100644 index 0000000000..66dcdbdfb6 --- /dev/null +++ b/ui/tests/unit/serializers/ldap/library-test.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'vault/tests/helpers'; + +module('Unit | Serializer | ldap/library', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + }); + + test('it should normalize and serialize disable_check_in_enforcement value', async function (assert) { + assert.expect(4); + + const model = this.store.createRecord('ldap/library', { + backend: 'ldap-test', + name: 'test-library', + }); + const cases = [ + { value: false, transformed: 'Enabled' }, + { value: true, transformed: 'Disabled' }, + ]; + + cases.forEach(({ value, transformed }) => { + const normalized = this.store.normalize('ldap/library', { disable_check_in_enforcement: value }); + assert.strictEqual( + normalized.data.attributes.disable_check_in_enforcement, + transformed, + `Normalizes ${value} value to ${transformed}` + ); + model.disable_check_in_enforcement = transformed; + const { disable_check_in_enforcement } = model.serialize(); + assert.strictEqual(disable_check_in_enforcement, value, `Serializes ${transformed} value to ${value}`); + }); + }); +}); diff --git a/ui/tests/unit/serializers/ldap/role-test.js b/ui/tests/unit/serializers/ldap/role-test.js new file mode 100644 index 0000000000..7fad0d3272 --- /dev/null +++ b/ui/tests/unit/serializers/ldap/role-test.js @@ -0,0 +1,64 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'vault/tests/helpers'; + +module('Unit | Serializer | ldap/role', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + const store = this.owner.lookup('service:store'); + this.model = store.createRecord('ldap/role', { + backend: 'ldap', + name: 'test-role', + dn: 'cn=hashicorp,ou=Users,dc=hashicorp,dc=com', + rotation_period: '24h', + username: 'hashicorp', + creation_ldif: 'foo', + deletion_ldif: 'bar', + rollback_ldif: 'baz', + username_template: 'default', + default_ttl: '1h', + max_ttl: '24h', + }); + }); + + test('it should serialize attributes based on type', async function (assert) { + assert.expect(11); + + const serializeAndAssert = (type) => { + this.model.type = type; + const payload = this.model.serialize(); + // intentionally not using fieldsForType from model to detect any drift + const fieldsForType = { + static: ['username', 'dn', 'rotation_period'], + dynamic: [ + 'default_ttl', + 'max_ttl', + 'username_template', + 'creation_ldif', + 'deletion_ldif', + 'rollback_ldif', + ], + }[type]; + + assert.strictEqual( + Object.keys(payload).length, + fieldsForType.length, + `Correct number of keys exist in serialized payload for ${type} role type` + ); + Object.keys(payload).forEach((key) => { + assert.true( + fieldsForType.includes(key), + `${key} property exists in serialized payload for ${type} role type` + ); + }); + }; + + serializeAndAssert('static'); + serializeAndAssert('dynamic'); + }); +}); diff --git a/ui/tsconfig.json b/ui/tsconfig.json index f86f81d510..1bd966c208 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -43,6 +43,8 @@ "kmip/*": ["lib/kmip/addon/*"], "kmip/test-support": ["lib/kmip/addon-test-support"], "kmip/test-support/*": ["lib/kmip/addon-test-support/*"], + "ldap": ["lib/ldap/addon"], + "ldap/*": ["lib/ldap/addon/*"], "kv": ["lib/kv/addon"], "kv/*": ["lib/kv/addon/*"], "kv/test-support": ["lib/kv/addon-test-support"], @@ -77,6 +79,7 @@ "lib/core/**/*", "lib/css/**/*", "lib/kmip/**/*", + "lib/ldap/**/*", "lib/open-api-explorer/**/*", "lib/pki/**/*", "lib/replication/**/*", diff --git a/ui/types/ember-data/types/registries/adapter.d.ts b/ui/types/ember-data/types/registries/adapter.d.ts index 6c0a082f39..ce8f738dfa 100644 --- a/ui/types/ember-data/types/registries/adapter.d.ts +++ b/ui/types/ember-data/types/registries/adapter.d.ts @@ -8,6 +8,8 @@ import Adapter from 'ember-data/adapter'; import ModelRegistry from 'ember-data/types/registries/model'; import PkiIssuerAdapter from 'vault/adapters/pki/issuer'; import PkiTidyAdapter from 'vault/adapters/pki/tidy'; +import LdapRoleAdapter from 'vault/adapters/ldap/role'; +import LdapLibraryAdapter from 'vault/adapters/ldap/library'; import KvDataAdapter from 'vault/adapters/kv/data'; import KvMetadataAdapter from 'vault/adapters/kv/metadata'; @@ -15,6 +17,8 @@ import KvMetadataAdapter from 'vault/adapters/kv/metadata'; * Catch-all for ember-data. */ export default interface AdapterRegistry { + 'ldap/library': LdapLibraryAdapter; + 'ldap/role': LdapRoleAdapter; 'pki/issuer': PkiIssuerAdapter; 'pki/tidy': PkiTidyAdapter; 'kv/data': KvDataAdapterAdapter; @@ -22,3 +26,7 @@ export default interface AdapterRegistry { application: Application; [key: keyof ModelRegistry]: Adapter; } + +export default interface AdapterError extends Error { + httpStatus: number; +} diff --git a/ui/types/vault/adapters/ldap/library.d.ts b/ui/types/vault/adapters/ldap/library.d.ts new file mode 100644 index 0000000000..0eb5c9ab74 --- /dev/null +++ b/ui/types/vault/adapters/ldap/library.d.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Store from '@ember-data/store'; +import { AdapterRegistry } from 'ember-data/adapter'; + +export interface LdapLibraryAccountStatus { + account: string; + available: boolean; + library: string; + borrower_client_token?: string; + borrower_entity_id?: string; +} + +export interface LdapLibraryCheckOutCredentials { + account: string; + password: string; + lease_id: string; + lease_duration: number; + renewable: boolean; +} + +export default interface LdapLibraryAdapter extends AdapterRegistry { + fetchCheckOutStatus(backend: string, name: string): Promise>; + checkOutAccount(backend: string, name: string, ttl?: string): Promise; + checkInAccount(backend: string, name: string, service_account_names: Array): Promise; +} diff --git a/ui/types/vault/adapters/ldap/role.d.ts b/ui/types/vault/adapters/ldap/role.d.ts new file mode 100644 index 0000000000..81c01bf3c6 --- /dev/null +++ b/ui/types/vault/adapters/ldap/role.d.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Store from '@ember-data/store'; +import { AdapterRegistry } from 'ember-data/adapter'; + +export default interface LdapRoleAdapter extends AdapterRegistry { + fetchCredentials(backend: string, type: string, name: string); + rotateStaticPassword(backend: string, name: string); +} diff --git a/ui/types/vault/app-types.ts b/ui/types/vault/app-types.ts index 5dc9970698..28d1476f97 100644 --- a/ui/types/vault/app-types.ts +++ b/ui/types/vault/app-types.ts @@ -2,6 +2,8 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ +import type EmberDataModel from '@ember-data/model'; +import type Owner from '@ember/owner'; // Type that comes back from expandAttributeMeta export interface FormField { @@ -41,6 +43,23 @@ export interface ModelValidations { invalidFormMessage: string; } +export interface Model extends Omit { + // override isNew which is a computed prop and ts will complain since it sees it as a function + isNew: boolean; +} + +export interface WithFormFieldsModel extends Model { + formFields: Array; + formFieldGroups: FormFieldGroups; + allFields: Array; +} + +export interface WithValidationsModel extends Model { + validate(): ModelValidations; +} + +export interface WithFormFieldsAndValidationsModel extends WithFormFieldsModel, WithValidationsModel {} + export interface Breadcrumb { label: string; route?: string; @@ -54,6 +73,16 @@ export interface TtlEvent { goSafeTimeString: string; } +export interface Breadcrumb { + label: string; + route?: string; + linkExternal?: boolean; +} + +export interface EngineOwner extends Owner { + mountPoint: string; +} + // Generic interfaces export interface StringMap { [key: string]: string; diff --git a/ui/types/vault/models/ldap/config.d.ts b/ui/types/vault/models/ldap/config.d.ts new file mode 100644 index 0000000000..15ec81d212 --- /dev/null +++ b/ui/types/vault/models/ldap/config.d.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ +import type { WithFormFieldsAndValidationsModel } from 'vault/app-types'; + +export default interface LdapConfigModel extends WithFormFieldsAndValidationsModel { + backend: string; + binddn: string; + bindpass: string; + url: string; + schema: string; + password_policy: string; + starttls: boolean; + insecure_tls: boolean; + certificate: string; + client_tls_cert: string; + client_tls_key: string; + userdn: string; + userattr: string; + upndomain: string; + connection_timeout: number; + request_timeout: number; + rotateRoot(): Promise; +} diff --git a/ui/types/vault/models/ldap/library.d.ts b/ui/types/vault/models/ldap/library.d.ts new file mode 100644 index 0000000000..cde7808a4b --- /dev/null +++ b/ui/types/vault/models/ldap/library.d.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ +import type { WithFormFieldsAndValidationsModel } from 'vault/app-types'; +import type { FormField } from 'vault/app-types'; +import CapabilitiesModel from '../capabilities'; +import type { + LdapLibraryAccountStatus, + LdapLibraryCheckOutCredentials, +} from 'vault/vault/adapters/ldap/library'; + +export default interface LdapLibraryModel extends WithFormFieldsAndValidationsModel { + backend: string; + name: string; + service_account_names: string; + default_ttl: number; + max_ttl: number; + disable_check_in_enforcement: string; + get displayFields(): Array; + libraryPath: CapabilitiesModel; + statusPath: CapabilitiesModel; + checkOutPath: CapabilitiesModel; + checkInPath: CapabilitiesModel; + get canCreate(): boolean; + get canDelete(): boolean; + get canEdit(): boolean; + get canRead(): boolean; + get canList(): boolean; + get canReadStatus(): boolean; + get canCheckOut(): boolean; + get canCheckIn(): boolean; + fetchStatus(): Promise>; + checkOutAccount(ttl?: string): Promise; + checkInAccount(account: string): Promise; +} diff --git a/ui/types/vault/models/ldap/role.d.ts b/ui/types/vault/models/ldap/role.d.ts new file mode 100644 index 0000000000..eac9b4d3a4 --- /dev/null +++ b/ui/types/vault/models/ldap/role.d.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ +import type { WithFormFieldsAndValidationsModel } from 'vault/app-types'; +import type { FormField } from 'vault/app-types'; +import CapabilitiesModel from '../capabilities'; +import { LdapDynamicRoleCredentials, LdapStaticRoleCredentials } from 'ldap/routes/roles/role/credentials'; +export default interface LdapRoleModel extends WithFormFieldsAndValidationsModel { + type: string; + backend: string; + name: string; + dn: string; + username: string; + rotation_period: string; + default_ttl: string; + max_ttl: string; + username_template: string; + creation_ldif: string; + rollback_ldif: string; + get isStatic(): string; + get isDynamic(): string; + get fieldsForType(): Array; + get displayFields(): Array; + get roleUri(): string; + get credsUri(): string; + rolePath: CapabilitiesModel; + credsPath: CapabilitiesModel; + staticRotateCredsPath: CapabilitiesModel; + get canCreate(): boolean; + get canDelete(): boolean; + get canEdit(): boolean; + get canRead(): boolean; + get canList(): boolean; + get canReadCreds(): boolean; + get canRotateStaticCreds(): boolean; + fetchCredentials(): Promise; + rotateStaticPassword(): Promise; +} diff --git a/ui/types/vault/models/mount-config.d.ts b/ui/types/vault/models/mount-config.d.ts new file mode 100644 index 0000000000..99ae9976d9 --- /dev/null +++ b/ui/types/vault/models/mount-config.d.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Model from '@ember-data/model'; + +export default class MountConfigModel extends Model { + defaultLeaseTtl: string; + maxLeaseTtl: string; + auditNonHmacRequestKeys: string; + auditNonHmacResponseKeys: string; + listingVisibility: string; + passthroughRequestHeaders: string; + allowedResponseHeaders: string; + tokenType: string; + allowedManagedKeys: string; +} diff --git a/ui/types/vault/models/secret-engine.d.ts b/ui/types/vault/models/secret-engine.d.ts new file mode 100644 index 0000000000..554c8b78ea --- /dev/null +++ b/ui/types/vault/models/secret-engine.d.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Model from '@ember-data/model'; + +import type { ModelValidations, FormField, FormFieldGroups } from 'vault/app-types'; +import type MountConfigModel from 'vault/models/mount-config'; + +export default class SecretEngineModel extends Model { + path: string; + type: string; + description: string; + config: MountConfigModel; + local: boolean; + sealWrap: boolean; + externalEntropyAccess: boolean; + version: number; + privateKey: string; + publicKey: string; + generateSigningKey: boolean; + lease: string; + leaseMax: string; + accessor: string; + maxVersions: number; + casRequired: boolean; + deleteVersionAfter: string; + get modelTypeForKV(): string; + get isV2KV(): boolean; + get attrs(): Array; + get fieldGroups(): FormFieldGroups; + get icon(): string; + get engineType(): string; + get shouldIncludeInList(): boolean; + get isSupportedBackend(): boolean; + get backendLink(): string; + get accessor(): string; + get localDisplay(): string; + get formFields(): Array; + get formFieldGroups(): FormFieldGroups; + saveCA(options: object): Promise; + saveZeroAddressConfig(): Promise; + validate(): ModelValidations; + // need to override isNew which is a computed prop and ts will complain since it sees it as a function + isNew: boolean; +} diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts new file mode 100644 index 0000000000..6833a58596 --- /dev/null +++ b/ui/types/vault/services/auth.d.ts @@ -0,0 +1,12 @@ +// temporary interface for auth service until it can be updated to ts +// add properties as needed + +import Service from '@ember/service'; + +export interface AuthData { + entity_id: string; +} + +export default class AuthService extends Service { + authData: AuthData; +}