LDAP/AD Secrets Engine (#20790)

* adds ldap ember engine (#20786)

* adds ldap as mountable and supported secrets engine (#20793)

* removes active directory as mountable secrets engine (#20798)

* LDAP Config Ember Data Setup (#20863)

* adds secret-engine-path adapter

* adds model, adapater and serializer for ldap config

* adds test for ldap config adapter

* addresses PR feedback

* updates remaining instances of getURL in secrets-engine-path adapter

* adds underscore to getURL method in kubernetes/config adapter

* adds check config vars test for kubernetes/config adapter

* adds comment regarding primaryKey in secrets-engine-path adapter

* adds tab-page-header component for ldap secrets engine (#20941)

* LDAP Config Route (#21059)

* converts secret-mount-path service to ts and moves kubernetes fetch-config decorator to core addon and converts to ts

* adds ldap config route

* fixes withConfig import path in kubernetes roles route

* updates types in ldap config route

* adds unit tests for fetch-secret-config decorator

* updates comments in fetch-secret-config decorator

* renames fetch-secret-config decorator

* LDAP Configure Page Component (#21384)

* adds ldap page configure component

* removes pauseTest and updates radio card selector in ldap config test

* LDAP Configuration (#21430)

* adds ldap configuration route

* adds secrets-engine-mount-config component to core addon

* adds ldap config-cta component

* adds display fields to ldap configuration page and test

* fixes ldap config-cta test

* adds yield to secrets-engine-mount-config component

* fixes tests

* LDAP Overview Route and Page Component (#21579)

* adds ldap overview route and page component

* changes toolbar link action type for create role on overview page

* LDAP Role Model, Adapter and Serializer (#21655)

* adds model, adapter and serializer for ldap roles

* addresses review feedback

* changes ldap role type from tracked prop to attr and sets in adapter for query methods

* adds assertions to verify that frontend only props are returned from query methods in ldap role adapter

* LDAP Library Model, Adapter and Serializer (#21728)

* adds model, adapter and serializer for ldap library

* updates capitalization and punction for ldap role and library form fields

* LDAP Roles Create and Edit (#21818)

* moves stringify and jsonify helpers to core addon

* adds validation error for ttl picker in form field component

* adds ldap roles create and edit routes and page component

* adds ldap mirage handler and factory for roles

* adds example workflow to json editor component

* adds tests for ldap page create and edit component

* addresses feedback

* LDAP Role Details (#22036)

* adds ldap role route to pass down model to child routes

* adds ldap role details route and page component

* updates ldap role model capabilities checks

* adds periods to error messages

* removes modelFor from ldap roles edit and details routes

* adds flash message on ldap role delete success

* LDAP Roles (#22070)

* adds ldap roles route and page component

* update ldap role adapter tests and adds adapter options to query for partialErrorInfo

* updates ldap role adapter based on PR feedback

* adds filter-input component to core addon

* updates ldap roles page to use filter-input component

* updates ldap role adapter tests

* LDAP Role Credentials (#22142)

* adds ldap roles route and page component

* update ldap role adapter tests and adds adapter options to query for partialErrorInfo

* adds credentials actions to ldap roles list menu and fixes rotate action in details view

* adds ldap role credentials route and page component

* adds tests for ldap role credentials

* LDAP Library Create and Edit (#22171)

* adds ldap library create/edit routes and page component

* adds ldap library create-and-edit tests and library mirage factory

* updates form-field component to display validation errors and warnings for all fields

* updates ldap library edit route class name

* updates ldap library model interface name

* adds missing period in flash message

* LDAP Libraries (#22184)

* updates interface and class names in ldap roles route

* adds ldap libraries route and page component

* fixes lint error

* LDAP Library Details (#22200)

* updates interface and class names in ldap roles route

* adds ldap libraries route and page component

* fixes lint error

* adds ldap library details route and page component

* LDAP Library Details Configuration (#22201)

* updates interface and class names in ldap roles route

* adds ldap libraries route and page component

* fixes lint error

* adds ldap library details route and page component

* adds ldap library details configuration route and page component

* updates ldap library check-in enforcement value mapping

* fixes issue in code mirror modifier after merging upgrade

* fixes failing database secrets test

* LDAP Library Account Details (#22287)

* adds route and page component for ldap library accounts

* adds ldap component for checked out accounts

* updates ldap library adapter tests

* LDAP Library Check-out (#22289)

* adds route and page component for ldap library accounts

* adds ldap component for checked out accounts

* adds route and page component for ldap library checkout

* addresses PR feedback

* LDAP Overview Cards (#22325)

* adds overview cards to ldap overview route

* adds create library toolbar action to ldap overview route

* adds acceptance tests for ldap workflows (#22375)

* Fetch Secrets Engine Config Decorator Docs (#22416)

* removes uneccesary asyncs from ldap route model hooks

* updates ldap overview route class name

* adds documentation for fetch-secrets-engine-config decorator

* add changelog

* adding back external links, missed due to merge.

* changelog

* fix test after merging in dashboard work

* Update 20790.txt

---------

Co-authored-by: Angel Garbarino <angel@hashicorp.com>
Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
This commit is contained in:
Jordan Reimer 2023-08-25 10:54:29 -06:00 committed by GitHub
parent 2d0d5c79ed
commit a8b593614e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
178 changed files with 7112 additions and 219 deletions

3
changelog/20790.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
**UI LDAP secrets engine**: Add LDAP secrets engine to the UI.
```

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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'],

View File

@ -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',

View File

@ -18,6 +18,7 @@ const SUPPORTED_SECRET_BACKENDS = [
'transform',
'keymgmt',
'kubernetes',
'ldap',
];
export function supportedSecretBackends() {

View File

@ -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);
}
}

View File

@ -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]);
}
}

230
ui/app/models/ldap/role.js Normal file
View File

@ -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 credentials 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 credentials 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);
}
}

View File

@ -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() {

View File

@ -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');

View File

@ -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 },
};
}

View File

@ -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';
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -5,7 +5,6 @@
.overview-card {
padding: $spacing-l;
display: initial;
line-height: initial;
.title-number {

View File

@ -326,4 +326,9 @@ a.button.disabled {
font-size: inherit;
font-weight: inherit;
cursor: pointer;
&:disabled {
color: $grey-light;
cursor: not-allowed;
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -116,6 +116,10 @@
border-radius: $radius;
}
.border-radius-4 {
border-radius: $radius-large;
}
// border-spacing
.is-border-spacing-revert {
border-spacing: revert;

View File

@ -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;
}

View File

@ -1,16 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<WizardSection
@headerText="Active Directory"
@headerIcon="azure-color"
@docText="Docs: Active Directory Secrets"
@docPath="/docs/secrets/ad/index.html"
>
<p>
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.
</p>
</WizardSection>

View File

@ -49,7 +49,7 @@
<LinkedBlock
@params={{array backend.backendLink backend.id}}
class="list-item-row linked-block-item is-no-underline"
data-test-auth-backend-link={{backend.id}}
data-test-secrets-backend-link={{backend.id}}
@disabled={{if backend.isSupportedBackend false true}}
>
<div>

View File

@ -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|}}
<InfoTableRow @label={{field.label}} @value={{field.value}} />
{{/each}}
{{else if @configError}}
<Page::Error @error={{@configError}} />
{{else}}
<ConfigCta />
{{/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(() => []),
});
}
}
```

View File

@ -0,0 +1,12 @@
<div class="field">
<p class="control has-icons-left">
<Input
class="filter input"
placeholder={{this.placeholder}}
data-test-filter-input
@value={{@value}}
{{on "input" this.onInput}}
/>
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>

View File

@ -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<Args> {
get placeholder() {
return this.args.placeholder || 'Type to filter results';
}
@action onInput(event: HTMLElementEvent<HTMLInputElement>) {
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);
}
}

View File

@ -57,14 +57,6 @@
{{/each}}
</select>
</div>
{{#if this.validationError}}
<AlertInline
@type="danger"
@message={{this.validationError}}
@paddingTop={{true}}
data-test-field-validation={{@attr.name}}
/>
{{/if}}
</div>
{{/if}}
{{else if (eq @attr.options.editType "searchSelect")}}
@ -86,9 +78,6 @@
class={{if this.validationError "dropdown-has-error-border"}}
/>
</div>
{{#if this.validationError}}
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
{{/if}}
{{else if (eq @attr.options.editType "mountAccessor")}}
<MountAccessorSelect
@name={{@attr.name}}
@ -126,6 +115,7 @@
{{#let (or (get @model this.valuePath) @attr.options.setDefault) as |initialValue|}}
<TtlPicker
data-test-input={{@attr.name}}
class={{if this.validationError "ttl-picker-form-field-error"}}
@onChange={{this.setAndBroadcastTtl}}
@label={{this.labelString}}
@helperTextDisabled={{or @attr.options.helperTextDisabled "Vault will use the default lease duration."}}
@ -194,6 +184,7 @@
</Toggle>
{{else if (eq @attr.options.editType "stringArray")}}
<StringList
class={{if this.validationError "string-list-form-field-error"}}
data-test-input={{@attr.name}}
@label={{this.labelString}}
@helpText={{if this.showHelpText @attr.options.helpText}}
@ -210,9 +201,6 @@
@onChange={{this.setAndBroadcast}}
@onKeyUp={{@onKeyUp}}
/>
{{#if this.validationError}}
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
{{/if}}
{{else if (or (eq @attr.type "number") (eq @attr.type "string"))}}
<div class="control">
{{#if (eq @attr.options.editType "textarea")}}
@ -224,9 +212,6 @@
oninput={{this.onChangeWithEvent}}
class="textarea {{if this.validationError 'has-error-border'}}"
></textarea>
{{#if this.validationError}}
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
{{/if}}
{{else if (eq @attr.options.editType "password")}}
<Input
data-test-input={{@attr.name}}
@ -253,6 +238,7 @@
@theme={{or @attr.options.theme "hashi"}}
@helpText={{@attr.options.helpText}}
@mode={{@attr.options.mode}}
@example={{@attr.options.example}}
>
{{#if @attr.options.allowReset}}
<button
@ -295,22 +281,12 @@
class="input {{if this.validationError 'has-error-border'}}"
maxLength={{@attr.options.characterLimit}}
/>
{{! TODO: explore removing in favor of new model validations pattern since it is only used on the namespace model }}
{{#if @attr.options.validationAttr}}
{{#if (and (get @model this.valuePath) (not (get @model @attr.options.validationAttr)))}}
<AlertInline @type="danger" @message={{@attr.options.invalidMessage}} />
{{/if}}
{{/if}}
{{#if this.validationError}}
<AlertInline @type="danger" @message={{this.validationError}} @paddingTop={{true}} />
{{/if}}
{{#if this.validationWarning}}
<AlertInline
@type="warning"
@message={{this.validationWarning}}
@paddingTop={{true}}
data-test-validation-warning
/>
{{/if}}
{{/if}}
</div>
{{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}}
<AlertInline
@type="danger"
@message={{this.validationError}}
@paddingTop={{not-eq @attr.options.editType "ttl"}}
data-test-field-validation={{@attr.name}}
class={{if (eq @attr.options.editType "stringArray") "has-top-margin-negative-xxl"}}
/>
{{/if}}
{{#if this.validationWarning}}
<AlertInline
@type="warning"
@message={{this.validationWarning}}
@paddingTop={{if (and (not this.validationError) (eq @attr.options.editType "ttl")) false true}}
data-test-validation-warning={{@attr.name}}
class={{if (and (not this.validationError) (eq @attr.options.editType "stringArray")) "has-top-margin-negative-xxl"}}
/>
{{/if}}
</div>

View File

@ -15,6 +15,18 @@
</label>
<ToolbarActions>
{{yield}}
{{#if @example}}
<button
type="button"
class="toolbar-link"
disabled={{not @value}}
{{on "click" this.restoreExample}}
data-test-restore-example
>
Restore example
<Icon @name="reload" />
</button>
{{/if}}
<div class="toolbar-separator"></div>
<CopyButton
class="button is-transparent"
@ -30,7 +42,7 @@
{{/if}}
<div
{{code-mirror
content=@value
content=(or @value @example)
extraKeys=@extraKeys
gutters=@gutters
lineNumbers=(if @readOnly false true)
@ -38,6 +50,7 @@
readOnly=@readOnly
theme=@theme
viewportMarg=@viewportMargin
onSetup=this.onSetup
onUpdate=this.onUpdate
onFocus=this.onFocus
}}

View File

@ -26,6 +26,7 @@ import { action } from '@ember/object';
* @param {String} [theme] - Specify or customize the look via a named "theme" class in scss.
* @param {String} [value] - Value within the display. Generally, a json string.
* @param {String} [viewportMargin] - Size of viewport. Often set to "Infinity" to load/show all text regardless of length.
* @param {string} [example] - Example to show when value is null -- when example is provided a restore action will render in the toolbar to clear the current value and show the example after input
*/
export default class JsonEditorComponent extends Component {
@ -33,6 +34,12 @@ export default class JsonEditorComponent extends Component {
return this.args.showToolbar === false ? false : true;
}
@action
onSetup(editor) {
// store reference to codemirror editor so that it can be passed to valueUpdated when restoring example
this._codemirrorEditor = editor;
}
@action
onUpdate(...args) {
if (!this.args.readOnly) {
@ -47,4 +54,10 @@ export default class JsonEditorComponent extends Component {
this.args.onFocusOut(...args);
}
}
@action
restoreExample() {
// set value to null which will cause the example value to be passed into the editor
this.args.valueUpdated(null, this._codemirrorEditor);
}
}

View File

@ -8,6 +8,7 @@
@hasBorder={{true}}
class="overview-card border-radius-2"
data-test-overview-card-container={{@cardTitle}}
...attributes
>
<div class="is-flex-between" data-test-overview-card={{@cardTitle}}>
<h3 class="title is-5">{{@cardTitle}}</h3>

View File

@ -9,14 +9,16 @@
<li data-test-crumb="{{idx}}">
<span class="sep">/</span>
{{#if breadcrumb.linkExternal}}
<LinkToExternal @route={{breadcrumb.route}}>{{breadcrumb.label}}</LinkToExternal>
<LinkToExternal @route={{breadcrumb.route}} data-test-breadcrumb={{breadcrumb.label}}>
{{breadcrumb.label}}
</LinkToExternal>
{{else if breadcrumb.route}}
{{#if breadcrumb.model}}
<LinkTo @route={{breadcrumb.route}} @model={{breadcrumb.model}}>
<LinkTo @route={{breadcrumb.route}} @model={{breadcrumb.model}} data-test-breadcrumb={{breadcrumb.label}}>
{{breadcrumb.label}}
</LinkTo>
{{else}}
<LinkTo @route={{breadcrumb.route}}>
<LinkTo @route={{breadcrumb.route}} data-test-breadcrumb={{breadcrumb.label}}>
{{breadcrumb.label}}
</LinkTo>
{{/if}}

View File

@ -0,0 +1,17 @@
<div ...attributes>
<ToggleButton
@isOpen={{this.showConfig}}
@openLabel="Hide mount configuration"
@closedLabel="Show mount configuration"
@onClick={{fn (mut this.showConfig) (not this.showConfig)}}
class="is-block"
data-test-mount-config-toggle
/>
{{#if this.showConfig}}
{{#each this.fields as |field|}}
<InfoTableRow @label={{field.label}} @value={{field.value}} data-test-mount-config-field={{field.label}} />
{{/each}}
{{! block for additional fields that may be engine specific }}
{{yield}}
{{/if}}
</div>

View File

@ -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<Args> {
@tracked showConfig = false;
get fields(): Array<Field> {
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 },
];
}
}

View File

@ -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 <RouteClass extends new (...args: any[]) => 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;

View File

@ -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);
}
}
}

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/components/filter-input';

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/components/secrets-engine-mount-config';

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default, jsonify } from 'core/helpers/jsonify';

View File

@ -3,4 +3,4 @@
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/helpers/stringify';
export { default, stringify } from 'core/helpers/stringify';

View File

@ -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;

View File

@ -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 });
}

View File

@ -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'),

View File

@ -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 });
}

View File

@ -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()))

View File

@ -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,
};
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -0,0 +1,75 @@
<OverviewCard
@cardTitle="Accounts checked-out"
@subText="The accounts that are currently on lease with this token or exist in a library set with check-in enforcement disabled."
class="has-padding-l"
...attributes
>
<hr class="has-background-gray-200" />
{{#if this.filteredAccounts}}
<Hds::Table @model={{this.filteredAccounts}} @columns={{this.columns}}>
<:body as |Body|>
<Body.Tr>
<Body.Td data-test-checked-out-account={{Body.data.account}}>{{Body.data.account}}</Body.Td>
{{#if @showLibraryColumn}}
<Body.Td data-test-checked-out-library={{Body.data.account}}>{{Body.data.library}}</Body.Td>
{{/if}}
<Body.Td>
<button
type="button"
class="text-button has-text-primary has-text-weight-semibold"
disabled={{this.disableCheckIn Body.data.library}}
data-test-checked-out-account-action={{Body.data.account}}
{{on "click" (fn (mut this.selectedStatus) Body.data)}}
>
<Icon @name="queue" />
Check-in
</button>
</Body.Td>
</Body.Tr>
</:body>
</Hds::Table>
{{else}}
<EmptyState
@title="No accounts checked out yet"
@message="There is no account that is currently in use."
class="is-shadowless"
/>
{{/if}}
</OverviewCard>
{{#if this.selectedStatus}}
<Modal
@title="Account Check-in"
@isActive={{this.selectedStatus}}
@showCloseButton={{true}}
@onClose={{fn (mut this.selectedStatus) undefined}}
>
<section class="modal-card-body">
<p>
This action will check-in account
{{this.selectedStatus.account}}
back to the library. Do you want to proceed?
</p>
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.checkIn.isRunning}}
data-test-check-in-confirm
{{on "click" (perform this.checkIn)}}
>
Confirm
</button>
<button
type="button"
class="button"
disabled={{this.checkIn.isRunning}}
{{on "click" (fn (mut this.selectedStatus) "")}}
>
Cancel
</button>
</footer>
</Modal>
{{/if}}

View File

@ -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<LdapLibraryModel>;
statuses: Array<LdapLibraryAccountStatus>;
showLibraryColumn: boolean;
}
export default class LdapAccountsCheckedOutComponent extends Component<Args> {
@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)}`);
}
}
}

View File

@ -0,0 +1,9 @@
<EmptyState
data-test-config-cta
@title="LDAP not configured"
@message="Get started by setting up the connection with your existing LDAP system."
>
<LinkTo class="has-top-margin-xs" @route="configure">
Configure LDAP
</LinkTo>
</EmptyState>

View File

@ -0,0 +1,39 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarActions>
{{#if @configModel}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{perform this.rotateRoot}}
@confirmTitle="Rotate root?"
@confirmMessage="After rotation, Vault will generate a new root password in your directory server."
@confirmButtonText="Rotate"
@disabled={{this.rotateRoot.isRunning}}
data-test-toolbar-rotate-action
>
Rotate root
</ConfirmAction>
{{/if}}
<ToolbarLink @route="configure" data-test-toolbar-config-action>
{{if @configModel "Edit configuration" "Configure LDAP"}}
</ToolbarLink>
</:toolbarActions>
</TabPageHeader>
{{#if @configModel}}
{{#each this.defaultFields as |field|}}
<InfoTableRow @label={{field.label}} @value={{field.value}} @formatTtl={{field.formatTtl}} @alwaysRender={{true}} />
{{/each}}
<h2 class="title is-4 has-top-margin-xl">TLS Connection</h2>
<hr class="is-marginless" />
{{#each this.connectionFields as |field|}}
<InfoTableRow @label={{field.label}} @value={{field.value}} @alwaysRender={{true}} />
{{/each}}
{{else if @configError}}
<Page::Error @error={{@configError}} />
{{else}}
<ConfigCta />
{{/if}}
<SecretsEngineMountConfig @model={{@backendModel}} class="has-top-margin-xl has-bottom-margin-xl" data-test-mount-config />

View File

@ -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<Breadcrumb>;
}
interface Field {
label: string;
value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
formatTtl?: boolean;
}
export default class LdapConfigurationPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
get defaultFields(): Array<Field> {
const model = this.args.configModel;
const keys = [
'binddn',
'url',
'schema',
'password_policy',
'userdn',
'userattr',
'connection_timeout',
'request_timeout',
];
return model.allFields.reduce<Array<Field>>((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<Field> {
const model = this.args.configModel;
const keys = ['certificate', 'starttls', 'insecure_tls', 'client_tls_cert', 'client_tls_key'];
return model.allFields.reduce<Array<Field>>((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)}`);
}
}
}

View File

@ -0,0 +1,113 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">Configure LDAP</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<form class="has-top-margin-l" {{on "submit" (perform this.save)}}>
<Hds::Form::RadioCard::Group @name="schema options" as |RadioGroup|>
{{#each this.schemaOptions as |option|}}
<RadioGroup.RadioCard
@checked={{eq option.value @model.schema}}
{{on "change" (fn (mut @model.schema) option.value)}}
data-test-radio-card={{option.title}}
as |Card|
>
<Card.Icon @name={{option.icon}} />
<Card.Label>{{option.title}}</Card.Label>
<Card.Description>{{option.description}}</Card.Description>
</RadioGroup.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
<div class="has-top-margin-xl">
<MessageError @errorMessage={{this.error}} />
<h2 class="title is-4">Schema Options</h2>
<hr class="has-background-gray-200" />
{{#if @model.schema}}
<div class="has-top-margin-l">
<FormFieldGroups @model={{@model}} @groupName="formFieldGroups" @modelValidations={{this.modelValidations}} />
</div>
{{else}}
<EmptyState
class="is-shadowless has-top-margin-l"
@title="Choose an option"
@message="Pick an option above to see available configuration options"
/>
{{/if}}
</div>
<hr class="has-background-gray-200 has-top-margin-l" />
<div class="has-top-margin-l has-bottom-margin-l is-flex">
<button
data-test-config-save
class="button is-primary"
type="submit"
disabled={{or this.save.isRunning (not @model.schema)}}
{{on "click" (perform this.save)}}
>
Save
</button>
<button
data-test-config-cancel
class="button has-left-margin-xs"
type="button"
disabled={{or this.save.isRunning this.fetchInferred.isRunning}}
{{on "click" this.cancel}}
>
Back
</button>
{{#if this.invalidFormMessage}}
<AlertInline
@type="danger"
@paddingTop={{true}}
@message={{this.invalidFormMessage}}
@mimicRefresh={{true}}
data-test-invalid-form-message
/>
{{/if}}
</div>
</form>
{{#if this.showRotatePrompt}}
<Modal
@title="Rotate your root password?"
@type="info"
@isActive={{this.showRotatePrompt}}
@showCloseButton={{true}}
@onClose={{fn (mut this.showRotatePrompt) false}}
>
<section class="modal-card-body">
<p>
Its best practice to rotate the administrator (root) password immediately after the initial configuration of the
LDAP engine. The rotation will update the password both in Vault and your directory server. Once rotated,
<span class="has-text-weight-semibold">only Vault knows the new root password.</span>
</p>
<br />
<p>
Would you like to rotate your new credentials? You can also do this later.
</p>
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button
data-test-save-with-rotate
type="button"
class="button is-primary"
{{on "click" (fn (perform this.save) null true)}}
>
Save and rotate
</button>
<button data-test-save-without-rotate type="button" class="button" {{on "click" (fn (perform this.save) null false)}}>
Save without rotating
</button>
</footer>
</Modal>
{{/if}}

View File

@ -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<Breadcrumb>;
}
interface SchemaOption {
title: string;
icon: string;
description: string;
value: string;
}
export default class LdapConfigurePageComponent extends Component<Args> {
@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<SchemaOption> {
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);
}
}

View File

@ -0,0 +1,91 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarFilters>
{{#if (and (not @promptConfig) @libraries)}}
<FilterInput @placeholder="Filter libraries" @onInput={{fn (mut this.filterValue)}} />
{{/if}}
</:toolbarFilters>
<:toolbarActions>
{{#if @promptConfig}}
<ToolbarLink @route="configure" data-test-toolbar-action="config">
Configure LDAP
</ToolbarLink>
{{else}}
<ToolbarLink @route="libraries.create" @type="add" data-test-toolbar-action="library">
Create library
</ToolbarLink>
{{/if}}
</:toolbarActions>
</TabPageHeader>
{{#if @promptConfig}}
<ConfigCta />
{{else if (not this.filteredLibraries)}}
{{#if this.filterValue}}
<EmptyState @title="There are no libraries matching &quot;{{this.filterValue}}&quot;" />
{{else}}
<EmptyState
data-test-config-cta
@title="No libraries created yet"
@message="Use libraries to manage a set of highly privileged accounts that can be shared among a team."
>
<LinkTo class="has-top-margin-xs" @route="libraries.create">
Create library
</LinkTo>
</EmptyState>
{{/if}}
{{else}}
<div class="has-bottom-margin-s">
{{#each this.filteredLibraries as |library|}}
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "libraries.library.details" library.name}} as |Item|>
<Item.content>
<Icon @name="folder" />
<span data-test-library={{library.name}}>{{library.name}}</span>
</Item.content>
<Item.menu as |Menu|>
{{#if library.libraryPath.isLoading}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-edit
@route="libraries.library.edit"
@model={{library}}
@disabled={{not library.canEdit}}
>
Edit
</LinkTo>
</li>
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-details
@route="libraries.library.details"
@model={{library}}
@disabled={{not library.canRead}}
>
Details
</LinkTo>
</li>
{{#if library.canDelete}}
<li class="action">
<Menu.Message
data-test-delete
@id={{library.id}}
@triggerText="Delete"
@title="Are you sure?"
@message="This library and associated accounts will be permanently deleted. You will not be able to recover it."
@onConfirm={{fn this.onDelete library}}
/>
</li>
{{/if}}
{{/if}}
</Item.menu>
</ListItem>
{{/each}}
</div>
{{/if}}

View File

@ -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<LdapLibraryModel>;
promptConfig: boolean;
backendModel: SecretEngineModel;
breadcrumbs: Array<Breadcrumb>;
}
export default class LdapLibrariesPageComponent extends Component<Args> {
@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)}`);
}
}
}

View File

@ -0,0 +1,50 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
Check-out
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-m" as |Alert|>
<Alert.Title>Warning</Alert.Title>
<Alert.Description data-test-alert-description>
You wont be able to access these credentials later, so please copy them now.
</Alert.Description>
</Hds::Alert>
<div class="has-top-margin-m">
<InfoTableRow @label="Account name" @value={{@credentials.account}} />
<InfoTableRow @label="Password">
<MaskedInput @value={{@credentials.password}} @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
<InfoTableRow @label="Lease ID" @value={{@credentials.lease_id}} />
<InfoTableRow @label="Lease duration" @value={{@credentials.lease_duration}} @formatTtl={{true}} />
<InfoTableRow @label="Lease renewable">
<div class="is-flex-v-centered">
<Icon
@name={{if @credentials.renewable "check-circle" "x-circle"}}
class="is-marginless {{if @credentials.renewable 'has-text-success' 'has-text-danger'}}"
/>
<span class="has-left-margin-xs">
{{if @credentials.renewable "True" "False"}}
</span>
</div>
</InfoTableRow>
</div>
<div class="has-top-margin-xl has-bottom-margin-l">
<button
data-test-done
class="button is-primary"
type="button"
{{on "click" (transition-to "vault.cluster.secrets.backend.ldap.libraries.library.details.accounts")}}
>
Done
</button>
</div>

View File

@ -0,0 +1,46 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{if @model.isNew "Create Library" "Edit Library"}}
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<form {{on "submit" (perform this.save)}} class="has-top-margin-m">
<MessageError @errorMessage={{this.error}} />
{{#each @model.formFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<hr class="has-background-gray-200 has-top-margin-l" />
<div class="has-top-margin-l has-bottom-margin-l is-flex">
<button data-test-save class="button is-primary" type="submit" disabled={{this.save.isRunning}}>
{{if @model.isNew "Create library" "Save"}}
</button>
<button
data-test-cancel
class="button has-left-margin-xs"
type="button"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
>
Cancel
</button>
{{#if this.invalidFormMessage}}
<AlertInline
@type="danger"
@paddingTop={{true}}
@message={{this.invalidFormMessage}}
@mimicRefresh={{true}}
data-test-invalid-form-message
/>
{{/if}}
</div>
</form>

View File

@ -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<Breadcrumb>;
}
export default class LdapCreateAndEditLibraryPageComponent extends Component<Args> {
@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');
}
}

View File

@ -0,0 +1,37 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
{{@model.name}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="ldap tabs">
<ul>
<LinkTo @route="libraries.library.details.accounts" data-test-tab="accounts">Accounts</LinkTo>
<LinkTo @route="libraries.library.details.configuration" data-test-tab="config">Configuration</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarActions>
{{#if @model.canDelete}}
<ConfirmAction @buttonClasses="toolbar-link" @onConfirmAction={{this.delete}} data-test-delete>
Delete library
</ConfirmAction>
{{#if @model.canEdit}}
<div class="toolbar-separator"></div>
{{/if}}
{{/if}}
{{#if @model.canEdit}}
<ToolbarLink @route="libraries.library.edit" data-test-edit>
Edit library
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>

View File

@ -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<Breadcrumb>;
}
export default class LdapLibraryDetailsPageComponent extends Component<Args> {
@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);
}
}
}

View File

@ -0,0 +1,90 @@
<div class="has-top-margin-l is-flex-align-start">
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-l is-flex-half">
<div class="is-flex-between">
<h3 class="is-size-5 has-text-weight-semibold">All accounts</h3>
{{#if @library.canCheckOut}}
<button
type="button"
class="button is-link"
data-test-check-out
{{on "click" (fn (mut this.showCheckOutPrompt) true)}}
>
Check-out
</button>
{{/if}}
</div>
<p class="has-text-grey is-size-8">The accounts within this library</p>
<hr class="has-background-gray-200" />
<Hds::Table @model={{@statuses}} @columns={{array (hash label="Accounts") (hash label="Status")}}>
<:body as |Body|>
<Body.Tr>
<Body.Td data-test-account-name={{Body.data.account}}>{{Body.data.account}}</Body.Td>
<Body.Td>
<Hds::Badge
@text={{if Body.data.available "Available" "Unavailable"}}
@color={{if Body.data.available "success" "neutral"}}
data-test-account-status={{Body.data.account}}
/>
</Body.Td>
</Body.Tr>
</:body>
</Hds::Table>
</Hds::Card::Container>
<div class="has-left-margin-l is-flex-half">
<AccountsCheckedOut @libraries={{array @library}} @statuses={{@statuses}} data-test-checked-out-card />
<OverviewCard
@cardTitle="To renew a checked-out account"
@subText="Use the CLI command below:"
class="has-padding-l has-top-margin-l"
>
<div class="has-padding-s has-background-gray-900 border-radius-4 is-flex-between has-top-margin-s">
<code class="has-text-white is-size-7" data-test-cli-command>{{this.cliCommand}}</code>
<CopyButton
class="button is-compact is-transparent has-text-grey-light"
data-test-cli-command-copy
@clipboardText={{this.cliCommand}}
@buttonType="button"
@success={{action (set-flash-message "Renew command copied!")}}
>
Copy
<Icon @name="clipboard-copy" aria-label="Copy" />
</CopyButton>
</div>
</OverviewCard>
</div>
</div>
{{#if this.showCheckOutPrompt}}
<Modal
@title="Account Check-out"
@isActive={{this.showCheckOutPrompt}}
@showCloseButton={{true}}
@onClose={{fn (mut this.showCheckOutPrompt) false}}
>
<section class="modal-card-body">
<p>
Current generated credentials time-to-live is set at
{{format-duration @library.ttl}}. You can set a different limit if youd like:
</p>
<br />
<TtlPicker @label="TTL" @hideToggle={{true}} @initialValue={{@library.ttl}} @onChange={{this.setTtl}} />
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button data-test-check-out="save" type="button" class="button is-primary" {{on "click" this.checkOut}}>
Check-out
</button>
<button
data-test-check-out="cancel"
type="button"
class="button"
{{on "click" (fn (mut this.showCheckOutPrompt) false)}}
>
Cancel
</button>
</footer>
</Modal>
{{/if}}

View File

@ -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<LdapLibraryAccountStatus>;
}
export default class LdapLibraryDetailsAccountsPageComponent extends Component<Args> {
@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 },
});
}
}

View File

@ -0,0 +1,21 @@
{{#each @model.displayFields as |field|}}
{{#let (get @model field.name) as |value|}}
{{#if (eq field.name "disable_check_in_enforcement")}}
<InfoTableRow @label={{field.options.label}}>
<Icon
class="is-flex-v-centered {{if (eq value 'Enabled') 'icon-true' 'icon-false'}}"
@name={{if (eq value "Enabled") "check-circle" "x-square"}}
data-test-check-in-icon
/>
<span>{{value}}</span>
</InfoTableRow>
{{else}}
<InfoTableRow
data-test-filtered-field
@label={{or field.options.detailsLabel field.options.label}}
@value={{value}}
@formatTtl={{eq field.options.editType "ttl"}}
/>
{{/if}}
{{/let}}
{{/each}}

View File

@ -0,0 +1,69 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarActions>
{{#if @promptConfig}}
<ToolbarLink @route="configure" data-test-toolbar-action="config">
Configure LDAP
</ToolbarLink>
{{/if}}
</:toolbarActions>
</TabPageHeader>
{{#if @promptConfig}}
<ConfigCta />
{{else}}
<div class="is-grid has-top-margin-l grid-2-columns grid-gap-2">
<OverviewCard
@cardTitle="Roles"
@subText="The total number of roles that have been set up in this secret engine in order to generate credentials."
@actionText="Create new"
@actionTo="roles.create"
>
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-roles-count>
{{or @roles.length "None"}}
</h2>
</OverviewCard>
<OverviewCard
@cardTitle="Libraries"
@subText="The total number of libraries that have been created for service account management."
@actionText="Create new"
@actionTo="libraries.create"
>
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-libraries-count>
{{or @libraries.length "None"}}
</h2>
</OverviewCard>
</div>
<div class="is-flex-align-start has-top-margin-l">
<AccountsCheckedOut
@libraries={{@libraries}}
@statuses={{@librariesStatus}}
@showLibraryColumn={{true}}
class="is-flex-half"
/>
<div class="has-left-margin-l is-flex-half">
<OverviewCard @cardTitle="Generate credentials" @subText="Quickly generate credentials by typing the role name.">
<div class="has-top-margin-m is-flex">
<SearchSelect
class="is-flex-1"
@placeholder="Select a role"
@disallowNewItems={{true}}
@options={{@roles}}
@selectLimit="1"
@fallbackComponent="input-search"
@onChange={{this.selectRole}}
/>
<button
class="button has-left-margin-s"
type="button"
disabled={{not this.selectedRole}}
{{on "click" this.generateCredentials}}
data-test-generate-credential-button
>
Get credentials
</button>
</div>
</OverviewCard>
</div>
</div>
{{/if}}

View File

@ -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<LdapRoleModel>;
libraries: Array<LdapLibraryModel>;
librariesStatus: Array<LdapLibraryAccountStatus>;
promptConfig: boolean;
backendModel: SecretEngineModel;
breadcrumbs: Array<Breadcrumb>;
}
export default class LdapLibrariesPageComponent extends Component<Args> {
@service declare readonly router: RouterService;
@tracked selectedRole: LdapRoleModel | undefined;
@action
selectRole([roleName]: Array<string>) {
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);
}
}

View File

@ -0,0 +1,70 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{if @model.isNew "Create Role" "Edit Role"}}
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<form {{on "submit" (perform this.save)}} class="has-top-margin-m">
<MessageError @errorMessage={{this.error}} />
<label class="is-label">
Role type
</label>
<Hds::Form::RadioCard::Group @name="role type options" class="has-bottom-margin-m" as |RadioGroup|>
{{#each this.roleTypeOptions as |option|}}
<RadioGroup.RadioCard
@checked={{eq option.value @model.type}}
@disabled={{not @model.isNew}}
{{on "change" (fn (mut @model.type) option.value)}}
data-test-radio-card={{option.value}}
as |Card|
>
<Card.Icon @name={{option.icon}} />
<Card.Label>{{option.title}}</Card.Label>
<Card.Description>{{option.description}}</Card.Description>
</RadioGroup.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{#each @model.formFields as |field|}}
{{! display section heading ahead of ldif fields }}
{{#if field.options.sectionHeading}}
<hr class="has-background-gray-200" />
<h2 class="title is-4 has-top-margin-xl">{{field.options.sectionHeading}}</h2>
{{/if}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<hr class="has-background-gray-200 has-top-margin-l" />
<div class="has-top-margin-l has-bottom-margin-l is-flex">
<button data-test-save class="button is-primary" type="submit" disabled={{this.save.isRunning}}>
{{if @model.isNew "Create role" "Save"}}
</button>
<button
data-test-cancel
class="button has-left-margin-xs"
type="button"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
>
Cancel
</button>
{{#if this.invalidFormMessage}}
<AlertInline
@type="danger"
@paddingTop={{true}}
@message={{this.invalidFormMessage}}
@mimicRefresh={{true}}
data-test-invalid-form-message
/>
{{/if}}
</div>
</form>

View File

@ -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<Breadcrumb>;
}
interface RoleTypeOption {
title: string;
icon: string;
description: string;
value: string;
}
export default class LdapCreateAndEditRolePageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly router: RouterService;
@tracked modelValidations: ValidationMap | null = null;
@tracked invalidFormMessage = '';
@tracked error = '';
get roleTypeOptions(): Array<RoleTypeOption> {
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');
}
}

View File

@ -0,0 +1,64 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
Credentials
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
{{#if (eq @credentials.type "dynamic")}}
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-m" as |Alert|>
<Alert.Title>Warning</Alert.Title>
<Alert.Description data-test-alert-description>
You wont be able to access these credentials later, so please copy them now.
</Alert.Description>
</Hds::Alert>
{{/if}}
<div class="has-top-margin-m">
{{#each this.fields as |field|}}
{{#let (get @credentials field.key) as |value|}}
{{#if field.hasBlock}}
<InfoTableRow @label={{field.label}}>
{{#if (eq field.hasBlock "masked")}}
<MaskedInput @value={{value}} @displayOnly={{true}} @allowCopy={{true}} />
{{else if (eq field.hasBlock "check")}}
<div class="is-flex-v-centered">
<Icon
@name={{if value "check-circle" "x-circle"}}
class="is-marginless {{if value 'has-text-success' 'has-text-danger'}}"
/>
<span class="has-left-margin-xs">
{{if value "True" "False"}}
</span>
</div>
{{/if}}
</InfoTableRow>
{{else}}
<InfoTableRow
@label={{field.label}}
@value={{value}}
@formatDate={{field.formatDate}}
@formatTtl={{field.formatTtl}}
@type={{field.type}}
/>
{{/if}}
{{/let}}
{{/each}}
</div>
<div class="has-top-margin-xl has-bottom-margin-l">
<button
data-test-done
class="button is-primary"
type="button"
{{on "click" (transition-to "vault.cluster.secrets.backend.ldap.roles.role.details")}}
>
Done
</button>
</div>

View File

@ -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<Breadcrumb>;
}
export default class LdapRoleCredentialsPageComponent extends Component<Args> {
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;
}
}

View File

@ -0,0 +1,54 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
{{@model.name}}
</h1>
</p.levelLeft>
</PageHeader>
<Toolbar>
<ToolbarActions>
{{#if @model.canDelete}}
<ConfirmAction @buttonClasses="toolbar-link" @onConfirmAction={{this.delete}} data-test-delete>
Delete role
</ConfirmAction>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @model.canReadCreds}}
<ToolbarLink @route="roles.role.credentials" data-test-get-credentials>
Get credentials
</ToolbarLink>
{{/if}}
{{#if @model.canRotateStaticCreds}}
<ConfirmAction
@buttonClasses="toolbar-link"
@confirmTitle="Rotate credentials?"
@confirmMessage="When manually rotating credentials, the rotation period will start over."
@confirmButtonText="Rotate"
@disabled={{this.rotateCredentials.isRunning}}
@onConfirmAction={{perform this.rotateCredentials}}
data-test-rotate-credentials
>
Rotate credentials
</ConfirmAction>
{{/if}}
{{#if @model.canEdit}}
<ToolbarLink @route="roles.role.edit" data-test-edit>
Edit role
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each @model.displayFields as |field|}}
{{#let (get @model field.name) as |value|}}
<InfoTableRow
data-test-filtered-field
@label={{or field.options.detailsLabel field.options.label}}
@value={{if (eq field.options.editType "ttl") (format-duration value) value}}
/>
{{/let}}
{{/each}}

View File

@ -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<Breadcrumb>;
}
export default class LdapRoleDetailsPageComponent extends Component<Args> {
@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)}`);
}
}
}

View File

@ -0,0 +1,117 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarFilters>
{{#if (and (not @promptConfig) @roles)}}
<FilterInput @placeholder="Filter roles" @onInput={{fn (mut this.filterValue)}} />
{{/if}}
</:toolbarFilters>
<:toolbarActions>
{{#if @promptConfig}}
<ToolbarLink @route="configure" data-test-toolbar-action="config">
Configure LDAP
</ToolbarLink>
{{else}}
<ToolbarLink @route="roles.create" @type="add" data-test-toolbar-action="role">
Create role
</ToolbarLink>
{{/if}}
</:toolbarActions>
</TabPageHeader>
{{#if @promptConfig}}
<ConfigCta />
{{else if (not this.filteredRoles)}}
{{#if this.filterValue}}
<EmptyState @title="There are no roles matching &quot;{{this.filterValue}}&quot;" />
{{else}}
<EmptyState
data-test-config-cta
@title="No roles created yet"
@message="Roles in Vault will allow you to manage LDAP credentials. Create a role to get started."
>
<LinkTo class="has-top-margin-xs" @route="roles.create">
Create role
</LinkTo>
</EmptyState>
{{/if}}
{{else}}
<div class="has-bottom-margin-s">
{{#each this.filteredRoles as |role|}}
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "roles.role.details" role.type role.name}} as |Item|>
<Item.content>
<Icon @name="user" />
<span data-test-role={{role.name}}>{{role.name}}</span>
<Hds::Badge @text={{role.type}} data-test-role-type-badge={{role.name}} />
</Item.content>
<Item.menu as |Menu|>
{{#if role.rolePath.isLoading}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-edit
@route="roles.role.edit"
@models={{array role.type role.name}}
@disabled={{not role.canEdit}}
>
Edit
</LinkTo>
</li>
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-get-creds
@route="roles.role.credentials"
@models={{array role.type role.name}}
@disabled={{not role.canReadCreds}}
>
Get credentials
</LinkTo>
</li>
{{#if role.canRotateStaticCreds}}
<li class="action">
<Menu.Message
data-test-rotate-creds
@id={{concat "rotate-" role.id}}
@triggerText="Rotate credentials"
@title="Are you sure?"
@message="When manually rotating credentials, the rotation period will start over."
@confirmButtonText="Rotate"
@onConfirm={{fn this.onRotate role}}
/>
</li>
{{/if}}
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-details
@route="roles.role.details"
{{! this will force the roles.role model hook to fire since we may only have a partial model loaded in the list view }}
@models={{array role.type role.name}}
@disabled={{not role.canRead}}
>
Details
</LinkTo>
</li>
{{#if role.canDelete}}
<li class="action">
<Menu.Message
data-test-delete
@id={{concat "delete-" role.id}}
@triggerText="Delete"
@title="Are you sure?"
@message="Deleting this role means that youll need to recreate it in order to generate credentials again."
@onConfirm={{fn this.onDelete role}}
/>
</li>
{{/if}}
{{/if}}
</Item.menu>
</ListItem>
{{/each}}
</div>
{{/if}}

View File

@ -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<LdapRoleModel>;
promptConfig: boolean;
backendModel: SecretEngineModel;
breadcrumbs: Array<Breadcrumb>;
}
export default class LdapRolesPageComponent extends Component<Args> {
@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)}`);
}
}
}

View File

@ -0,0 +1,31 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
<Icon @name={{@model.icon}} @size="24" class="has-text-grey-light" />
{{@model.id}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="ldap tabs">
<ul>
<LinkTo @route="overview" data-test-tab="overview">Overview</LinkTo>
<LinkTo @route="roles" data-test-tab="roles">Roles</LinkTo>
<LinkTo @route="libraries" data-test-tab="libraries">Libraries</LinkTo>
<LinkTo @route="configuration" data-test-tab="config">Configuration</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarFilters>
{{yield to="toolbarFilters"}}
</ToolbarFilters>
<ToolbarActions>
{{yield to="toolbarActions"}}
</ToolbarActions>
</Toolbar>

View File

@ -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);

View File

@ -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');
});

View File

@ -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<Breadcrumb>;
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 },
];
}
}

View File

@ -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<Breadcrumb>;
}
@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' },
];
}
}

View File

@ -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<Breadcrumb>;
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;
}
}

View File

@ -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<Breadcrumb>;
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' },
];
}
}

View File

@ -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<LdapLibraryModel>;
}
interface LdapLibrariesController extends Controller {
breadcrumbs: Array<Breadcrumb>;
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 },
];
}
}

View File

@ -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 });
}
}

View File

@ -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<Breadcrumb>;
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);
}
}

View File

@ -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<Breadcrumb>;
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 },
];
}
}

View File

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

View File

@ -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');
}
}

View File

@ -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<Breadcrumb>;
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' },
];
}
}

View File

@ -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');
}
}

View File

@ -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<Breadcrumb>;
}
interface LdapOverviewRouteModel {
backendModel: SecretEngineModel;
promptConfig: boolean;
roles: Array<LdapRoleModel>;
libraries: Array<LdapLibraryModel>;
librariesStatus: Array<LdapLibraryAccountStatus>;
}
@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<LdapLibraryModel>): Promise<Array<LdapLibraryAccountStatus>> {
const allStatuses: Array<LdapLibraryAccountStatus> = [];
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<LdapLibraryModel>),
});
}
setupController(
controller: LdapOverviewController,
resolvedModel: LdapOverviewRouteModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backendModel.id },
];
}
}

View File

@ -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<Breadcrumb>;
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' },
];
}
}

View File

@ -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<LdapRoleModel>;
}
interface LdapRolesController extends Controller {
breadcrumbs: Array<Breadcrumb>;
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 },
];
}
}

View File

@ -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 });
}
}

View File

@ -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<Breadcrumb>;
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<string>;
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' },
];
}
}

View File

@ -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<Breadcrumb>;
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 },
];
}
}

Some files were not shown because too many files have changed in this diff Show More