Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: LDAP Hierarchical Library names #29293

Merged
merged 13 commits into from
Jan 7, 2025
6 changes: 6 additions & 0 deletions changelog/29293.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
```release-note:improvement
ui: Adds navigation for LDAP hierarchical libraries
```
```release-note:bug
ui: Fixes navigation for quick actions in LDAP roles' popup menu
```
27 changes: 16 additions & 11 deletions ui/app/adapters/ldap/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,28 @@ import NamedPathAdapter from 'vault/adapters/named-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';

export default class LdapLibraryAdapter extends NamedPathAdapter {
getURL(backend, name) {
// path could be the library name (full path) or just part of the path i.e. west-account/
_getURL(backend, path) {
const base = `${this.buildURL()}/${encodePath(backend)}/library`;
return name ? `${base}/${name}` : base;
return path ? `${base}/${path}` : base;
}

urlForUpdateRecord(name, modelName, snapshot) {
return this.getURL(snapshot.attr('backend'), name);
// when editing the name IS the full path so we can use "name" instead of "completeLibraryName" here
return this._getURL(snapshot.attr('backend'), name);
}
urlForDeleteRecord(name, modelName, snapshot) {
return this.getURL(snapshot.attr('backend'), name);
const { backend, completeLibraryName } = snapshot.record;
return this._getURL(backend, completeLibraryName);
}

query(store, type, query) {
const { backend } = query;
return this.ajax(this.getURL(backend), 'GET', { data: { list: true } })
const { backend, path_to_library } = query;
// if we have a path_to_library then we're listing subdirectories at a hierarchical library path (i.e west-account/my-library)
const url = this._getURL(backend, path_to_library);
return this.ajax(url, 'GET', { data: { list: true } })
.then((resp) => {
return resp.data.keys.map((name) => ({ name, backend }));
return resp.data.keys.map((name) => ({ name, backend, path_to_library }));
})
.catch((error) => {
if (error.httpStatus === 404) {
Expand All @@ -34,11 +39,11 @@ export default class LdapLibraryAdapter extends NamedPathAdapter {
}
queryRecord(store, type, query) {
const { backend, name } = query;
return this.ajax(this.getURL(backend, name), 'GET').then((resp) => ({ ...resp.data, backend, name }));
return this.ajax(this._getURL(backend, name), 'GET').then((resp) => ({ ...resp.data, backend, name }));
}

fetchStatus(backend, name) {
const url = `${this.getURL(backend, name)}/status`;
const url = `${this._getURL(backend, name)}/status`;
return this.ajax(url, 'GET').then((resp) => {
const statuses = [];
for (const key in resp.data) {
Expand All @@ -53,15 +58,15 @@ export default class LdapLibraryAdapter extends NamedPathAdapter {
});
}
checkOutAccount(backend, name, ttl) {
const url = `${this.getURL(backend, name)}/check-out`;
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`;
const url = `${this._getURL(backend, name)}/check-in`;
return this.ajax(url, 'POST', { data: { service_account_names } }).then((resp) => resp.data);
}
}
4 changes: 2 additions & 2 deletions ui/app/adapters/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ export default class LdapRoleAdapter extends ApplicationAdapter {
}

urlForDeleteRecord(id, modelName, snapshot) {
const { backend, type, name } = snapshot.record;
return this._getURL(backend, this._pathForRoleType(type), name);
const { backend, type, completeRoleName } = snapshot.record;
return this._getURL(backend, this._pathForRoleType(type), completeRoleName);
}

/*
Expand Down
7 changes: 7 additions & 0 deletions ui/app/models/ldap/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const formFields = ['name', 'service_account_names', 'ttl', 'max_ttl', 'disable_
@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') path_to_library; // ancestral path to the library added in the adapter (only exists for nested libraries)

@attr('string', {
label: 'Library name',
Expand Down Expand Up @@ -64,6 +65,12 @@ export default class LdapLibraryModel extends Model {
})
disable_check_in_enforcement;

get completeLibraryName() {
// if there is a path_to_library then the name is hierarchical
// and we must concat the ancestors with the leaf name to get the full library path
return this.path_to_library ? this.path_to_library + this.name : this.name;
}

get displayFields() {
return this.formFields.filter((field) => field.name !== 'service_account_names');
}
Expand Down
12 changes: 10 additions & 2 deletions ui/app/models/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export default class LdapRoleModel extends Model {
})
rollback_ldif;

get completeRoleName() {
// if there is a path_to_role then the name is hierarchical
// and we must concat the ancestors with the leaf name to get the full role path
return this.path_to_role ? this.path_to_role + this.name : this.name;
}

get isStatic() {
return this.type === 'static';
}
Expand Down Expand Up @@ -224,9 +230,11 @@ export default class LdapRoleModel extends Model {
}

fetchCredentials() {
return this.store.adapterFor('ldap/role').fetchCredentials(this.backend, this.type, this.name);
return this.store
.adapterFor('ldap/role')
.fetchCredentials(this.backend, this.type, this.completeRoleName);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the bug fix when clicking the popup menu quick actions for an ldap role, such as "Get credentials"
Screenshot 2025-01-06 at 11 19 28 AM

}
rotateStaticPassword() {
return this.store.adapterFor('ldap/role').rotateStaticPassword(this.backend, this.name);
return this.store.adapterFor('ldap/role').rotateStaticPassword(this.backend, this.completeRoleName);
}
}
48 changes: 30 additions & 18 deletions ui/lib/ldap/addon/components/page/libraries.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
{{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|>
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{this.linkParams library}} as |Item|>
<Item.content>
<Icon @name="folder" />
<span data-test-library={{library.name}}>{{library.name}}</span>
<span data-test-library={{library.completeLibraryName}}>{{library.name}}</span>
</Item.content>
<Item.menu>
{{#if (or library.canRead library.canEdit library.canDelete)}}
Expand All @@ -55,24 +55,36 @@
@icon="more-horizontal"
@text="More options"
@hasChevron={{false}}
data-test-popup-menu-trigger
data-test-popup-menu-trigger={{library.completeLibraryName}}
/>
{{#if library.canEdit}}
<dd.Interactive data-test-edit @route="libraries.library.edit" @model={{library}}>Edit</dd.Interactive>
{{/if}}
{{#if library.canRead}}
<dd.Interactive
data-test-details
@route="libraries.library.details"
@model={{library}}
>Details</dd.Interactive>
{{/if}}
{{#if library.canDelete}}
{{#if (this.isHierarchical library.name)}}
<dd.Interactive
data-test-delete
@color="critical"
{{on "click" (fn (mut this.libraryToDelete) library)}}
>Delete</dd.Interactive>
data-test-subdirectory
@route="libraries.subdirectory"
@model={{library.completeLibraryName}}
>Content</dd.Interactive>
{{else}}
{{#if library.canEdit}}
<dd.Interactive
data-test-edit
@route="libraries.library.edit"
@model={{library.completeLibraryName}}
>Edit</dd.Interactive>
{{/if}}
{{#if library.canRead}}
<dd.Interactive
data-test-details
@route="libraries.library.details"
@model={{library.completeLibraryName}}
>Details</dd.Interactive>
{{/if}}
{{#if library.canDelete}}
<dd.Interactive
data-test-delete
@color="critical"
{{on "click" (fn (mut this.libraryToDelete) library)}}
>Delete</dd.Interactive>
{{/if}}
{{/if}}
</Hds::Dropdown>
{{/if}}
Expand Down
12 changes: 11 additions & 1 deletion ui/lib/ldap/addon/components/page/libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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';
import type RouterService from '@ember/routing/router-service';

interface Args {
libraries: Array<LdapLibraryModel>;
Expand All @@ -24,10 +25,18 @@ interface Args {

export default class LdapLibrariesPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;

@tracked filterValue = '';
@tracked libraryToDelete: LdapLibraryModel | null = null;

isHierarchical = (name: string) => name.endsWith('/');

linkParams = (library: LdapLibraryModel) => {
const route = this.isHierarchical(library.name) ? 'libraries.subdirectory' : 'libraries.library.details';
return [route, library.completeLibraryName];
};

get mountPoint(): string {
const owner = getOwner(this) as EngineOwner;
return owner.mountPoint;
Expand All @@ -43,8 +52,9 @@ export default class LdapLibrariesPageComponent extends Component<Args> {
@action
async onDelete(model: LdapLibraryModel) {
try {
const message = `Successfully deleted library ${model.name}.`;
const message = `Successfully deleted library ${model.completeLibraryName}.`;
await model.destroyRecord();
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries');
this.flashMessages.success(message);
} catch (error) {
this.flashMessages.danger(`Error deleting library \n ${errorMessage(error)}`);
Expand Down
10 changes: 7 additions & 3 deletions ui/lib/ldap/addon/components/page/roles.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
<dd.Interactive
data-test-subdirectory
@route="roles.subdirectory"
@models={{array role.type (concat role.path_to_role role.name)}}
@models={{array role.type role.completeRoleName}}
>Content</dd.Interactive>
{{else}}
{{#if role.canEdit}}
Expand All @@ -72,7 +72,11 @@
>Edit</dd.Interactive>
{{/if}}
{{#if role.canReadCreds}}
<dd.Interactive data-test-get-creds @route="roles.role.credentials" @models={{array role.type role.name}}>
<dd.Interactive
data-test-get-creds
@route="roles.role.credentials"
@models={{array role.type role.completeRoleName}}
>
Get credentials
</dd.Interactive>
{{/if}}
Expand All @@ -87,7 +91,7 @@
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}}
@models={{array role.type role.completeRoleName}}
>Details</dd.Interactive>
{{#if role.canDelete}}
<dd.Interactive
Expand Down
9 changes: 3 additions & 6 deletions ui/lib/ldap/addon/components/page/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ export default class LdapRolesPageComponent extends Component<Args> {

linkParams = (role: LdapRoleModel) => {
const route = this.isHierarchical(role.name) ? 'roles.subdirectory' : 'roles.role.details';
// if there is a path_to_role we're in a subdirectory
// and must concat the ancestors with the leaf name to get the full role path
const roleName = role.path_to_role ? role.path_to_role + role.name : role.name;
return [route, role.type, roleName];
return [route, role.type, role.completeRoleName];
};

get mountPoint(): string {
Expand All @@ -61,7 +58,7 @@ export default class LdapRolesPageComponent extends Component<Args> {
@action
async onRotate(model: LdapRoleModel) {
try {
const message = `Successfully rotated credentials for ${model.name}.`;
const message = `Successfully rotated credentials for ${model.completeRoleName}.`;
await model.rotateStaticPassword();
this.flashMessages.success(message);
} catch (error) {
Expand All @@ -74,7 +71,7 @@ export default class LdapRolesPageComponent extends Component<Args> {
@action
async onDelete(model: LdapRoleModel) {
try {
const message = `Successfully deleted role ${model.name}.`;
const message = `Successfully deleted role ${model.completeRoleName}.`;
await model.destroyRecord();
this.pagination.clearDataset('ldap/role');
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles');
Expand Down
2 changes: 2 additions & 0 deletions ui/lib/ldap/addon/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export default buildRoutes(function () {
});
this.route('libraries', function () {
this.route('create');
// wildcard route so we can traverse hierarchical libraries i.e. prod/admin/my-library
this.route('subdirectory', { path: '/subdirectory/*path_to_library' });
this.route('library', { path: '/:name' }, function () {
this.route('details', function () {
this.route('accounts');
Expand Down
7 changes: 5 additions & 2 deletions ui/lib/ldap/addon/routes/libraries/library/check-out.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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
import { ldapBreadcrumbs, libraryRoutes } from 'ldap/utils/ldap-breadcrumbs';

interface LdapLibraryCheckOutController extends Controller {
breadcrumbs: Array<Breadcrumb>;
Expand Down Expand Up @@ -45,12 +46,14 @@ export default class LdapLibraryCheckOutRoute extends Route {
transition: Transition
) {
super.setupController(controller, resolvedModel, transition);

const library = this.modelFor('libraries.library') as LdapLibraryModel;
const routeParams = (childResource: string) => {
return [library.backend, childResource];
};
controller.breadcrumbs = [
{ label: library.backend, route: 'overview' },
{ label: 'Libraries', route: 'libraries' },
{ label: library.name, route: 'libraries.library' },
...ldapBreadcrumbs(library.name, routeParams, libraryRoutes),
{ label: 'Check-Out' },
];
}
Expand Down
7 changes: 6 additions & 1 deletion ui/lib/ldap/addon/routes/libraries/library/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { ldapBreadcrumbs, libraryRoutes } from 'ldap/utils/ldap-breadcrumbs';

interface LdapLibraryDetailsController extends Controller {
breadcrumbs: Array<Breadcrumb>;
Expand All @@ -23,10 +24,14 @@ export default class LdapLibraryDetailsRoute extends Route {
) {
super.setupController(controller, resolvedModel, transition);

const routeParams = (childResource: string) => {
return [resolvedModel.backend, childResource];
};

controller.breadcrumbs = [
{ label: resolvedModel.backend, route: 'overview' },
{ label: 'Libraries', route: 'libraries' },
{ label: resolvedModel.name },
...ldapBreadcrumbs(resolvedModel.name, routeParams, libraryRoutes, true),
];
}
}
6 changes: 5 additions & 1 deletion ui/lib/ldap/addon/routes/libraries/library/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { ldapBreadcrumbs, libraryRoutes } from 'ldap/utils/ldap-breadcrumbs';

interface LdapLibraryEditController extends Controller {
breadcrumbs: Array<Breadcrumb>;
Expand All @@ -23,10 +24,13 @@ export default class LdapLibraryEditRoute extends Route {
) {
super.setupController(controller, resolvedModel, transition);

const routeParams = (childResource: string) => {
return [resolvedModel.backend, childResource];
};
controller.breadcrumbs = [
{ label: resolvedModel.backend, route: 'overview' },
{ label: 'Libraries', route: 'libraries' },
{ label: resolvedModel.name, route: 'libraries.library.details' },
...ldapBreadcrumbs(resolvedModel.name, routeParams, libraryRoutes),
{ label: 'Edit' },
];
}
Expand Down
Loading
Loading