UI: LDAP Hierarchical Library names (#29293)

* 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
This commit is contained in:
claire bontempo
2025-01-07 12:54:36 -06:00
committed by GitHub
parent 67663c85a3
commit 6e3ae793f5
29 changed files with 532 additions and 255 deletions

6
changelog/29293.txt Normal file
View File

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

View File

@@ -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) {
@@ -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) {
@@ -53,7 +58,7 @@ 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;
@@ -61,7 +66,7 @@ export default class LdapLibraryAdapter extends NamedPathAdapter {
});
}
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);
}
}

View File

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

View File

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

View File

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

View File

@@ -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)}}
@@ -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}}
{{#if (this.isHierarchical library.name)}}
<dd.Interactive
data-test-details
@route="libraries.library.details"
@model={{library}}
>Details</dd.Interactive>
{{/if}}
{{#if library.canDelete}}
<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}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
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';
import { ldapBreadcrumbs, libraryRoutes } from 'ldap/utils/ldap-breadcrumbs';
interface RouteModel {
backendModel: SecretEngineModel;
path_to_library: string;
libraries: Array<LdapLibraryModel>;
}
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: RouteModel;
}
interface RouteParams {
path_to_library?: string;
}
export default class LdapLibrariesSubdirectoryRoute extends Route {
@service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath;
model(params: RouteParams) {
const backendModel = this.modelFor('application') as SecretEngineModel;
const { path_to_library } = params;
return hash({
backendModel,
path_to_library,
libraries: this.store.query('ldap/library', { backend: backendModel.id, path_to_library }),
});
}
setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
const routeParams = (childResource: string) => {
return [resolvedModel.backendModel.id, childResource];
};
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backendModel.id, route: 'overview' },
{ label: 'Libraries', route: 'libraries' },
...ldapBreadcrumbs(resolvedModel.path_to_library, routeParams, libraryRoutes, true),
];
}
}

View File

@@ -5,7 +5,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import { ldapBreadcrumbs, roleRoutes } from 'ldap/utils/ldap-breadcrumbs';
import type Store from '@ember-data/store';
import type LdapRoleModel from 'vault/models/ldap/role';
@@ -58,11 +58,14 @@ export default class LdapRoleCredentialsRoute extends Route {
super.setupController(controller, resolvedModel, transition);
const role = this.modelFor('roles.role') as LdapRoleModel;
const routeParams = (childResource: string) => {
return [role.backend, role.type, childResource];
};
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: role.backend, route: 'overview' },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(role.name, role.type, role.backend),
...ldapBreadcrumbs(role.name, routeParams, roleRoutes),
{ label: 'Credentials' },
];
}

View File

@@ -5,7 +5,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import { ldapBreadcrumbs, roleRoutes } from 'ldap/utils/ldap-breadcrumbs';
import type { Breadcrumb } from 'vault/vault/app-types';
import type Controller from '@ember/controller';
@@ -24,11 +24,15 @@ export default class LdapRolesRoleDetailsRoute extends Route {
setupController(controller: RouteController, resolvedModel: LdapRoleModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
const routeParams = (childResource: string) => {
return [this.secretMountPath.currentPath, resolvedModel.type, childResource];
};
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview' },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(resolvedModel.name, resolvedModel.type, this.secretMountPath.currentPath, true),
...ldapBreadcrumbs(resolvedModel.name, routeParams, roleRoutes, true),
];
}
}

View File

@@ -4,7 +4,7 @@
*/
import Route from '@ember/routing/route';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import { ldapBreadcrumbs, roleRoutes } from 'ldap/utils/ldap-breadcrumbs';
import type LdapRoleModel from 'vault/models/ldap/role';
import type Controller from '@ember/controller';
@@ -20,11 +20,15 @@ export default class LdapRoleEditRoute extends Route {
setupController(controller: RouteController, resolvedModel: LdapRoleModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
const routeParams = (childResource: string) => {
return [resolvedModel.backend, resolvedModel.type, childResource];
};
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview' },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(resolvedModel.name, resolvedModel.type, resolvedModel.backend),
...ldapBreadcrumbs(resolvedModel.name, routeParams, roleRoutes),
{ label: 'Edit' },
];
}

View File

