Skip to content

Commit

Permalink
UI: LDAP Hierarchical Library names (#29293)
Browse files Browse the repository at this point in the history
* refactor crumbs

* add subdirectory library route and hierarchical nav

* update library breadcrumbs;

* fix role popup menus

* add getter to library model for full path

* cleanup model getters

* add changelog

* add bug fix note

* add transition after deleting

* fix function definition

* update adapter test

* add test coverage

* fix crumb typo
  • Loading branch information
hellobontempo authored Jan 7, 2025
1 parent 67663c8 commit 6e3ae79
Show file tree
Hide file tree
Showing 29 changed files with 518 additions and 241 deletions.
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);
}
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

0 comments on commit 6e3ae79

Please sign in to comment.