UI: LDAP Hierarchical roles (#28824)

* remove named path adapter extension, add subdirectory query logic to adapter

* add subdirectory route and logic to page::roles component

* fix overview page search select

* breadcrumbs

* update tests and mirage

* revert ss changes

* oops

* cleanup adapter, add _ for private methods

* add acceptance test

* remove type

* add changelog

* add ldap breadcrumb test

* VAULT-31905 link jira

* update breadcrumbs in Edit route

* rename type interfaces
This commit is contained in:
claire bontempo
2024-11-05 16:52:29 -08:00
committed by GitHub
parent 752bb08664
commit 30d4e21e88
36 changed files with 664 additions and 225 deletions

6
changelog/28824.txt Normal file
View File

@@ -0,0 +1,6 @@
```release-note:improvement
ui: Adds navigation for LDAP hierarchical roles
```
```release-note:bug
ui: Fixes rendering issues of LDAP dynamic and static roles with the same name
```

View File

@@ -3,46 +3,87 @@
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
import NamedPathAdapter from 'vault/adapters/named-path'; import ApplicationAdapter from 'vault/adapters/application';
import { encodePath } from 'vault/utils/path-encoding-helpers'; import { encodePath } from 'vault/utils/path-encoding-helpers';
import { service } from '@ember/service'; import { service } from '@ember/service';
import AdapterError from '@ember-data/adapter/error'; import AdapterError from '@ember-data/adapter/error';
import { addManyToArray } from 'vault/helpers/add-to-array'; import { addManyToArray } from 'vault/helpers/add-to-array';
import sortObjects from 'vault/utils/sort-objects'; import sortObjects from 'vault/utils/sort-objects';
export default class LdapRoleAdapter extends NamedPathAdapter { export const ldapRoleID = (type, name) => `type:${type}::name:${name}`;
export default class LdapRoleAdapter extends ApplicationAdapter {
namespace = 'v1';
@service flashMessages; @service flashMessages;
getURL(backend, path, name) { // we do this in the adapter because query() requests separate endpoints to fetch static and dynamic roles.
// it also handles some error logic and serializing (some of which is for lazyPaginatedQuery)
// so for consistency formatting the response here
_constructRecord({ backend, name, type }) {
// ID cannot just be the 'name' because static and dynamic roles can have identical names
return { id: ldapRoleID(type, name), backend, name, type };
}
_getURL(backend, path, name) {
const base = `${this.buildURL()}/${encodePath(backend)}/${path}`; const base = `${this.buildURL()}/${encodePath(backend)}/${path}`;
return name ? `${base}/${name}` : base; return name ? `${base}/${name}` : base;
} }
pathForRoleType(type, isCred) {
_pathForRoleType(type, isCred) {
const staticPath = isCred ? 'static-cred' : 'static-role'; const staticPath = isCred ? 'static-cred' : 'static-role';
const dynamicPath = isCred ? 'creds' : 'role'; const dynamicPath = isCred ? 'creds' : 'role';
return type === 'static' ? staticPath : dynamicPath; return type === 'static' ? staticPath : dynamicPath;
} }
urlForUpdateRecord(name, modelName, snapshot) { _createOrUpdate(store, modelSchema, snapshot) {
const { backend, type } = snapshot.record; const { backend, name, type } = snapshot.record;
return this.getURL(backend, this.pathForRoleType(type), name); const data = snapshot.serialize();
} return this.ajax(this._getURL(backend, this._pathForRoleType(type), name), 'POST', {
urlForDeleteRecord(name, modelName, snapshot) { data,
const { backend, type } = snapshot.record; }).then(() => {
return this.getURL(backend, this.pathForRoleType(type), name); // add ID to response because ember data dislikes 204s...
return { data: this._constructRecord({ backend, name, type }) };
});
} }
createRecord() {
return this._createOrUpdate(...arguments);
}
updateRecord() {
return this._createOrUpdate(...arguments);
}
urlForDeleteRecord(id, modelName, snapshot) {
const { backend, type, name } = snapshot.record;
return this._getURL(backend, this._pathForRoleType(type), name);
}
/*
roleAncestry: { path_to_role: string; type: string };
*/
async query(store, type, query, recordArray, options) { async query(store, type, query, recordArray, options) {
const { showPartialError } = options.adapterOptions || {}; const { showPartialError, roleAncestry } = options.adapterOptions || {};
const { backend } = query; const { backend } = query;
if (roleAncestry) {
return this._querySubdirectory(backend, roleAncestry);
}
return this._queryAll(backend, showPartialError);
}
// LIST request for all roles (static and dynamic)
async _queryAll(backend, showPartialError) {
let roles = []; let roles = [];
const errors = []; const errors = [];
for (const roleType of ['static', 'dynamic']) { for (const roleType of ['static', 'dynamic']) {
const url = this.getURL(backend, this.pathForRoleType(roleType)); const url = this._getURL(backend, this._pathForRoleType(roleType));
try { try {
const models = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => { const models = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => {
return resp.data.keys.map((name) => ({ id: name, name, backend, type: roleType })); return resp.data.keys.map((name) => this._constructRecord({ backend, name, type: roleType }));
}); });
roles = addManyToArray(roles, models); roles = addManyToArray(roles, models);
} catch (error) { } catch (error) {
@@ -75,14 +116,32 @@ export default class LdapRoleAdapter extends NamedPathAdapter {
// changing the responsePath or providing the extractLazyPaginatedData serializer method causes normalizeResponse to return data: [undefined] // changing the responsePath or providing the extractLazyPaginatedData serializer method causes normalizeResponse to return data: [undefined]
return { data: { keys: sortObjects(roles, 'name') } }; return { data: { keys: sortObjects(roles, 'name') } };
} }
// LIST request for children of a hierarchical role
async _querySubdirectory(backend, roleAncestry) {
// path_to_role is the ancestral path
const { path_to_role, type: roleType } = roleAncestry;
const url = `${this._getURL(backend, this._pathForRoleType(roleType))}/${path_to_role}`;
const roles = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => {
return resp.data.keys.map((name) => ({
...this._constructRecord({ backend, name, type: roleType }),
path_to_role, // adds path_to_role attr to ldap/role model
}));
});
return { data: { keys: sortObjects(roles, 'name') } };
}
queryRecord(store, type, query) { queryRecord(store, type, query) {
const { backend, name, type: roleType } = query; const { backend, name, type: roleType } = query;
const url = this.getURL(backend, this.pathForRoleType(roleType), name); const url = this._getURL(backend, this._pathForRoleType(roleType), name);
return this.ajax(url, 'GET').then((resp) => ({ ...resp.data, backend, name, type: roleType })); return this.ajax(url, 'GET').then((resp) => ({
...resp.data,
...this._constructRecord({ backend, name, type: roleType }),
}));
} }
fetchCredentials(backend, type, name) { fetchCredentials(backend, type, name) {
const url = this.getURL(backend, this.pathForRoleType(type, true), name); const url = this._getURL(backend, this._pathForRoleType(type, true), name);
return this.ajax(url, 'GET').then((resp) => { return this.ajax(url, 'GET').then((resp) => {
if (type === 'dynamic') { if (type === 'dynamic') {
const { lease_id, lease_duration, renewable } = resp; const { lease_id, lease_duration, renewable } = resp;
@@ -92,7 +151,7 @@ export default class LdapRoleAdapter extends NamedPathAdapter {
}); });
} }
rotateStaticPassword(backend, name) { rotateStaticPassword(backend, name) {
const url = this.getURL(backend, 'rotate-role', name); const url = this._getURL(backend, 'rotate-role', name);
return this.ajax(url, 'POST'); return this.ajax(url, 'POST');
} }
} }

View File

@@ -63,7 +63,8 @@ export const dynamicRoleFields = [
@withModelValidations(validations) @withModelValidations(validations)
@withFormFields() @withFormFields()
export default class LdapRoleModel extends Model { export default class LdapRoleModel extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord @attr('string') backend; // mount path of ldap engine -- set on response from value passed to queryRecord
@attr('string') path_to_role; // ancestral path to the role added in the adapter (only exists for nested roles)
@attr('string', { @attr('string', {
defaultValue: 'static', defaultValue: 'static',

View File

@@ -6,8 +6,6 @@
import ApplicationSerializer from '../application'; import ApplicationSerializer from '../application';
export default class LdapRoleSerializer extends ApplicationSerializer { export default class LdapRoleSerializer extends ApplicationSerializer {
primaryKey = 'name';
serialize(snapshot) { serialize(snapshot) {
// remove all fields that are not relevant to specified role type // remove all fields that are not relevant to specified role type
const { fieldsForType } = snapshot.record; const { fieldsForType } = snapshot.record;

View File

@@ -6,17 +6,11 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
import type { Breadcrumb } from 'vault/vault/app-types';
interface Args { interface Args {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
} }
interface Breadcrumb {
label: string;
route?: string; // Do not provide for current route
icon?: string;
model?: string;
models?: string[];
linkToExternal?: boolean;
}
/** /**
* @module Page::Breadcrumbs * @module Page::Breadcrumbs

View File

@@ -53,7 +53,10 @@
class="is-flex-half" class="is-flex-half"
/> />
<div> <div>
<OverviewCard @cardTitle="Generate credentials" @subText="Quickly generate credentials by typing the role name."> <OverviewCard
@cardTitle="Generate credentials"
@subText="Quickly generate credentials by typing the role name. Only the engine's top-level roles are listed here."
>
<:content> <:content>
<div class="has-top-margin-m is-flex"> <div class="has-top-margin-m is-flex">
<SearchSelect <SearchSelect
@@ -61,11 +64,14 @@
@ariaLabel="Role" @ariaLabel="Role"
@placeholder="Select a role" @placeholder="Select a role"
@disallowNewItems={{true}} @disallowNewItems={{true}}
@options={{@roles}} @options={{this.roleOptions}}
@selectLimit="1" @selectLimit="1"
@fallbackComponent="input-search" @fallbackComponent="input-search"
@onChange={{this.selectRole}} @onChange={{this.selectRole}}
@renderInPlace={{true}} @renderInPlace={{true}}
@passObject={{true}}
@objectKeys={{array "id" "name" "type"}}
@shouldRenderName={{true}}
/> />
<div> <div>
<Hds::Button <Hds::Button

View File

@@ -24,16 +24,36 @@ interface Args {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
} }
interface Option {
id: string;
name: string;
type: string;
}
export default class LdapLibrariesPageComponent extends Component<Args> { export default class LdapLibrariesPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService; @service('app-router') declare readonly router: RouterService;
@tracked selectedRole: LdapRoleModel | undefined; @tracked selectedRole: LdapRoleModel | undefined;
get roleOptions() {
const options = this.args.roles
// hierarchical roles are not selectable
.filter((r: LdapRoleModel) => !r.name.endsWith('/'))
// *hack alert* - type is set as id so it renders beside name in search select
// this is to avoid more changes to search select and is okay here because
// we use the type and name to select the item below, not the id
.map((r: LdapRoleModel) => ({ id: r.type, name: r.name, type: r.type }));
return options;
}
@action @action
selectRole([roleName]: Array<string>) { async selectRole([option]: Array<Option>) {
const model = this.args.roles.find((role) => role.name === roleName); if (option) {
const { name, type } = option;
const model = this.args.roles.find((role) => role.name === name && role.type === type);
this.selectedRole = model; this.selectedRole = model;
} }
}
@action @action
generateCredentials() { generateCredentials() {

View File

@@ -43,15 +43,27 @@
{{else}} {{else}}
<div class="has-bottom-margin-s"> <div class="has-bottom-margin-s">
{{#each @roles as |role|}} {{#each @roles as |role|}}
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "roles.role.details" role.type role.name}} as |Item|> <ListItem @linkPrefix={{this.mountPoint}} @linkParams={{this.linkParams role}} as |Item|>
<Item.content> <Item.content>
<Icon @name="user" /> <Icon @name="user" />
<span data-test-role={{role.name}}>{{role.name}}</span> <span data-test-role="{{role.type}} {{role.name}}">{{role.name}}</span>
<Hds::Badge @text={{role.type}} data-test-role-type-badge={{role.name}} /> <Hds::Badge @text={{role.type}} data-test-role-type-badge={{role.name}} />
</Item.content> </Item.content>
<Item.menu> <Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|> <Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon @icon="more-horizontal" @text="More options" @hasChevron={{false}} data-test-popup-menu-trigger /> <dd.ToggleIcon
@icon="more-horizontal"
@text="More options"
@hasChevron={{false}}
data-test-popup-menu-trigger="{{role.type}} {{role.name}}"
/>
{{#if (this.isHierarchical role.name)}}
<dd.Interactive
data-test-subdirectory
@route="roles.subdirectory"
@models={{array role.type (concat role.path_to_role role.name)}}
>Content</dd.Interactive>
{{else}}
{{#if role.canEdit}} {{#if role.canEdit}}
<dd.Interactive <dd.Interactive
data-test-edit data-test-edit
@@ -84,6 +96,7 @@
{{on "click" (fn (mut this.roleToDelete) role)}} {{on "click" (fn (mut this.roleToDelete) role)}}
>Delete</dd.Interactive> >Delete</dd.Interactive>
{{/if}} {{/if}}
{{/if}}
</Hds::Dropdown> </Hds::Dropdown>
</Item.menu> </Item.menu>
</ListItem> </ListItem>
@@ -108,7 +121,9 @@
<Hds::Pagination::Numbered <Hds::Pagination::Numbered
@currentPage={{@roles.meta.currentPage}} @currentPage={{@roles.meta.currentPage}}
@currentPageSize={{@roles.meta.pageSize}} @currentPageSize={{@roles.meta.pageSize}}
@route="roles" {{! localName will be either "index" or "subdirectory" }}
@route="roles.{{this.router.currentRoute.localName}}"
@models={{@currentRouteParams}}
@showSizeSelector={{false}} @showSizeSelector={{false}}
@totalItems={{@roles.meta.filteredTotal}} @totalItems={{@roles.meta.filteredTotal}}
@queryFunction={{this.paginationQueryParams}} @queryFunction={{this.paginationQueryParams}}

View File

@@ -8,6 +8,7 @@ import { service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { getOwner } from '@ember/owner'; import { getOwner } from '@ember/owner';
import errorMessage from 'vault/utils/error-message'; import errorMessage from 'vault/utils/error-message';
import { tracked } from '@glimmer/tracking';
import type LdapRoleModel from 'vault/models/ldap/role'; import type LdapRoleModel from 'vault/models/ldap/role';
import type SecretEngineModel from 'vault/models/secret-engine'; import type SecretEngineModel from 'vault/models/secret-engine';
@@ -15,7 +16,6 @@ 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'; import type RouterService from '@ember/routing/router-service';
import type PaginationService from 'vault/services/pagination'; import type PaginationService from 'vault/services/pagination';
import { tracked } from '@glimmer/tracking';
interface Args { interface Args {
roles: Array<LdapRoleModel>; roles: Array<LdapRoleModel>;
@@ -29,9 +29,20 @@ export default class LdapRolesPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService; @service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService; @service('app-router') declare readonly router: RouterService;
@service declare readonly pagination: PaginationService; @service declare readonly pagination: PaginationService;
@tracked credsToRotate: LdapRoleModel | null = null; @tracked credsToRotate: LdapRoleModel | null = null;
@tracked roleToDelete: LdapRoleModel | null = null; @tracked roleToDelete: LdapRoleModel | null = null;
isHierarchical = (name: string) => name.endsWith('/');
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];
};
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,7 +54,8 @@ export default class LdapRolesPageComponent extends Component<Args> {
@action @action
onFilterChange(pageFilter: string) { onFilterChange(pageFilter: string) {
this.router.transitionTo('vault.cluster.secrets.backend.ldap.roles', { queryParams: { pageFilter } }); // refresh route, which fires off lazyPaginatedQuery to re-request and filter response
this.router.transitionTo(this.router?.currentRoute?.name, { queryParams: { pageFilter } });
} }
@action @action

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
export default class LdapRolesSubdirectoryController extends Controller {
queryParams = ['pageFilter', 'page'];
}

View File

@@ -9,6 +9,8 @@ export default buildRoutes(function () {
this.route('overview'); this.route('overview');
this.route('roles', function () { this.route('roles', function () {
this.route('create'); this.route('create');
// wildcard route so we can traverse hierarchical roles i.e. prod/admin/my-role
this.route('subdirectory', { path: '/:type/subdirectory/*path_to_role' });
this.route('role', { path: '/:type/:name' }, function () { this.route('role', { path: '/:type/:name' }, function () {
this.route('details'); this.route('details');
this.route('edit'); this.route('edit');

View File

@@ -16,14 +16,14 @@ import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
interface LdapConfigurationRouteModel { interface RouteModel {
backendModel: SecretEngineModel; backendModel: SecretEngineModel;
configModel: LdapConfigModel; configModel: LdapConfigModel;
configError: AdapterError; configError: AdapterError;
} }
interface LdapConfigurationController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
model: LdapConfigurationRouteModel; model: RouteModel;
} }
@withConfig('ldap/config') @withConfig('ldap/config')
@@ -42,11 +42,7 @@ export default class LdapConfigurationRoute extends Route {
}; };
} }
setupController( setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
controller: LdapConfigurationController,
resolvedModel: LdapConfigurationRouteModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [

View File

@@ -14,7 +14,7 @@ import type LdapConfigModel from 'vault/models/ldap/config';
import type Controller from '@ember/controller'; import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
interface LdapConfigureController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
} }
@@ -30,11 +30,7 @@ export default class LdapConfigureRoute extends Route {
return this.configModel || this.store.createRecord('ldap/config', { backend }); return this.configModel || this.store.createRecord('ldap/config', { backend });
} }
setupController( setupController(controller: RouteController, resolvedModel: LdapConfigModel, transition: Transition) {
controller: LdapConfigureController,
resolvedModel: LdapConfigModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [

View File

@@ -18,10 +18,10 @@ import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
import { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library'; import { LdapLibraryAccountStatus } from 'vault/vault/adapters/ldap/library';
interface LdapOverviewController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
} }
interface LdapOverviewRouteModel { interface RouteModel {
backendModel: SecretEngineModel; backendModel: SecretEngineModel;
promptConfig: boolean; promptConfig: boolean;
roles: Array<LdapRoleModel>; roles: Array<LdapRoleModel>;
@@ -66,11 +66,7 @@ export default class LdapOverviewRoute extends Route {
}); });
} }
setupController( setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
controller: LdapOverviewController,
resolvedModel: LdapOverviewRouteModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type PaginationService from 'vault/services/pagination';
import type SecretMountPath from 'vault/services/secret-mount-path';
// Base class for roles/index and roles/subdirectory routes
export default class LdapRolesRoute extends Route {
@service declare readonly pagination: PaginationService;
@service declare readonly secretMountPath: SecretMountPath;
lazyQuery(backendId: string, params: { page?: string; pageFilter: string }, adapterOptions: object) {
const page = Number(params.page) || 1;
return this.pagination.lazyPaginatedQuery(
'ldap/role',
{
backend: backendId,
page,
pageFilter: params.pageFilter,
responsePath: 'data.keys',
skipCache: page === 1,
},
{ adapterOptions }
);
}
}

View File

@@ -13,7 +13,7 @@ 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';
interface LdapRolesCreateController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
model: LdapRoleModel; model: LdapRoleModel;
} }
@@ -27,11 +27,7 @@ export default class LdapRolesCreateRoute extends Route {
return this.store.createRecord('ldap/role', { backend }); return this.store.createRecord('ldap/role', { backend });
} }
setupController( setupController(controller: RouteController, resolvedModel: LdapRoleModel, transition: Transition) {
controller: LdapRolesCreateController,
resolvedModel: LdapRoleModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [

View File

@@ -3,42 +3,32 @@
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
import Route from '@ember/routing/route'; import LdapRolesRoute from '../roles';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { withConfig } from 'core/decorators/fetch-secrets-engine-config'; import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
import { hash } from 'rsvp'; import { hash } from 'rsvp';
import type StoreService from 'vault/services/store'; import type StoreService from 'vault/services/store';
import type PaginationService from 'vault/services/pagination';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Transition from '@ember/routing/transition'; import type Transition from '@ember/routing/transition';
import type LdapRoleModel from 'vault/models/ldap/role'; import type LdapRoleModel from 'vault/models/ldap/role';
import type SecretEngineModel from 'vault/models/secret-engine'; import type SecretEngineModel from 'vault/models/secret-engine';
import type Controller from '@ember/controller'; import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
interface LdapRolesRouteModel { interface RouteModel {
backendModel: SecretEngineModel; backendModel: SecretEngineModel;
promptConfig: boolean; promptConfig: boolean;
roles: Array<LdapRoleModel>; roles: Array<LdapRoleModel>;
} }
interface LdapRolesController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: LdapRolesRouteModel;
pageFilter: string | undefined;
page: number | undefined;
}
interface LdapRolesRouteParams { interface RouteController extends Controller {
page?: string; breadcrumbs: Array<Breadcrumb>;
pageFilter: string; model: RouteModel;
} }
@withConfig('ldap/config') @withConfig('ldap/config')
export default class LdapRolesRoute extends Route { export default class LdapRolesIndexRoute extends LdapRolesRoute {
@service declare readonly store: StoreService; @service declare readonly store: StoreService; // necessary for @withConfig decorator
@service declare readonly pagination: PaginationService;
@service declare readonly secretMountPath: SecretMountPath;
declare promptConfig: boolean; declare promptConfig: boolean;
@@ -51,39 +41,26 @@ export default class LdapRolesRoute extends Route {
}, },
}; };
model(params: LdapRolesRouteParams) { model(params: { page?: string; pageFilter: string }) {
const backendModel = this.modelFor('application') as SecretEngineModel; const backendModel = this.modelFor('application') as SecretEngineModel;
return hash({ return hash({
backendModel, backendModel,
promptConfig: this.promptConfig, promptConfig: this.promptConfig,
roles: this.pagination.lazyPaginatedQuery( roles: this.lazyQuery(backendModel.id, params, { showPartialError: true }),
'ldap/role',
{
backend: backendModel.id,
page: Number(params.page) || 1,
pageFilter: params.pageFilter,
responsePath: 'data.keys',
},
{ adapterOptions: { showPartialError: true } }
),
}); });
} }
setupController( setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
controller: LdapRolesController,
resolvedModel: LdapRolesRouteModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true }, { label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backendModel.id, route: 'overview', model: resolvedModel.backend }, { label: resolvedModel.backendModel.id, route: 'overview' },
{ label: 'Roles' }, { label: 'Roles' },
]; ];
} }
resetController(controller: LdapRolesController, isExiting: boolean) { resetController(controller: RouteController, isExiting: boolean) {
if (isExiting) { if (isExiting) {
controller.set('pageFilter', undefined); controller.set('pageFilter', undefined);
controller.set('page', undefined); controller.set('page', undefined);

View File

@@ -6,19 +6,17 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { ModelFrom } from 'vault/vault/route';
import type Store from '@ember-data/store'; import type Store from '@ember-data/store';
import type SecretMountPath from 'vault/services/secret-mount-path'; import type SecretMountPath from 'vault/services/secret-mount-path';
interface LdapRoleRouteParams { export type LdapRolesRoleRouteModel = ModelFrom<LdapRolesRoleRoute>;
name: string;
type: string;
}
export default class LdapRoleRoute extends Route { export default class LdapRolesRoleRoute extends Route {
@service declare readonly store: Store; @service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath; @service declare readonly secretMountPath: SecretMountPath;
model(params: LdapRoleRouteParams) { model(params: { name: string; type: string }) {
const backend = this.secretMountPath.currentPath; const backend = this.secretMountPath.currentPath;
const { name, type } = params; const { name, type } = params;
return this.store.queryRecord('ldap/role', { backend, name, type }); return this.store.queryRecord('ldap/role', { backend, name, type });

View File

@@ -5,6 +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 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';
@@ -13,7 +14,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 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
export interface LdapStaticRoleCredentials { export interface StaticCredentials {
dn: string; dn: string;
last_vault_rotation: string; last_vault_rotation: string;
password: string; password: string;
@@ -23,7 +24,7 @@ export interface LdapStaticRoleCredentials {
username: string; username: string;
type: string; type: string;
} }
export interface LdapDynamicRoleCredentials { export interface DynamicCredentials {
distinguished_names: Array<string>; distinguished_names: Array<string>;
password: string; password: string;
username: string; username: string;
@@ -32,13 +33,13 @@ export interface LdapDynamicRoleCredentials {
renewable: boolean; renewable: boolean;
type: string; type: string;
} }
interface LdapRoleCredentialsRouteModel { interface RouteModel {
credentials: undefined | LdapStaticRoleCredentials | LdapDynamicRoleCredentials; credentials: undefined | StaticCredentials | DynamicCredentials;
error: undefined | AdapterError; error: undefined | AdapterError;
} }
interface LdapRoleCredentialsController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
model: LdapRoleCredentialsRouteModel; model: RouteModel;
} }
export default class LdapRoleCredentialsRoute extends Route { export default class LdapRoleCredentialsRoute extends Route {
@@ -53,19 +54,16 @@ export default class LdapRoleCredentialsRoute extends Route {
return { error }; return { error };
} }
} }
setupController( setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
controller: LdapRoleCredentialsController,
resolvedModel: LdapRoleCredentialsRouteModel,
transition: Transition
) {
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;
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: role.backend, route: 'overview' }, { label: role.backend, route: 'overview' },
{ label: 'roles', route: 'roles' }, { label: 'Roles', route: 'roles' },
{ label: role.name, route: 'roles.role' }, ...ldapBreadcrumbs(role.name, role.type, role.backend),
{ label: 'credentials' }, { label: 'Credentials' },
]; ];
} }
} }

View File

@@ -4,29 +4,31 @@
*/ */
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import type LdapRoleModel from 'vault/models/ldap/role';
import type Controller from '@ember/controller';
import type Transition from '@ember/routing/transition';
import type { Breadcrumb } from 'vault/vault/app-types'; import type { Breadcrumb } from 'vault/vault/app-types';
import type Controller from '@ember/controller';
import type LdapRoleModel from 'vault/models/ldap/role';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Transition from '@ember/routing/transition';
interface LdapRoleDetailsController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
model: LdapRoleModel; model: LdapRoleModel;
} }
export default class LdapRoleEditRoute extends Route { export default class LdapRolesRoleDetailsRoute extends Route {
setupController( @service declare readonly secretMountPath: SecretMountPath;
controller: LdapRoleDetailsController,
resolvedModel: LdapRoleModel, setupController(controller: RouteController, resolvedModel: LdapRoleModel, transition: Transition) {
transition: Transition
) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview' }, { label: resolvedModel.backend, route: 'overview' },
{ label: 'roles', route: 'roles' }, { label: 'Roles', route: 'roles' },
{ label: resolvedModel.name }, ...ldapBreadcrumbs(resolvedModel.name, resolvedModel.type, this.secretMountPath.currentPath, true),
]; ];
} }
} }

View File

@@ -4,26 +4,28 @@
*/ */
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { ldapBreadcrumbs } 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';
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';
interface LdapRoleEditController extends Controller { interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>; breadcrumbs: Array<Breadcrumb>;
model: LdapRoleModel; model: LdapRoleModel;
} }
export default class LdapRoleEditRoute extends Route { export default class LdapRoleEditRoute extends Route {
setupController(controller: LdapRoleEditController, resolvedModel: LdapRoleModel, transition: Transition) { setupController(controller: RouteController, resolvedModel: LdapRoleModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition); super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [ controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview' }, { label: resolvedModel.backend, route: 'overview' },
{ label: 'roles', route: 'roles' }, { label: 'Roles', route: 'roles' },
{ label: resolvedModel.name, route: 'roles.role' }, ...ldapBreadcrumbs(resolvedModel.name, resolvedModel.type, resolvedModel.backend),
{ label: 'edit' }, { label: 'Edit' },
]; ];
} }
} }

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import LdapRolesRoute from '../roles';
import { hash } from 'rsvp';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import type { Breadcrumb } from 'vault/vault/app-types';
import type Controller from '@ember/controller';
import type LdapRoleModel from 'vault/models/ldap/role';
import type SecretEngineModel from 'vault/models/secret-engine';
import type Transition from '@ember/routing/transition';
interface RouteModel {
backendModel: SecretEngineModel;
roleAncestry: { path_to_role: string; type: string };
roles: Array<LdapRoleModel>;
}
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: RouteModel;
}
interface RouteParams {
page?: string;
pageFilter: string;
path_to_role: string;
type: string;
}
export default class LdapRolesSubdirectoryRoute extends LdapRolesRoute {
queryParams = {
pageFilter: {
refreshModel: true,
},
page: {
refreshModel: true,
},
};
model(params: RouteParams) {
const backendModel = this.modelFor('application') as SecretEngineModel;
const { path_to_role, type } = params;
const roleAncestry = { path_to_role, type };
return hash({
backendModel,
roleAncestry,
roles: this.lazyQuery(backendModel.id, params, { roleAncestry }),
});
}
setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
const { backendModel, roleAncestry } = resolvedModel;
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),
];
// must call 'set' so breadcrumbs update as we navigate through directories
controller.set('breadcrumbs', crumbs);
}
resetController(controller: RouteController, isExiting: boolean) {
if (isExiting) {
controller.set('pageFilter', undefined);
controller.set('page', undefined);
}
}
}

View File

@@ -0,0 +1,15 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#let this.model.roleAncestry.type this.model.roleAncestry.path_to_role as |roleType path_to_role|}}
<Page::Roles
@roles={{this.model.roles}}
@promptConfig={{false}}
@backendModel={{this.model.backendModel}}
@breadcrumbs={{this.breadcrumbs}}
@pageFilter={{this.pageFilter}}
@currentRouteParams={{array this.model.backendModel.id roleType path_to_role}}
/>
{{/let}}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type { Breadcrumb } from 'vault/vault/app-types';
export const ldapBreadcrumbs = (
fullPath: string | undefined, // i.e. path/to/item
roleType: string,
mountPath: string,
lastItemCurrent = false // this array of objects can be spread anywhere within the crumbs array
): Breadcrumb[] => {
if (!fullPath) return [];
const ancestry = fullPath.split('/').filter((path) => path !== '');
const isDirectory = fullPath.endsWith('/');
return ancestry.map((name: string, idx: number) => {
const isLast = ancestry.length === idx + 1;
// if the end of the path is the current route, don't return a route link
if (isLast && lastItemCurrent) return { label: name };
// each segment is a continued concatenation of ancestral paths.
// for example, if the full path to an item is "prod/admin/west"
// the segments will be: prod/, prod/admin/, prod/admin/west.
// LIST or GET requests can then be made for each crumb accordingly.
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,
};
});
};

View File

@@ -30,19 +30,42 @@ export default function (server) {
}; };
const listRecords = (schema, dbKey, query = {}) => { const listRecords = (schema, dbKey, query = {}) => {
const records = schema.db[dbKey].where(query); const records = schema.db[dbKey].where(query);
const keys = records.map(({ name }) => {
if (name.includes('/')) {
const [parent, child] = name.split('/');
// query.name is only passed by listOrGetRecord and means we want to list children of admin/
// otherwise this is the request for all roles in an engine so we return the top-level paths
return query?.name ? child : `${parent}/`;
}
return name;
});
return { return {
data: { keys: records.map((record) => record.name) }, data: { keys },
}; };
}; };
const listOrGetRecord = (schema, req, type) => {
// if the param name is admin, we want to LIST admin/ roles
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` });
}
// otherwise we want to view details for a specific role
return getRecord(schema, req, 'ldapRoles', type);
};
// config // config
server.post('/:backend/config', (schema, req) => createOrUpdateRecord(schema, req, 'ldapConfigs')); server.post('/:backend/config', (schema, req) => createOrUpdateRecord(schema, req, 'ldapConfigs'));
server.get('/:backend/config', (schema, req) => getRecord(schema, req, 'ldapConfigs')); server.get('/:backend/config', (schema, req) => getRecord(schema, req, 'ldapConfigs'));
// roles // roles
server.post('/:backend/static-role/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapRoles')); server.post('/:backend/static-role/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapRoles'));
server.post('/:backend/role/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapRoles')); server.post('/:backend/role/:name', (schema, req) => createOrUpdateRecord(schema, req, 'ldapRoles'));
server.get('/:backend/static-role/:name', (schema, req) => getRecord(schema, req, 'ldapRoles', 'static')); // if the role is hierarchical the name ends in a forward slash so we make a list request
server.get('/:backend/role/:name', (schema, req) => getRecord(schema, req, 'ldapRoles', 'dynamic')); server.get('/:backend/static-role/*name', (schema, req) => listOrGetRecord(schema, req, 'static'));
server.get('/:backend/role/*name', (schema, req) => listOrGetRecord(schema, req, 'dynamic'));
server.get('/:backend/static-role', (schema) => listRecords(schema, 'ldapRoles', { type: 'static' })); server.get('/:backend/static-role', (schema) => listRecords(schema, 'ldapRoles', { type: 'static' }));
server.get('/:backend/role', (schema) => listRecords(schema, 'ldapRoles', { type: 'dynamic' })); server.get('/:backend/role', (schema) => listRecords(schema, 'ldapRoles', { type: 'dynamic' }));
// role credentials // role credentials

View File

@@ -7,6 +7,12 @@ export default function (server) {
server.create('ldap-config', { path: 'kubernetes', backend: 'ldap-test' }); server.create('ldap-config', { path: 'kubernetes', backend: 'ldap-test' });
server.create('ldap-role', 'static', { name: 'static-role' }); server.create('ldap-role', 'static', { name: 'static-role' });
server.create('ldap-role', 'dynamic', { name: 'dynamic-role' }); server.create('ldap-role', 'dynamic', { name: 'dynamic-role' });
// hierarchical roles
server.create('ldap-role', 'static', { name: 'admin/child-static-role' });
server.create('ldap-role', 'dynamic', { name: 'admin/child-dynamic-role' });
// use same name for both role types to test edge cases
server.create('ldap-role', 'static', { 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' });
server.create('ldap-account-status', { server.create('ldap-account-status', {
id: 'bob.johnson', id: 'bob.johnson',

View File

@@ -11,9 +11,10 @@ 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, fillIn, waitFor } from '@ember/test-helpers'; import { click, fillIn, waitFor } from '@ember/test-helpers';
import { isURL, visitURL } from 'vault/tests/helpers/ldap/ldap-helpers'; import { assertURL, isURL, visitURL } from 'vault/tests/helpers/ldap/ldap-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors';
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 | roles', function (hooks) { module('Acceptance | ldap | roles', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
@@ -45,37 +46,46 @@ module('Acceptance | ldap | roles', function (hooks) {
}); });
test('it should transition to role details route on list item click', async function (assert) { test('it should transition to role details route on list item click', async function (assert) {
await click('[data-test-list-item-link]:nth-of-type(1) a'); let path;
assert.true(
isURL('roles/dynamic/dynamic-role/details', this.backend),
'Transitions to role details route on list item click'
);
await click('[data-test-breadcrumb="roles"] a'); await click(LDAP_SELECTORS.roleItem('dynamic', 'dynamic-role'));
await click('[data-test-list-item-link]:nth-of-type(2) a'); path = 'roles/dynamic/dynamic-role/details';
assert.true( assertURL(assert, this.backend, path);
isURL('roles/static/static-role/details', this.backend), await click(GENERAL.breadcrumbLink('Roles'));
'Transitions to role details route on list item click'
); await click(LDAP_SELECTORS.roleItem('static', 'static-role'));
path = 'roles/static/static-role/details';
assertURL(assert, this.backend, path);
await click(GENERAL.breadcrumbLink('Roles'));
// edge case, roles of different type with same name
await click(LDAP_SELECTORS.roleItem('dynamic', 'my-role'));
path = 'roles/dynamic/my-role/details';
assertURL(assert, this.backend, path);
await click(GENERAL.breadcrumbLink('Roles'));
await click(LDAP_SELECTORS.roleItem('static', 'my-role'));
path = 'roles/static/my-role/details';
assertURL(assert, this.backend, path);
}); });
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(3); assert.expect(3);
for (const action of ['edit', 'get-creds', 'details']) { for (const action of ['edit', 'get-creds', 'details']) {
await click('[data-test-popup-menu-trigger]'); await click(LDAP_SELECTORS.roleMenu('dynamic', 'dynamic-role'));
await click(`[data-test-${action}]`); await click(LDAP_SELECTORS.action(action));
const uri = action === 'get-creds' ? 'credentials' : action; const uri = action === 'get-creds' ? 'credentials' : action;
assert.true( assert.true(
isURL(`roles/dynamic/dynamic-role/${uri}`, this.backend), isURL(`roles/dynamic/dynamic-role/${uri}`, this.backend),
`Transitions to ${uri} route on list item action menu click` `Transitions to ${uri} route on list item action menu click`
); );
await click('[data-test-breadcrumb="roles"] a'); await click(GENERAL.breadcrumbLink('Roles'));
} }
}); });
test('it should transition to routes from role details toolbar links', async function (assert) { test('it should transition to routes from role details toolbar links', async function (assert) {
await click('[data-test-list-item-link]:nth-of-type(1) a'); await click(LDAP_SELECTORS.roleItem('dynamic', 'dynamic-role'));
await click('[data-test-get-credentials]'); await click('[data-test-get-credentials]');
assert.true( assert.true(
isURL('roles/dynamic/dynamic-role/credentials', this.backend), isURL('roles/dynamic/dynamic-role/credentials', this.backend),
@@ -100,4 +110,60 @@ module('Acceptance | ldap | roles', function (hooks) {
await click('[data-test-tab="roles"]'); await click('[data-test-tab="roles"]');
assert.dom('[data-test-filter-input]').hasNoValue('Roles page filter value cleared on route exit'); assert.dom('[data-test-filter-input]').hasNoValue('Roles page filter value cleared on route exit');
}); });
module('subdirectory', function () {
test('it navigates to hierarchical roles', async function (assert) {
let path;
// hierarchical paths
await click(LDAP_SELECTORS.roleItem('dynamic', 'admin/'));
path = 'roles/dynamic/subdirectory/admin/';
assertURL(assert, this.backend, path);
await click(LDAP_SELECTORS.roleItem('dynamic', 'child-dynamic-role'));
path = 'roles/dynamic/admin%2Fchild-dynamic-role/details';
assertURL(assert, this.backend, path);
// navigate out via breadcrumbs to test
await click(GENERAL.breadcrumbLink('admin'));
path = 'roles/dynamic/subdirectory/admin/';
assertURL(assert, this.backend, path);
await click(GENERAL.breadcrumbLink('Roles'));
await click(LDAP_SELECTORS.roleItem('static', 'admin/'));
path = 'roles/static/subdirectory/admin/';
assertURL(assert, this.backend, path);
await click(LDAP_SELECTORS.roleItem('static', 'child-static-role'));
path = 'roles/static/admin%2Fchild-static-role/details';
assertURL(assert, this.backend, path);
// navigate out via breadcrumbs to test
await click(GENERAL.breadcrumbLink('admin'));
path = 'roles/static/subdirectory/admin/';
assertURL(assert, this.backend, path);
});
test('it should transition to subdirectory from hierarchical role popup menu', async function (assert) {
assert.expect(4);
await click(LDAP_SELECTORS.roleMenu('dynamic', 'admin/'));
for (const action of ['edit', 'get-creds', 'details']) {
assert.dom(LDAP_SELECTORS.action(action)).doesNotExist(`${action} does not render in popup menu`);
}
await click(LDAP_SELECTORS.action('subdirectory'));
assertURL(assert, this.backend, 'roles/dynamic/subdirectory/admin/');
});
test('it should clear roles page filter value on route exit', async function (assert) {
await visitURL('roles/static/subdirectory/admin/', this.backend);
await fillIn('[data-test-filter-input]', 'foo');
assert
.dom('[data-test-filter-input]')
.hasValue('foo', 'Roles page filter value set after model refresh and rerender');
await waitFor(GENERAL.emptyStateTitle);
await click('[data-test-tab="libraries"]');
await click('[data-test-tab="roles"]');
assert.dom('[data-test-filter-input]').hasNoValue('Roles page filter value cleared on route exit');
});
});
}); });

View File

@@ -35,6 +35,10 @@ export const isURL = (uri, backend = 'ldap-test') => {
return currentURL() === `${baseURL(backend)}${stripLeadingSlash(uri)}`; return currentURL() === `${baseURL(backend)}${stripLeadingSlash(uri)}`;
}; };
export const assertURL = (assert, backend, path) => {
assert.strictEqual(currentURL(), baseURL(backend) + path, `url is ${path}`);
};
export const visitURL = (uri, backend = 'ldap-test') => { export const visitURL = (uri, backend = 'ldap-test') => {
return visit(`${baseURL(backend)}${stripLeadingSlash(uri)}`); return visit(`${baseURL(backend)}${stripLeadingSlash(uri)}`);
}; };

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export const LDAP_SELECTORS = {
roleItem: (type: string, name: string) => `[data-test-role="${type} ${name}"]`,
roleMenu: (type: string, name: string) => `[data-test-popup-menu-trigger="${type} ${name}"]`,
action: (action: string) => `[data-test-${action}]`,
};

View File

@@ -10,6 +10,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, click, fillIn } from '@ember/test-helpers'; import { render, click, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon'; import sinon from 'sinon';
import { ldapRoleID } from 'vault/adapters/ldap/role';
module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (hooks) { module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@@ -29,7 +30,8 @@ module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (h
this.newModel = this.store.createRecord('ldap/role', { backend: 'ldap-test' }); this.newModel = this.store.createRecord('ldap/role', { backend: 'ldap-test' });
['static', 'dynamic'].forEach((type) => { ['static', 'dynamic'].forEach((type) => {
this[`${type}RoleData`] = this.server.create('ldap-role', type, { name: `${type}-role` }); const name = `${type}-role`;
this[`${type}RoleData`] = this.server.create('ldap-role', type, { name, id: ldapRoleID(type, name) });
this.store.pushPayload('ldap/role', { this.store.pushPayload('ldap/role', {
modelName: 'ldap/role', modelName: 'ldap/role',
backend: 'ldap-test', backend: 'ldap-test',
@@ -44,6 +46,8 @@ module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (h
{ label: 'Create' }, { label: 'Create' },
]; ];
this.fetchModel = (type, name) => this.store.peekRecord('ldap/role', ldapRoleID(type, name));
this.renderComponent = () => { this.renderComponent = () => {
return render( return render(
hbs`<Page::Role::CreateAndEdit @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`, hbs`<Page::Role::CreateAndEdit @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`,
@@ -100,14 +104,14 @@ module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (h
}); });
}; };
this.model = this.store.peekRecord('ldap/role', 'static-role'); this.model = this.fetchModel('static', 'static-role');
await this.renderComponent(); await this.renderComponent();
assert.dom('[data-test-radio-card="static"]').isDisabled('Type selection is disabled when editing'); assert.dom('[data-test-radio-card="static"]').isDisabled('Type selection is disabled when editing');
assert.dom('[data-test-input="name"]').isDisabled('Name field is disabled when editing'); assert.dom('[data-test-input="name"]').isDisabled('Name field is disabled when editing');
checkFields(['name', 'dn', 'username']); checkFields(['name', 'dn', 'username']);
checkTtl(['rotation_period']); checkTtl(['rotation_period']);
this.model = this.store.peekRecord('ldap/role', 'dynamic-role'); this.model = this.fetchModel('dynamic', 'dynamic-role');
await this.renderComponent(); await this.renderComponent();
checkFields(['name', 'username_template']); checkFields(['name', 'username_template']);
checkTtl(['default_ttl', 'max_ttl']); checkTtl(['default_ttl', 'max_ttl']);
@@ -115,7 +119,7 @@ module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (h
}); });
test('it should go back to list route and clean up model on cancel', async function (assert) { test('it should go back to list route and clean up model on cancel', async function (assert) {
this.model = this.store.peekRecord('ldap/role', 'static-role'); this.model = this.fetchModel('static', 'static-role');
const spy = sinon.spy(this.model, 'rollbackAttributes'); const spy = sinon.spy(this.model, 'rollbackAttributes');
await this.renderComponent(); await this.renderComponent();
@@ -181,7 +185,7 @@ module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (h
assert.deepEqual(expected, data, 'POST request made to save role with correct properties'); assert.deepEqual(expected, data, 'POST request made to save role with correct properties');
}); });
this.model = this.store.peekRecord('ldap/role', 'static-role'); this.model = this.fetchModel('static', 'static-role');
await this.renderComponent(); await this.renderComponent();
await fillIn('[data-test-input="dn"]', 'foo'); await fillIn('[data-test-input="dn"]', 'foo');

View File

@@ -11,6 +11,7 @@ import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon'; import sinon from 'sinon';
import { duration } from 'core/helpers/format-duration'; import { duration } from 'core/helpers/format-duration';
import { ldapRoleID } from 'vault/adapters/ldap/role';
module('Integration | Component | ldap | Page::Role::Details', function (hooks) { module('Integration | Component | ldap | Page::Role::Details', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@@ -25,6 +26,7 @@ module('Integration | Component | ldap | Page::Role::Details', function (hooks)
})); }));
this.renderComponent = (type) => { this.renderComponent = (type) => {
const data = this.server.create('ldap-role', type); const data = this.server.create('ldap-role', type);
data.id = ldapRoleID(type, data.name);
const store = this.owner.lookup('service:store'); const store = this.owner.lookup('service:store');
store.pushPayload('ldap/role', { store.pushPayload('ldap/role', {
modelName: 'ldap/role', modelName: 'ldap/role',
@@ -32,7 +34,7 @@ module('Integration | Component | ldap | Page::Role::Details', function (hooks)
type, type,
...data, ...data,
}); });
this.model = store.peekRecord('ldap/role', data.name); this.model = store.peekRecord('ldap/role', ldapRoleID(type, data.name));
this.breadcrumbs = [ this.breadcrumbs = [
{ label: this.model.backend, route: 'overview' }, { label: this.model.backend, route: 'overview' },
{ label: 'roles', route: 'roles' }, { label: 'roles', route: 'roles' },

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 sinon from 'sinon'; import sinon from 'sinon';
import { LDAP_SELECTORS } from 'vault/tests/helpers/ldap/ldap-selectors';
module('Integration | Component | ldap | Page::Roles', function (hooks) { module('Integration | Component | ldap | Page::Roles', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@@ -95,7 +96,9 @@ module('Integration | Component | ldap | Page::Roles', function (hooks) {
await this.renderComponent(); await this.renderComponent();
assert.dom('[data-test-list-item-content] svg').hasClass('flight-icon-user', 'List item icon renders'); assert.dom('[data-test-list-item-content] svg').hasClass('flight-icon-user', 'List item icon renders');
assert.dom('[data-test-role="static-test"]').hasText(this.roles[0].name, 'List item name renders'); assert
.dom(LDAP_SELECTORS.roleItem('static', 'static-test'))
.hasText(this.roles[0].name, 'List item name renders');
assert assert
.dom('[data-test-role-type-badge="static-test"]') .dom('[data-test-role-type-badge="static-test"]')
.hasText(this.roles[0].type, 'List item type badge renders'); .hasText(this.roles[0].type, 'List item type badge renders');
@@ -116,8 +119,14 @@ module('Integration | Component | ldap | Page::Roles', function (hooks) {
}); });
test('it should filter roles', async function (assert) { test('it should filter roles', async function (assert) {
const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); const currentRouteName = 'vault.cluster.secrets.backend.ldap.roles';
this.router = this.owner.lookup('service:router');
const transitionStub = sinon.stub(this.router, 'transitionTo');
// stub because the component calls either "roles.subdirectory" or "roles.index"
// depending on where it renders
sinon.stub(this.router, 'currentRoute').value({
name: currentRouteName,
});
this.roles.meta.filteredTotal = 0; this.roles.meta.filteredTotal = 0;
this.pageFilter = 'foo'; this.pageFilter = 'foo';
@@ -129,10 +138,13 @@ module('Integration | Component | ldap | Page::Roles', function (hooks) {
await fillIn('[data-test-filter-input]', 'bar'); await fillIn('[data-test-filter-input]', 'bar');
assert.true( const [calledRoute, calledParams] = transitionStub.lastCall.args;
transitionStub.calledWith('vault.cluster.secrets.backend.ldap.roles', { assert.strictEqual(calledRoute, currentRouteName);
assert.propEqual(
calledParams,
{
queryParams: { pageFilter: 'bar' }, queryParams: { pageFilter: 'bar' },
}), },
'Transition called with correct query params on filter change' 'Transition called with correct query params on filter change'
); );
}); });

View File

@@ -8,6 +8,7 @@ import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { Response } from 'miragejs'; import { Response } from 'miragejs';
import sinon from 'sinon'; import sinon from 'sinon';
import { ldapRoleID } from 'vault/adapters/ldap/role';
module('Unit | Adapter | ldap/role', function (hooks) { module('Unit | Adapter | ldap/role', function (hooks) {
setupTest(hooks); setupTest(hooks);
@@ -17,6 +18,18 @@ module('Unit | Adapter | ldap/role', function (hooks) {
this.store = this.owner.lookup('service:store'); this.store = this.owner.lookup('service:store');
this.adapter = this.store.adapterFor('ldap/role'); this.adapter = this.store.adapterFor('ldap/role');
this.path = 'role'; this.path = 'role';
this.getModel = (type) => {
const name = 'test-role';
this.store.pushPayload('ldap/role', {
modelName: 'ldap/role',
backend: 'ldap-test',
name,
type,
id: ldapRoleID(type, name),
});
return this.store.peekRecord('ldap/role', ldapRoleID(type, name));
};
}); });
test('it should make request to correct endpoints when listing records', async function (assert) { test('it should make request to correct endpoints when listing records', async function (assert) {
@@ -24,7 +37,7 @@ module('Unit | Adapter | ldap/role', function (hooks) {
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.params.path === '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] } };
}; };
@@ -176,15 +189,8 @@ module('Unit | Adapter | ldap/role', function (hooks) {
); );
}); });
this.store.pushPayload('ldap/role', {
modelName: 'ldap/role',
backend: 'ldap-test',
name: 'test-role',
});
const record = this.store.peekRecord('ldap/role', 'test-role');
for (const type of ['dynamic', 'static']) { for (const type of ['dynamic', 'static']) {
record.type = type; const record = this.getModel(type);
await record.save(); await record.save();
this.path = 'static-role'; this.path = 'static-role';
} }
@@ -201,18 +207,8 @@ module('Unit | Adapter | ldap/role', function (hooks) {
); );
}); });
const getModel = () => {
this.store.pushPayload('ldap/role', {
modelName: 'ldap/role',
backend: 'ldap-test',
name: 'test-role',
});
return this.store.peekRecord('ldap/role', 'test-role');
};
for (const type of ['dynamic', 'static']) { for (const type of ['dynamic', 'static']) {
const record = getModel(); const record = this.getModel(type);
record.type = type;
await record.destroyRecord(); await record.destroyRecord();
this.path = 'static-role'; this.path = 'static-role';
} }

View File

@@ -0,0 +1,76 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { ldapBreadcrumbs } 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';
this.testCrumbs = (path, { lastItemCurrent }) => {
return ldapBreadcrumbs(path, this.roleType, this.mountPath, lastItemCurrent);
};
});
test('it generates crumbs when the path is a directory', function (assert) {
const path = 'prod/org/';
let actual = this.testCrumbs(path, { lastItemCurrent: true });
let expected = [
{ label: 'prod', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/'] },
{ label: 'org' },
];
assert.propEqual(actual, expected, 'crumbs are correct when lastItemCurrent = true');
actual = this.testCrumbs(path, { lastItemCurrent: false });
expected = [
{ label: 'prod', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/'] },
{ label: 'org', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/org/'] },
];
assert.propEqual(actual, expected, 'crumbs are correct when lastItemCurrent = false');
});
test('it generates crumbs when the path is not a directory', function (assert) {
const path = 'prod/org/admin';
let actual = this.testCrumbs(path, { lastItemCurrent: true });
let expected = [
{ label: 'prod', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/'] },
{ label: 'org', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/org/'] },
{ label: 'admin' },
];
assert.propEqual(actual, expected, 'crumbs are correct when lastItemCurrent = true');
actual = this.testCrumbs(path, { lastItemCurrent: false });
expected = [
{ label: 'prod', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/'] },
{ label: 'org', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/org/'] },
{
label: 'admin',
route: 'roles.role.details',
models: [this.mountPath, this.roleType, 'prod/org/admin'],
},
];
assert.propEqual(actual, expected, 'crumbs are correct when lastItemCurrent = false');
});
test('it generates crumbs when the path is the top-level', function (assert) {
const path = 'prod/';
let actual = this.testCrumbs(path, { lastItemCurrent: true });
let expected = [{ label: 'prod' }];
assert.propEqual(actual, expected, 'crumbs are correct when lastItemCurrent = true');
actual = this.testCrumbs(path, { lastItemCurrent: false });
expected = [
{ label: 'prod', route: 'roles.subdirectory', models: [this.mountPath, this.roleType, 'prod/'] },
];
assert.propEqual(actual, expected, 'crumbs are correct when lastItemCurrent = false');
});
test('it fails gracefully when no path', function (assert) {
const path = undefined;
const actual = this.testCrumbs(path, { lastItemCurrent: false });
assert.propEqual(actual, [], 'returns empty array when path is null');
});
});

View File

@@ -63,6 +63,9 @@ export interface WithFormFieldsAndValidationsModel extends WithFormFieldsModel,
export interface Breadcrumb { export interface Breadcrumb {
label: string; label: string;
route?: string; route?: string;
icon?: string;
model?: string;
models?: string[];
linkExternal?: boolean; linkExternal?: boolean;
} }
@@ -73,12 +76,6 @@ export interface TtlEvent {
goSafeTimeString: string; goSafeTimeString: string;
} }
export interface Breadcrumb {
label: string;
route?: string;
linkExternal?: boolean;
}
export interface EngineOwner extends Owner { export interface EngineOwner extends Owner {
mountPoint: string; mountPoint: string;
} }

View File

@@ -7,8 +7,10 @@ import type { FormField } from 'vault/app-types';
import CapabilitiesModel from '../capabilities'; import CapabilitiesModel from '../capabilities';
import { LdapDynamicRoleCredentials, LdapStaticRoleCredentials } from 'ldap/routes/roles/role/credentials'; import { LdapDynamicRoleCredentials, LdapStaticRoleCredentials } from 'ldap/routes/roles/role/credentials';
export default interface LdapRoleModel extends WithFormFieldsAndValidationsModel { export default interface LdapRoleModel extends WithFormFieldsAndValidationsModel {
id: string;
type: string; type: string;
backend: string; backend: string;
path_to_role: string;
name: string; name: string;
dn: string; dn: string;
username: string; username: string;