@@ -5,7 +5,7 @@
import LdapRolesRoute from '../roles';
import { hash } from 'rsvp';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import { ldapBreadcrumbs, roleRoutes } from 'ldap/utils/ldap-breadcrumbs';
import type { Breadcrumb } from 'vault/vault/app-types';
import type Controller from '@ember/controller';
@@ -55,11 +55,16 @@ export default class LdapRolesSubdirectoryRoute extends LdapRolesRoute {
setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
const { backendModel, roleAncestry } = resolvedModel;
const routeParams = (childResource: string) => {
return [backendModel.id, roleAncestry.type, childResource];
};
const crumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: backendModel.id, route: 'overview' },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(roleAncestry.path_to_role, roleAncestry.type, backendModel.id, true),
...ldapBreadcrumbs(roleAncestry.path_to_role, routeParams, roleRoutes, true),
];
// must call 'set' so breadcrumbs update as we navigate through directories

View File

@@ -0,0 +1,11 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Page::Libraries
@libraries={{this.model.libraries}}
@promptConfig={{false}}
@backendModel={{this.model.backendModel}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@@ -4,10 +4,16 @@
*/
import type { Breadcrumb } from 'vault/vault/app-types';
export const roleRoutes = { details: 'roles.role.details', subdirectory: 'roles.subdirectory' };
export const libraryRoutes = {
details: 'libraries.library.details',
subdirectory: 'libraries.subdirectory',
};
export const ldapBreadcrumbs = (
fullPath: string | undefined, // i.e. path/to/item
roleType: string,
mountPath: string,
routeParams: (childResource: string) => string[], // array of route param strings
routes: { details: string; subdirectory: string },
lastItemCurrent = false // this array of objects can be spread anywhere within the crumbs array
): Breadcrumb[] => {
if (!fullPath) return [];
@@ -26,11 +32,10 @@ export const ldapBreadcrumbs = (
const segment = ancestry.slice(0, idx + 1).join('/');
const itemPath = isLast && !isDirectory ? segment : `${segment}/`;
const routeParams = [mountPath, roleType, itemPath];
return {
label: name,
route: isLast && !isDirectory ? 'roles.role.details' : 'roles.subdirectory',
models: routeParams,
route: isLast && !isDirectory ? routes.details : routes.subdirectory,
models: routeParams(itemPath),
};
});
};

View File

