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'; import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class LdapLibraryAdapter extends NamedPathAdapter { 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`; const base = `${this.buildURL()}/${encodePath(backend)}/library`;
return name ? `${base}/${name}` : base; return path ? `${base}/${path}` : base;
} }
urlForUpdateRecord(name, modelName, snapshot) { 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) { 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) { query(store, type, query) {
const { backend } = query; const { backend, path_to_library } = query;
return this.ajax(this.getURL(backend), 'GET', { data: { list: true } }) // 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) => { .then((resp) => {
return resp.data.keys.map((name) => ({ name, backend })); return resp.data.keys.map((name) => ({ name, backend, path_to_library }));
}) })
.catch((error) => { .catch((error) => {
if (error.httpStatus === 404) { if (error.httpStatus === 404) {
@@ -34,11 +39,11 @@ export default class LdapLibraryAdapter extends NamedPathAdapter {
} }
queryRecord(store, type, query) { queryRecord(store, type, query) {
const { backend, name } = 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) { fetchStatus(backend, name) {
const url = `${this.getURL(backend, name)}/status`; const url = `${this._getURL(backend, name)}/status`;
return this.ajax(url, 'GET').then((resp) => { return this.ajax(url, 'GET').then((resp) => {
const statuses = []; const statuses = [];
for (const key in resp.data) { for (const key in resp.data) {
@@ -53,7 +58,7 @@ export default class LdapLibraryAdapter extends NamedPathAdapter {
}); });
} }
checkOutAccount(backend, name, ttl) { 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) => { return this.ajax(url, 'POST', { data: { ttl } }).then((resp) => {
const { lease_id, lease_duration, renewable } = resp; const { lease_id, lease_duration, renewable } = resp;
const { service_account_name: account, password } = resp.data; const { service_account_name: account, password } = resp.data;
@@ -61,7 +66,7 @@ export default class LdapLibraryAdapter extends NamedPathAdapter {
}); });
} }
checkInAccount(backend, name, service_account_names) { 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); 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) { urlForDeleteRecord(id, modelName, snapshot) {
const { backend, type, name } = snapshot.record; const { backend, type, completeRoleName } = snapshot.record;
return this._getURL(backend, this._pathForRoleType(type), name); 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) @withFormFields(formFields)
export default class LdapLibraryModel extends Model { export default class LdapLibraryModel extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord @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', { @attr('string', {
label: 'Library name', label: 'Library name',
@@ -64,6 +65,12 @@ export default class LdapLibraryModel extends Model {
}) })
disable_check_in_enforcement; 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() { get displayFields() {
return this.formFields.filter((field) => field.name !== 'service_account_names'); return this.formFields.filter((field) => field.name !== 'service_account_names');
} }

View File

@@ -163,6 +163,12 @@ export default class LdapRoleModel extends Model {
}) })
rollback_ldif; 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() { get isStatic() {
return this.type === 'static'; return this.type === 'static';
} }
@@ -224,9 +230,11 @@ export default class LdapRoleModel extends Model {
} }
fetchCredentials() { 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() { 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}} {{else}}
<div class="has-bottom-margin-s"> <div class="has-bottom-margin-s">
{{#each this.filteredLibraries as |library|}} {{#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> <Item.content>
<Icon @name="folder" /> <Icon @name="folder" />
<span data-test-library={{library.name}}>{{library.name}}</span> <span data-test-library={{library.completeLibraryName}}>{{library.name}}</span>
</Item.content> </Item.content>
<Item.menu> <Item.menu>
{{#if (or library.canRead library.canEdit library.canDelete)}} {{#if (or library.canRead library.canEdit library.canDelete)}}
@@ -55,24 +55,36 @@
@icon="more-horizontal" @icon="more-horizontal"
@text="More options" @text="More options"
@hasChevron={{false}} @hasChevron={{false}}
data-test-popup-menu-trigger data-test-popup-menu-trigger={{library.completeLibraryName}}
/> />
{{#if library.canEdit}} {{#if (this.isHierarchical library.name)}}
<dd.Interactive data-test-edit @route="libraries.library.edit" @model={{library}}>Edit</dd.Interactive>
{{/if}}
{{#if library.canRead}}
<dd.Interactive <dd.Interactive
data-test-details data-test-subdirectory
@route="libraries.library.details" @route="libraries.subdirectory"
@model={{library}} @model={{library.completeLibraryName}}
>Details</dd.Interactive> >Content</dd.Interactive>
{{/if}} {{else}}
{{#if library.canDelete}} {{#if library.canEdit}}
<dd.Interactive <dd.Interactive
data-test-delete data-test-edit
@color="critical" @route="libraries.library.edit"
{{on "click" (fn (mut this.libraryToDelete) library)}} @model={{library.completeLibraryName}}
>Delete</dd.Interactive> >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}} {{/if}}
</Hds::Dropdown> </Hds::Dropdown>
{{/if}} {{/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 SecretEngineModel from 'vault/models/secret-engine';
import type FlashMessageService from 'vault/services/flash-messages'; import type FlashMessageService from 'vault/services/flash-messages';
import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types'; import type { Breadcrumb, EngineOwner } from 'vault/vault/app-types';
import type RouterService from '@ember/routing/router-service';
interface Args { interface Args {
libraries: Array<LdapLibraryModel>; libraries: Array<LdapLibraryModel>;
@@ -24,10 +25,18 @@ interface Args {
export default class LdapLibrariesPageComponent extends Component<Args> { export default class LdapLibrariesPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService; @service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@tracked filterValue = ''; @tracked filterValue = '';
@tracked libraryToDelete: LdapLibraryModel | null = null; @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 { get mountPoint(): string {
const owner = getOwner(this) as EngineOwner; const owner = getOwner(this) as EngineOwner;
return owner.mountPoint; return owner.mountPoint;
@@ -43,8 +52,9 @@ export default class LdapLibrariesPageComponent extends Component<Args> {
@action @action
async onDelete(model: LdapLibraryModel) { async onDelete(model: LdapLibraryModel) {
try { try {
const message = `Successfully deleted library ${model.name}.`; const message = `Successfully deleted library ${model.completeLibraryName}.`;
await model.destroyRecord(); await model.destroyRecord();
this.router.transitionTo('vault.cluster.secrets.backend.ldap.libraries');
this.flashMessages.success(message); this.flashMessages.success(message);
} catch (error) { } catch (error) {
this.flashMessages.danger(`Error deleting library \n ${errorMessage(error)}`); this.flashMessages.danger(`Error deleting library \n ${errorMessage(error)}`);

View File

@@ -61,7 +61,7 @@
<dd.Interactive <dd.Interactive
data-test-subdirectory data-test-subdirectory
@route="roles.subdirectory" @route="roles.subdirectory"
@models={{array role.type (concat role.path_to_role role.name)}} @models={{array role.type role.completeRoleName}}
>Content</dd.Interactive> >Content</dd.Interactive>
{{else}} {{else}}
{{#if role.canEdit}} {{#if role.canEdit}}
@@ -72,7 +72,11 @@
>Edit</dd.Interactive> >Edit</dd.Interactive>
{{/if}} {{/if}}
{{#if role.canReadCreds}} {{#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 Get credentials
</dd.Interactive> </dd.Interactive>
{{/if}} {{/if}}
@@ -87,7 +91,7 @@
data-test-details data-test-details
@route="roles.role.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 }} {{! 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> >Details</dd.Interactive>
{{#if role.canDelete}} {{#if role.canDelete}}
<dd.Interactive <dd.Interactive

View File

@@ -37,10 +37,7 @@ export default class LdapRolesPageComponent extends Component<Args> {
linkParams = (role: LdapRoleModel) => { linkParams = (role: LdapRoleModel) => {
const route = this.isHierarchical(role.name) ? 'roles.subdirectory' : 'roles.role.details'; const route = this.isHierarchical(role.name) ? 'roles.subdirectory' : 'roles.role.details';
// if there is a path_to_role we're in a subdirectory return [route, role.type, role.completeRoleName];
// 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];
}; };
get mountPoint(): string { get mountPoint(): string {
@@ -61,7 +58,7 @@ export default class LdapRolesPageComponent extends Component<Args> {
@action @action
async onRotate(model: LdapRoleModel) { async onRotate(model: LdapRoleModel) {
try { try {
const message = `Successfully rotated credentials for ${model.name}.`; const message = `Successfully rotated credentials for ${model.completeRoleName}.`;
await model.rotateStaticPassword(); await model.rotateStaticPassword();
this.flashMessages.success(message); this.flashMessages.success(message);
} catch (error) { } catch (error) {
@@ -74,7 +71,7 @@ export default class LdapRolesPageComponent extends Component<Args> {
@action @action
async onDelete(model: LdapRoleModel) { async onDelete(model: LdapRoleModel) {
try { try {
const message = `Successfully deleted role ${model.name}.`; const message = `Successfully deleted role ${model.completeRoleName}.`;
await model.destroyRecord(); await model.destroyRecord();
this.pagination.clearDataset('ldap/role'); this.pagination.clearDataset('ldap/role');
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles'); 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('libraries', function () {
this.route('create'); 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('library', { path: '/:name' }, function () {
this.route('details', function () { this.route('details', function () {
this.route('accounts'); 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 type { Breadcrumb } from 'vault/vault/app-types';
import { LdapLibraryCheckOutCredentials } from 'vault/vault/adapters/ldap/library'; 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 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 { interface LdapLibraryCheckOutController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
@@ -45,12 +46,14 @@ export default class LdapLibraryCheckOutRoute extends Route {
transition: Transition transition: Transition
) { ) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
const library = this.modelFor('libraries.library') as LdapLibraryModel; const library = this.modelFor('libraries.library') as LdapLibraryModel;
const routeParams = (childResource: string) => {
return [library.backend, childResource];
};
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: library.backend, route: 'overview' }, { label: library.backend, route: 'overview' },
{ label: 'Libraries', route: 'libraries' }, { label: 'Libraries', route: 'libraries' },
{ label: library.name, route: 'libraries.library' }, ...ldapBreadcrumbs(library.name, routeParams, libraryRoutes),
{ label: 'Check-Out' }, { 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 Controller from '@ember/controller';
import type Transition from '@ember/routing/transition'; import type Transition from '@ember/routing/transition';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
import { ldapBreadcrumbs, libraryRoutes } from 'ldap/utils/ldap-breadcrumbs';
interface LdapLibraryDetailsController extends Controller { interface LdapLibraryDetailsController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
@@ -23,10 +24,14 @@ export default class LdapLibraryDetailsRoute extends Route {
) { ) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
const routeParams = (childResource: string) => {
return [resolvedModel.backend, childResource];
};
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: resolvedModel.backend, route: 'overview' }, { label: resolvedModel.backend, route: 'overview' },
{ label: 'Libraries', route: 'libraries' }, { 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 Controller from '@ember/controller';
import type Transition from '@ember/routing/transition'; import type Transition from '@ember/routing/transition';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
import { ldapBreadcrumbs, libraryRoutes } from 'ldap/utils/ldap-breadcrumbs';
interface LdapLibraryEditController extends Controller { interface LdapLibraryEditController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
@@ -23,10 +24,13 @@ export default class LdapLibraryEditRoute extends Route {
) { ) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
const routeParams = (childResource: string) => {
return [resolvedModel.backend, childResource];
};
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: resolvedModel.backend, route: 'overview' }, { label: resolvedModel.backend, route: 'overview' },
{ label: 'Libraries', route: 'libraries' }, { label: 'Libraries', route: 'libraries' },
{ label: resolvedModel.name, route: 'libraries.library.details' }, ...ldapBreadcrumbs(resolvedModel.name, routeParams, libraryRoutes),
{ label: 'Edit' }, { 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 Route from '@ember/routing/route';
import { service } from '@ember/service'; 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 Store from '@ember-data/store';
import type LdapRoleModel from 'vault/models/ldap/role'; import type LdapRoleModel from 'vault/models/ldap/role';
@@ -58,11 +58,14 @@ export default class LdapRoleCredentialsRoute extends Route {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
const role = this.modelFor('roles.role') as LdapRoleModel; const role = this.modelFor('roles.role') as LdapRoleModel;
const routeParams = (childResource: string) => {
return [role.backend, role.type, childResource];
};
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true }, { label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: role.backend, route: 'overview' }, { label: role.backend, route: 'overview' },
{ label: 'Roles', route: 'roles' }, { label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(role.name, role.type, role.backend), ...ldapBreadcrumbs(role.name, routeParams, roleRoutes),
{ label: 'Credentials' }, { label: 'Credentials' },
]; ];
} }

View File

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

View File

@@ -5,7 +5,7 @@
import LdapRolesRoute from '../roles'; import LdapRolesRoute from '../roles';
import { hash } from 'rsvp'; 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 { Breadcrumb } from 'vault/vault/app-types';
import type Controller from '@ember/controller'; import type Controller from '@ember/controller';
@@ -55,11 +55,16 @@ export default class LdapRolesSubdirectoryRoute extends LdapRolesRoute {
setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) { setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
const { backendModel, roleAncestry } = resolvedModel; const { backendModel, roleAncestry } = resolvedModel;
const routeParams = (childResource: string) => {
return [backendModel.id, roleAncestry.type, childResource];
};
const crumbs = [ const crumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true }, { label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: backendModel.id, route: 'overview' }, { label: backendModel.id, route: 'overview' },
{ label: 'Roles', route: 'roles' }, { 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 // 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'; 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 = ( export const ldapBreadcrumbs = (
fullPath: string | undefined, // i.e. path/to/item fullPath: string | undefined, // i.e. path/to/item
roleType: string, routeParams: (childResource: string) => string[], // array of route param strings
mountPath: string, routes: { details: string; subdirectory: string },
lastItemCurrent = false // this array of objects can be spread anywhere within the crumbs array lastItemCurrent = false // this array of objects can be spread anywhere within the crumbs array
): Breadcrumb[] => { ): Breadcrumb[] => {
if (!fullPath) return []; if (!fullPath) return [];
@@ -26,11 +32,10 @@ export const ldapBreadcrumbs = (
const segment = ancestry.slice(0, idx + 1).join('/'); const segment = ancestry.slice(0, idx + 1).join('/');
const itemPath = isLast && !isDirectory ? segment : `${segment}/`; const itemPath = isLast && !isDirectory ? segment : `${segment}/`;
const routeParams = [mountPath, roleType, itemPath];
return { return {
label: name, label: name,
route: isLast && !isDirectory ? 'roles.role.details' : 'roles.subdirectory', route: isLast && !isDirectory ? routes.details : routes.subdirectory,
models: routeParams, models: routeParams(itemPath),
}; };
}); });
}; };

View File

@@ -46,14 +46,16 @@ export default function (server) {
}; };
const listOrGetRecord = (schema, req, type) => { 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) { if (req.queryParams.list) {
// passing a query with specific name is not flexible // the mirage database has setup all hierarchical names to be prefixed with "admin/"
// but we only seeded the mirage db with one hierarchical role for each type // while passing a query with specific name is not flexible, for simplicity
return listRecords(schema, 'ldapRoles', { type, name: `admin/child-${type}-role` }); // 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 // otherwise we want to view details for a specific resource
return getRecord(schema, req, 'ldapRoles', type); return getRecord(schema, req, dbKey);
}; };
// config // config
@@ -77,9 +79,9 @@ export default function (server) {
})); }));
// libraries // libraries
server.post('/:backend/library/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapLibraries')); 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', (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) => { const data = schema.db['ldapAccountStatuses'].reduce((prev, curr) => {
prev[curr.account] = { prev[curr.account] = {
available: curr.available, 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', 'static', { name: 'my-role' });
server.create('ldap-role', 'dynamic', { name: 'my-role' }); server.create('ldap-role', 'dynamic', { name: 'my-role' });
server.create('ldap-library', { name: 'test-library' }); 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', { server.create('ldap-account-status', {
id: 'bob.johnson', id: 'bob.johnson',
account: '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 ldapMirageScenario from 'vault/mirage/scenarios/ldap';
import ldapHandlers from 'vault/mirage/handlers/ldap'; import ldapHandlers from 'vault/mirage/handlers/ldap';
import authPage from 'vault/tests/pages/auth'; 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 { isURL, visitURL } from 'vault/tests/helpers/ldap/ldap-helpers';
import { deleteEngineCmd, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands'; 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) { module('Acceptance | ldap | libraries', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
@@ -37,7 +38,7 @@ module('Acceptance | ldap | libraries', function (hooks) {
test('it should show libraries on overview page', async function (assert) { test('it should show libraries on overview page', async function (assert) {
await visitURL('overview', this.backend); 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) { 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) { test('it should transition to library details route on list item click', async function (assert) {
await click('[data-test-list-item-link] a'); await click(LDAP_SELECTORS.libraryItem('test-library'));
assert.true( assert.strictEqual(
isURL('libraries/test-library/details/accounts', this.backend), currentURL(),
`/vault/secrets/${this.backend}/ldap/libraries/test-library/details/accounts`,
'Transitions to library details accounts route on list item click' '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-account-name]').exists({ count: 2 }, 'lists the accounts');
assert.dom('[data-test-checked-out-account]').exists({ count: 1 }, 'lists the checked out 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) { test('it should transition to routes from list item action menu', async function (assert) {
assert.expect(2); assert.expect(2);

View File

@@ -5,6 +5,8 @@
export const LDAP_SELECTORS = { export const LDAP_SELECTORS = {
roleItem: (type: string, name: string) => `[data-test-role="${type} ${name}"]`, 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}"]`, 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}]`, 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 { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap/ldap-helpers'; import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap/ldap-helpers';
import { setRunOptions } from 'ember-a11y-testing/test-support'; 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) { module('Integration | Component | ldap | Page::Libraries', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@@ -25,7 +26,7 @@ module('Integration | Component | ldap | Page::Libraries', function (hooks) {
this.backend = createSecretsEngine(this.store); this.backend = createSecretsEngine(this.store);
this.breadcrumbs = generateBreadcrumbs(this.backend.id); this.breadcrumbs = generateBreadcrumbs(this.backend.id);
for (const name of ['foo', 'bar']) { for (const name of ['foo', 'bar', 'foo/']) {
this.store.pushPayload('ldap/library', { this.store.pushPayload('ldap/library', {
modelName: 'ldap/library', modelName: 'ldap/library',
backend: 'ldap-test', backend: 'ldap-test',
@@ -93,12 +94,19 @@ module('Integration | Component | ldap | Page::Libraries', function (hooks) {
await this.renderComponent(); await this.renderComponent();
assert.dom('[data-test-list-item-content] svg').hasClass('hds-icon-folder', 'List item icon renders'); 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-edit]').hasText('Edit', 'Edit link renders in menu');
assert.dom('[data-test-details]').hasText('Details', 'Details 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'); 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) { 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'); .hasText('There are no libraries matching "baz"', 'Filter message renders');
await fillIn('[data-test-filter-input]', 'foo'); 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]', ''); await fillIn('[data-test-filter-input]', '');
assert assert
.dom('[data-test-list-item-content]') .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.adapter = this.store.adapterFor('ldap/role');
this.path = 'role'; this.path = 'role';
this.getModel = (type) => { this.getModel = (type, roleName) => {
const name = 'test-role'; const name = roleName || 'test-role';
this.store.pushPayload('ldap/role', { this.store.pushPayload('ldap/role', {
modelName: 'ldap/role', modelName: 'ldap/role',
backend: 'ldap-test', 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) { module('happy paths', function () {
assert.expect(6); test('it should make request to correct endpoints when listing records', async function (assert) {
assert.expect(6);
const assertRequest = (schema, req) => { const assertRequest = (schema, req) => {
assert.ok(req.queryParams.list, 'list query param sent when listing roles'); assert.ok(req.queryParams.list, 'list query param sent when listing roles');
const name = req.url.includes('static-role') ? 'static-test' : 'dynamic-test'; const name = req.url.includes('static-role') ? 'static-test' : 'dynamic-test';
return { data: { keys: [name] } }; return { data: { keys: [name] } };
}; };
this.server.get('/ldap-test/static-role', assertRequest); this.server.get('/ldap-test/static-role', assertRequest);
this.server.get('/ldap-test/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]; const model = this.models[0];
assert.strictEqual(this.models.length, 2, 'Returns responses from both endpoints'); 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'); assert.strictEqual(model.backend, 'ldap-test', 'Backend value is set on records returned from query');
// sorted alphabetically by name so dynamic should be first // 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.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'); 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 });
}); });
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' }); await this.store.query('ldap/role', { backend: 'ldap-test' });
} catch (error) { await this.store.query(
assert.deepEqual( 'ldap/role',
error.errors, { backend: 'ldap-test' },
['/v1/ldap-test/static-role: permission denied', '/v1/ldap-test/role: server error'], { adapterOptions: { showPartialError: true } }
'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.'
);
}
});
test('it should make request to correct endpoints when querying record', async function (assert) { assert.true(
assert.expect(5); flashSpy.calledOnceWith('Error fetching roles from /v1/ldap-test/static-role: permission denied'),
'Partial error info only displays when adapter option is passed'
this.server.get('/ldap-test/:path/test-role', (schema, req) => {
assert.strictEqual(
req.params.path,
this.path,
'GET request made to correct endpoint when querying record'
); );
}); });
for (const type of ['dynamic', 'static']) { test('it should throw error for query when requests to both endpoints fail', async function (assert) {
this.model = await this.store.queryRecord('ldap/role', { assert.expect(2);
backend: 'ldap-test',
type, this.server.get('/ldap-test/:path', (schema, req) => {
name: 'test-role', 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( try {
this.model.backend, await this.store.query('ldap/role', { backend: 'ldap-test' });
'ldap-test', } catch (error) {
'Backend value is set on records returned from query' assert.deepEqual(
); error.errors,
assert.strictEqual(this.model.type, 'static', 'Type value is set on records returned from query'); ['/v1/ldap-test/static-role: permission denied', '/v1/ldap-test/role: server error'],
assert.strictEqual(this.model.name, 'test-role', 'Name value is set on records returned from query'); 'Error messages is thrown with correct payload from query.'
}); );
assert.strictEqual(
test('it should make request to correct endpoints when creating new dynamic role record', async function (assert) { error.message,
assert.expect(1); 'Error fetching roles:',
'Error message is thrown with correct payload from query.'
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 getModel = (type, name) => { test('it should make request to correct endpoints when querying record', async function (assert) {
return this.store.createRecord('ldap/role', { assert.expect(5);
backend: 'ldap-test',
name, this.server.get('/ldap-test/:path/test-role', (schema, req) => {
type, assert.strictEqual(
req.params.path,
this.path,
'GET request made to correct endpoint when querying record'
);
}); });
};
const model = getModel('dynamic-role', 'dynamic-role-name'); for (const type of ['dynamic', 'static']) {
await model.save(); 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( assert.strictEqual(
req.params.path, this.model.backend,
this.path, 'ldap-test',
'POST request made to correct endpoint when creating new record for a static role' '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) => { test('it should make request to correct endpoints when creating new dynamic role record', async function (assert) {
return this.store.createRecord('ldap/role', { assert.expect(1);
backend: 'ldap-test',
name, this.server.post('/ldap-test/:path/:name', (schema, req) => {
type, 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'); const getModel = (type, name) => {
await model.save(); 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) { module('hierarchical paths', function () {
assert.expect(2); 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) => { const staticAncestry = { path_to_role: 'static-admin/', type: 'static' };
assert.strictEqual( const dynamicAncestry = { path_to_role: 'dynamic-admin/', type: 'dynamic' };
req.params.path,
this.path, this.server.get(`/ldap-test/static-role/${staticAncestry.path_to_role}`, (schema, req) => {
'POST request made to correct endpoint when updating record' 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']) { for (const type of ['dynamic', 'static']) {
const record = this.getModel(type); test(`it should make request to correct endpoint when deleting a role for type: ${type}`, async function (assert) {
await record.save(); assert.expect(1);
this.path = 'static-role';
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) { test('it should make request to correct endpoint when rotating static role password', async function (assert) {
assert.expect(2); assert.expect(1);
this.server.delete('/ldap-test/:path/test-role', (schema, req) => { this.server.post('/ldap-test/rotate-role/admin/test-role', () => {
assert.strictEqual( assert.ok('GET request made to correct endpoint when rotating static role password');
req.params.path, });
this.path,
'DELETE request made to correct endpoint when deleting record' 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 * 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'; import { module, test } from 'qunit';
module('Unit | Utility | ldap breadcrumbs', function (hooks) { module('Unit | Utility | ldap breadcrumbs', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
this.mountPath = 'my-engine'; this.mountPath = 'my-engine';
this.roleType = 'static'; this.roleType = 'static';
const routeParams = (childResource) => {
return [this.mountPath, this.roleType, childResource];
};
this.testCrumbs = (path, { lastItemCurrent }) => { 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 { export default interface LdapLibraryModel extends WithFormFieldsAndValidationsModel {
backend: string; backend: string;
name: string; name: string;
path_to_library: string;
service_account_names: string; service_account_names: string;
default_ttl: number; default_ttl: number;
max_ttl: number; max_ttl: number;
disable_check_in_enforcement: string; disable_check_in_enforcement: string;
get completeLibraryName(): string;
get displayFields(): Array<FormField>; get displayFields(): Array<FormField>;
libraryPath: CapabilitiesModel; libraryPath: CapabilitiesModel;
statusPath: CapabilitiesModel; statusPath: CapabilitiesModel;

View File

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