@@ -46,14 +46,16 @@ export default function (server) {
};
const listOrGetRecord = (schema, req, type) => {
// if the param name is admin, we want to LIST admin/ roles
const dbKey = type ? 'ldapRoles' : 'ldapLibraries';
const query = type ? { type, name: `admin/child-${type}-role` } : { name: 'admin/test-library' };
if (req.queryParams.list) {
// passing a query with specific name is not flexible
// but we only seeded the mirage db with one hierarchical role for each type
return listRecords(schema, 'ldapRoles', { type, name: `admin/child-${type}-role` });
// the mirage database has setup all hierarchical names to be prefixed with "admin/"
// while passing a query with specific name is not flexible, for simplicity
// we only seeded the mirage db with one hierarchical resource for each role and a library
return listRecords(schema, dbKey, query);
}
// otherwise we want to view details for a specific role
return getRecord(schema, req, 'ldapRoles', type);
// otherwise we want to view details for a specific resource
return getRecord(schema, req, dbKey);
};
// config
@@ -77,9 +79,9 @@ export default function (server) {
}));
// libraries
server.post('/:backend/library/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapLibraries'));
server.get('/:backend/library/:name', (schema, req) => getRecord(schema, req, 'ldapLibraries'));
server.get('/:backend/library/*name', (schema, req) => listOrGetRecord(schema, req));
server.get('/:backend/library', (schema) => listRecords(schema, 'ldapLibraries'));
server.get('/:backend/library/:name/status', (schema) => {
server.get('/:backend/library/*name/status', (schema) => {
const data = schema.db['ldapAccountStatuses'].reduce((prev, curr) => {
prev[curr.account] = {
available: curr.available,

View File

@@ -14,6 +14,8 @@ export default function (server) {
server.create('ldap-role', 'static', { name: 'my-role' });
server.create('ldap-role', 'dynamic', { name: 'my-role' });
server.create('ldap-library', { name: 'test-library' });
// mirage handler is hardcoded to accommodate hierarchical paths starting with 'admin/'
server.create('ldap-library', { name: 'admin/test-library' });
server.create('ldap-account-status', {
id: 'bob.johnson',
account: 'bob.johnson',

View File

@@ -10,9 +10,10 @@ import { v4 as uuidv4 } from 'uuid';
import ldapMirageScenario from 'vault/mirage/scenarios/ldap';
import ldapHandlers from 'vault/mirage/handlers/ldap';
import authPage from 'vault/tests/pages/auth';
import { click } from '@ember/test-helpers';
import { click, currentURL } from '@ember/test-helpers';
import { isURL, visitURL } from 'vault/tests/helpers/ldap/ldap-helpers';
import { deleteEngineCmd, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands';
import { LDAP_SELECTORS } from 'vault/tests/helpers/ldap/ldap-selectors';
module('Acceptance | ldap | libraries', function (hooks) {
setupApplicationTest(hooks);
@@ -37,7 +38,7 @@ module('Acceptance | ldap | libraries', function (hooks) {
test('it should show libraries on overview page', async function (assert) {
await visitURL('overview', this.backend);
assert.dom('[data-test-libraries-count]').hasText('1');
assert.dom('[data-test-libraries-count]').hasText('2');
});
test('it should transition to create library route on toolbar link click', async function (assert) {
@@ -49,15 +50,34 @@ module('Acceptance | ldap | libraries', function (hooks) {
});
test('it should transition to library details route on list item click', async function (assert) {
await click('[data-test-list-item-link] a');
assert.true(
isURL('libraries/test-library/details/accounts', this.backend),
await click(LDAP_SELECTORS.libraryItem('test-library'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/ldap/libraries/test-library/details/accounts`,
'Transitions to library details accounts route on list item click'
);
assert.dom('[data-test-account-name]').exists({ count: 2 }, 'lists the accounts');
assert.dom('[data-test-checked-out-account]').exists({ count: 1 }, 'lists the checked out accounts');
});
test('it should transition to library details for hierarchical list items', async function (assert) {
await click(LDAP_SELECTORS.libraryItem('admin/'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/ldap/libraries/subdirectory/admin/`,
'Transitions to subdirectory list view'
);
await click(LDAP_SELECTORS.libraryItem('admin/test-library'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/ldap/libraries/admin%2Ftest-library/details/accounts`,
'Transitions to child library details accounts'
);
assert.dom('[data-test-account-name]').exists({ count: 2 }, 'lists the accounts');
assert.dom('[data-test-checked-out-account]').exists({ count: 1 }, 'lists the checked out accounts');
});
test('it should transition to routes from list item action menu', async function (assert) {
assert.expect(2);

View File

@@ -5,6 +5,8 @@
export const LDAP_SELECTORS = {
roleItem: (type: string, name: string) => `[data-test-role="${type} ${name}"]`,
libraryItem: (name: string) => `[data-test-library="${name}"]`,
roleMenu: (type: string, name: string) => `[data-test-popup-menu-trigger="${type} ${name}"]`,
libraryMenu: (name: string) => `[data-test-popup-menu-trigger="${name}"]`,
action: (action: string) => `[data-test-${action}]`,
};

View File

@@ -12,6 +12,7 @@ import hbs from 'htmlbars-inline-precompile';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap/ldap-helpers';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { LDAP_SELECTORS } from 'vault/tests/helpers/ldap/ldap-selectors';
module('Integration | Component | ldap | Page::Libraries', function (hooks) {
setupRenderingTest(hooks);
@@ -25,7 +26,7 @@ module('Integration | Component | ldap | Page::Libraries', function (hooks) {
this.backend = createSecretsEngine(this.store);
this.breadcrumbs = generateBreadcrumbs(this.backend.id);
for (const name of ['foo', 'bar']) {
for (const name of ['foo', 'bar', 'foo/']) {
this.store.pushPayload('ldap/library', {
modelName: 'ldap/library',
backend: 'ldap-test',
@@ -93,12 +94,19 @@ module('Integration | Component | ldap | Page::Libraries', function (hooks) {
await this.renderComponent();
assert.dom('[data-test-list-item-content] svg').hasClass('hds-icon-folder', 'List item icon renders');
assert.dom('[data-test-library]').hasText(this.libraries[0].name, 'List item name renders');
assert.dom('[data-test-library="foo"]').hasText('foo', 'List item name renders');
await click('[data-test-popup-menu-trigger]');
await click(LDAP_SELECTORS.libraryMenu('foo'));
assert.dom('[data-test-subdirectory]').doesNotExist();
assert.dom('[data-test-edit]').hasText('Edit', 'Edit link renders in menu');
assert.dom('[data-test-details]').hasText('Details', 'Details link renders in menu');
assert.dom('[data-test-delete]').hasText('Delete', 'Details link renders in menu');
await click(LDAP_SELECTORS.libraryMenu('foo/'));
assert.dom('[data-test-subdirectory]').hasText('Content', 'Content link renders in menu');
assert.dom('[data-test-edit]').doesNotExist();
assert.dom('[data-test-details]').doesNotExist();
assert.dom('[data-test-delete]').doesNotExist();
});
test('it should filter libraries', async function (assert) {
@@ -110,11 +118,11 @@ module('Integration | Component | ldap | Page::Libraries', function (hooks) {
.hasText('There are no libraries matching "baz"', 'Filter message renders');
await fillIn('[data-test-filter-input]', 'foo');
assert.dom('[data-test-list-item-content]').exists({ count: 1 }, 'List is filtered with correct results');
assert.dom('[data-test-list-item-content]').exists({ count: 2 }, 'List is filtered with correct results');
await fillIn('[data-test-filter-input]', '');
assert
.dom('[data-test-list-item-content]')
.exists({ count: 2 }, 'All libraries are displayed when filter is cleared');
.exists({ count: 3 }, 'All libraries are displayed when filter is cleared');
});
});

View File

@@ -19,8 +19,8 @@ module('Unit | Adapter | ldap/role', function (hooks) {
this.adapter = this.store.adapterFor('ldap/role');
this.path = 'role';
this.getModel = (type) => {
const name = 'test-role';
this.getModel = (type, roleName) => {
const name = roleName || 'test-role';
this.store.pushPayload('ldap/role', {
modelName: 'ldap/role',
backend: 'ldap-test',
@@ -32,214 +32,296 @@ module('Unit | Adapter | ldap/role', function (hooks) {
};
});
test('it should make request to correct endpoints when listing records', async function (assert) {
assert.expect(6);
module('happy paths', function () {
test('it should make request to correct endpoints when listing records', async function (assert) {
assert.expect(6);
const assertRequest = (schema, req) => {
assert.ok(req.queryParams.list, 'list query param sent when listing roles');
const name = req.url.includes('static-role') ? 'static-test' : 'dynamic-test';
return { data: { keys: [name] } };
};
const assertRequest = (schema, req) => {
assert.ok(req.queryParams.list, 'list query param sent when listing roles');
const name = req.url.includes('static-role') ? 'static-test' : 'dynamic-test';
return { data: { keys: [name] } };
};
this.server.get('/ldap-test/static-role', assertRequest);
this.server.get('/ldap-test/role', assertRequest);
this.server.get('/ldap-test/static-role', assertRequest);
this.server.get('/ldap-test/role', assertRequest);
this.models = await this.store.query('ldap/role', { backend: 'ldap-test' });
this.models = await this.store.query('ldap/role', { backend: 'ldap-test' });
const model = this.models[0];
assert.strictEqual(this.models.length, 2, 'Returns responses from both endpoints');
assert.strictEqual(model.backend, 'ldap-test', 'Backend value is set on records returned from query');
// sorted alphabetically by name so dynamic should be first
assert.strictEqual(model.type, 'dynamic', 'Type value is set on records returned from query');
assert.strictEqual(model.name, 'dynamic-test', 'Name value is set on records returned from query');
});
test('it should conditionally trigger info level flash message for single endpoint error from query', async function (assert) {
const flashMessages = this.owner.lookup('service:flashMessages');
const flashSpy = sinon.spy(flashMessages, 'info');
this.server.get('/ldap-test/static-role', () => {
return new Response(403, {}, { errors: ['permission denied'] });
});
this.server.get('/ldap-test/role', () => ({ data: { keys: ['dynamic-test'] } }));
await this.store.query('ldap/role', { backend: 'ldap-test' });
await this.store.query(
'ldap/role',
{ backend: 'ldap-test' },
{ adapterOptions: { showPartialError: true } }
);
assert.true(
flashSpy.calledOnceWith('Error fetching roles from /v1/ldap-test/static-role: permission denied'),
'Partial error info only displays when adapter option is passed'
);
});
test('it should throw error for query when requests to both endpoints fail', async function (assert) {
assert.expect(2);
this.server.get('/ldap-test/:path', (schema, req) => {
const errors = {
'static-role': ['permission denied'],
role: ['server error'],
}[req.params.path];
return new Response(req.params.path === 'static-role' ? 403 : 500, {}, { errors });
const model = this.models[0];
assert.strictEqual(this.models.length, 2, 'Returns responses from both endpoints');
assert.strictEqual(model.backend, 'ldap-test', 'Backend value is set on records returned from query');
// sorted alphabetically by name so dynamic should be first
assert.strictEqual(model.type, 'dynamic', 'Type value is set on records returned from query');
assert.strictEqual(model.name, 'dynamic-test', 'Name value is set on records returned from query');
});
try {
test('it should conditionally trigger info level flash message for single endpoint error from query', async function (assert) {
const flashMessages = this.owner.lookup('service:flashMessages');
const flashSpy = sinon.spy(flashMessages, 'info');
this.server.get('/ldap-test/static-role', () => {
return new Response(403, {}, { errors: ['permission denied'] });
});
this.server.get('/ldap-test/role', () => ({ data: { keys: ['dynamic-test'] } }));
await this.store.query('ldap/role', { backend: 'ldap-test' });
} catch (error) {
assert.deepEqual(
error.errors,
['/v1/ldap-test/static-role: permission denied', '/v1/ldap-test/role: server error'],
'Error messages is thrown with correct payload from query.'
await this.store.query(
'ldap/role',
{ backend: 'ldap-test' },
{ adapterOptions: { showPartialError: true } }
);
assert.strictEqual(
error.message,
'Error fetching roles:',
'Error message is thrown with correct payload from query.'
);
}
});
test('it should make request to correct endpoints when querying record', async function (assert) {
assert.expect(5);
this.server.get('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'GET request made to correct endpoint when querying record'
assert.true(
flashSpy.calledOnceWith('Error fetching roles from /v1/ldap-test/static-role: permission denied'),
'Partial error info only displays when adapter option is passed'
);
});
for (const type of ['dynamic', 'static']) {
this.model = await this.store.queryRecord('ldap/role', {
backend: 'ldap-test',
type,
name: 'test-role',
test('it should throw error for query when requests to both endpoints fail', async function (assert) {
assert.expect(2);
this.server.get('/ldap-test/:path', (schema, req) => {
const errors = {
'static-role': ['permission denied'],
role: ['server error'],
}[req.params.path];
return new Response(req.params.path === 'static-role' ? 403 : 500, {}, { errors });
});
this.path = 'static-role';
}
assert.strictEqual(
this.model.backend,
'ldap-test',
'Backend value is set on records returned from query'
);
assert.strictEqual(this.model.type, 'static', 'Type value is set on records returned from query');
assert.strictEqual(this.model.name, 'test-role', 'Name value is set on records returned from query');
});
test('it should make request to correct endpoints when creating new dynamic role record', async function (assert) {
assert.expect(1);
this.server.post('/ldap-test/:path/:name', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'POST request made to correct endpoint when creating new record for a dynamic role'
);
try {
await this.store.query('ldap/role', { backend: 'ldap-test' });
} catch (error) {
assert.deepEqual(
error.errors,
['/v1/ldap-test/static-role: permission denied', '/v1/ldap-test/role: server error'],
'Error messages is thrown with correct payload from query.'
);
assert.strictEqual(
error.message,
'Error fetching roles:',
'Error message is thrown with correct payload from query.'
);
}
});
const getModel = (type, name) => {
return this.store.createRecord('ldap/role', {
backend: 'ldap-test',
name,
type,
test('it should make request to correct endpoints when querying record', async function (assert) {
assert.expect(5);
this.server.get('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'GET request made to correct endpoint when querying record'
);
});
};
const model = getModel('dynamic-role', 'dynamic-role-name');
await model.save();
});
for (const type of ['dynamic', 'static']) {
this.model = await this.store.queryRecord('ldap/role', {
backend: 'ldap-test',
type,
name: 'test-role',
});
this.path = 'static-role';
}
test('it should make request to correct endpoints when creating new static role record', async function (assert) {
assert.expect(1);
this.server.post('/ldap-test/:path/:name', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'POST request made to correct endpoint when creating new record for a static role'
this.model.backend,
'ldap-test',
'Backend value is set on records returned from query'
);
assert.strictEqual(this.model.type, 'static', 'Type value is set on records returned from query');
assert.strictEqual(this.model.name, 'test-role', 'Name value is set on records returned from query');
});
const getModel = (type, name) => {
return this.store.createRecord('ldap/role', {
backend: 'ldap-test',
name,
type,
test('it should make request to correct endpoints when creating new dynamic role record', async function (assert) {
assert.expect(1);
this.server.post('/ldap-test/:path/:name', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'POST request made to correct endpoint when creating new record for a dynamic role'
);
});
};
const model = getModel('static-role', 'static-role-name');
await model.save();
const getModel = (type, name) => {
return this.store.createRecord('ldap/role', {
backend: 'ldap-test',
name,
type,
});
};
const model = getModel('dynamic-role', 'dynamic-role-name');
await model.save();
});
test('it should make request to correct endpoints when creating new static role record', async function (assert) {
assert.expect(1);
this.server.post('/ldap-test/:path/:name', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'POST request made to correct endpoint when creating new record for a static role'
);
});
const getModel = (type, name) => {
return this.store.createRecord('ldap/role', {
backend: 'ldap-test',
name,
type,
});
};
const model = getModel('static-role', 'static-role-name');
await model.save();
});
test('it should make request to correct endpoints when updating record', async function (assert) {
assert.expect(2);
this.server.post('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'POST request made to correct endpoint when updating record'
);
});
for (const type of ['dynamic', 'static']) {
const record = this.getModel(type);
await record.save();
this.path = 'static-role';
}
});
test('it should make request to correct endpoints when deleting record', async function (assert) {
assert.expect(2);
this.server.delete('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'DELETE request made to correct endpoint when deleting record'
);
});
for (const type of ['dynamic', 'static']) {
const record = this.getModel(type);
await record.destroyRecord();
this.path = 'static-role';
}
});
test('it should make request to correct endpoints when fetching credentials', async function (assert) {
assert.expect(2);
this.path = 'creds';
this.server.get('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'GET request made to correct endpoint when fetching credentials'
);
});
for (const type of ['dynamic', 'static']) {
await this.adapter.fetchCredentials('ldap-test', type, 'test-role');
this.path = 'static-cred';
}
});
test('it should make request to correct endpoint when rotating static role password', async function (assert) {
assert.expect(1);
this.server.post('/ldap-test/rotate-role/test-role', () => {
assert.ok('GET request made to correct endpoint when rotating static role password');
});
await this.adapter.rotateStaticPassword('ldap-test', 'test-role');
});
});
test('it should make request to correct endpoints when updating record', async function (assert) {
assert.expect(2);
module('hierarchical paths', function () {
test('it should make request to correct endpoint when listing hierarchical records', async function (assert) {
assert.expect(2);
this.server.post('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'POST request made to correct endpoint when updating record'
const staticAncestry = { path_to_role: 'static-admin/', type: 'static' };
const dynamicAncestry = { path_to_role: 'dynamic-admin/', type: 'dynamic' };
this.server.get(`/ldap-test/static-role/${staticAncestry.path_to_role}`, (schema, req) => {
assert.strictEqual(
req.queryParams.list,
'true',
`query request lists roles of type: ${staticAncestry.type}`
);
return { data: { keys: ['my-static-role'] } };
});
this.server.get(`/ldap-test/role/${dynamicAncestry.path_to_role}`, (schema, req) => {
assert.strictEqual(
req.queryParams.list,
'true',
`query request lists roles of type: ${dynamicAncestry.type}`
);
return { data: { keys: ['my-dynamic-role'] } };
});
await this.store.query(
'ldap/role',
{ backend: 'ldap-test' },
{ adapterOptions: { roleAncestry: staticAncestry } }
);
await this.store.query(
'ldap/role',
{ backend: 'ldap-test' },
{ adapterOptions: { roleAncestry: dynamicAncestry } }
);
});
for (const type of ['dynamic', 'static']) {
const record = this.getModel(type);
await record.save();
this.path = 'static-role';
test(`it should make request to correct endpoint when deleting a role for type: ${type}`, async function (assert) {
assert.expect(1);
const url =
type === 'static'
? '/ldap-test/static-role/admin/my-static-role'
: '/ldap-test/role/admin/my-dynamic-role';
this.server.delete(url, () => {
assert.true(true, `DELETE request made to delete hierarchical role of type: ${type}`);
});
const record = this.getModel(type, `admin/my-${type}-role`);
await record.destroyRecord();
});
test(`it should make request to correct endpoints when fetching credentials for type: ${type}`, async function (assert) {
assert.expect(1);
const url =
type === 'static'
? '/ldap-test/static-cred/admin/my-static-role'
: '/ldap-test/creds/admin/my-dynamic-role';
this.server.get(url, () => {
assert.true(true, `request made to fetch credentials for role type: ${type}`);
});
await this.adapter.fetchCredentials('ldap-test', type, `admin/my-${type}-role`);
});
}
});
test('it should make request to correct endpoints when deleting record', async function (assert) {
assert.expect(2);
test('it should make request to correct endpoint when rotating static role password', async function (assert) {
assert.expect(1);
this.server.delete('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'DELETE request made to correct endpoint when deleting record'
);
this.server.post('/ldap-test/rotate-role/admin/test-role', () => {
assert.ok('GET request made to correct endpoint when rotating static role password');
});
await this.adapter.rotateStaticPassword('ldap-test', 'admin/test-role');
});
for (const type of ['dynamic', 'static']) {
const record = this.getModel(type);
await record.destroyRecord();
this.path = 'static-role';
}
});
test('it should make request to correct endpoints when fetching credentials', async function (assert) {
assert.expect(2);
this.path = 'creds';
this.server.get('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'GET request made to correct endpoint when fetching credentials'
);
});
for (const type of ['dynamic', 'static']) {
await this.adapter.fetchCredentials('ldap-test', type, 'test-role');
this.path = 'static-cred';
}
});
test('it should make request to correct endpoint when rotating static role password', async function (assert) {
assert.expect(1);
this.server.post('/ldap-test/rotate-role/test-role', () => {
assert.ok('GET request made to correct endpoint when rotating static role password');
});
await this.adapter.rotateStaticPassword('ldap-test', 'test-role');
});
});

View File

@@ -3,15 +3,18 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import { ldapBreadcrumbs, roleRoutes } from 'ldap/utils/ldap-breadcrumbs';
import { module, test } from 'qunit';
module('Unit | Utility | ldap breadcrumbs', function (hooks) {
hooks.beforeEach(async function () {
this.mountPath = 'my-engine';
this.roleType = 'static';
const routeParams = (childResource) => {
return [this.mountPath, this.roleType, childResource];
};
this.testCrumbs = (path, { lastItemCurrent }) => {
return ldapBreadcrumbs(path, this.roleType, this.mountPath, lastItemCurrent);
return ldapBreadcrumbs(path, routeParams, roleRoutes, lastItemCurrent);
};
});

View File

@@ -13,10 +13,12 @@ import type {
export default interface LdapLibraryModel extends WithFormFieldsAndValidationsModel {
backend: string;
name: string;
path_to_library: string;
service_account_names: string;
default_ttl: number;
max_ttl: number;
disable_check_in_enforcement: string;
get completeLibraryName(): string;
get displayFields(): Array<FormField>;
libraryPath: CapabilitiesModel;
statusPath: CapabilitiesModel;

View File

@@ -20,6 +20,7 @@ export default interface LdapRoleModel extends WithFormFieldsAndValidationsModel
username_template: string;
creation_ldif: string;
rollback_ldif: string;
get completeRoleName(): string;
get isStatic(): string;
get isDynamic(): string;
get fieldsForType(): Array<string>;