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
*/
import NamedPathAdapter from 'vault/adapters/named-path';
import ApplicationAdapter from 'vault/adapters/application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { service } from '@ember/service';
import AdapterError from '@ember-data/adapter/error';
import { addManyToArray } from 'vault/helpers/add-to-array';
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;
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}`;
return name ? `${base}/${name}` : base;
}
pathForRoleType(type, isCred) {
_pathForRoleType(type, isCred) {
const staticPath = isCred ? 'static-cred' : 'static-role';
const dynamicPath = isCred ? 'creds' : 'role';
return type === 'static' ? staticPath : dynamicPath;
}
urlForUpdateRecord(name, modelName, snapshot) {
const { backend, type } = snapshot.record;
return this.getURL(backend, this.pathForRoleType(type), name);
}
urlForDeleteRecord(name, modelName, snapshot) {
const { backend, type } = snapshot.record;
return this.getURL(backend, this.pathForRoleType(type), name);
_createOrUpdate(store, modelSchema, snapshot) {
const { backend, name, type } = snapshot.record;
const data = snapshot.serialize();
return this.ajax(this._getURL(backend, this._pathForRoleType(type), name), 'POST', {
data,
}).then(() => {
// 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) {
const { showPartialError } = options.adapterOptions || {};
const { showPartialError, roleAncestry } = options.adapterOptions || {};
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 = [];
const errors = [];
for (const roleType of ['static', 'dynamic']) {
const url = this.getURL(backend, this.pathForRoleType(roleType));
const url = this._getURL(backend, this._pathForRoleType(roleType));
try {
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);
} 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]
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) {
const { backend, name, type: roleType } = query;
const url = this.getURL(backend, this.pathForRoleType(roleType), name);
return this.ajax(url, 'GET').then((resp) => ({ ...resp.data, backend, name, type: roleType }));
const url = this._getURL(backend, this._pathForRoleType(roleType), name);
return this.ajax(url, 'GET').then((resp) => ({
...resp.data,
...this._constructRecord({ backend, name, type: roleType }),
}));
}
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) => {
if (type === 'dynamic') {
const { lease_id, lease_duration, renewable } = resp;
@@ -92,7 +151,7 @@ export default class LdapRoleAdapter extends NamedPathAdapter {
});
}
rotateStaticPassword(backend, name) {
const url = this.getURL(backend, 'rotate-role', name);
const url = this._getURL(backend, 'rotate-role', name);
return this.ajax(url, 'POST');
}
}

View File

@@ -63,7 +63,8 @@ export const dynamicRoleFields = [
@withModelValidations(validations)
@withFormFields()
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', {
defaultValue: 'static',

View File

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

View File

@@ -6,17 +6,11 @@
import Component from '@glimmer/component';
import { assert } from '@ember/debug';
import type { Breadcrumb } from 'vault/vault/app-types';
interface Args {
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

View File

@@ -53,7 +53,10 @@
class="is-flex-half"
/>
<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>
<div class="has-top-margin-m is-flex">
<SearchSelect
@@ -61,11 +64,14 @@
@ariaLabel="Role"
@placeholder="Select a role"
@disallowNewItems={{true}}
@options={{@roles}}
@options={{this.roleOptions}}
@selectLimit="1"
@fallbackComponent="input-search"
@onChange={{this.selectRole}}
@renderInPlace={{true}}
@passObject={{true}}
@objectKeys={{array "id" "name" "type"}}
@shouldRenderName={{true}}
/>
<div>
<Hds::Button

View File

@@ -24,15 +24,35 @@ interface Args {
breadcrumbs: Array<Breadcrumb>;
}
interface Option {
id: string;
name: string;
type: string;
}
export default class LdapLibrariesPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@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
selectRole([roleName]: Array<string>) {
const model = this.args.roles.find((role) => role.name === roleName);
this.selectedRole = model;
async selectRole([option]: Array<Option>) {
if (option) {
const { name, type } = option;
const model = this.args.roles.find((role) => role.name === name && role.type === type);
this.selectedRole = model;
}
}
@action

View File

@@ -43,46 +43,59 @@
{{else}}
<div class="has-bottom-margin-s">
{{#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>
<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}} />
</Item.content>
<Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon @icon="more-horizontal" @text="More options" @hasChevron={{false}} data-test-popup-menu-trigger />
{{#if role.canEdit}}
<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-edit
@route="roles.role.edit"
data-test-subdirectory
@route="roles.subdirectory"
@models={{array role.type (concat role.path_to_role role.name)}}
>Content</dd.Interactive>
{{else}}
{{#if role.canEdit}}
<dd.Interactive
data-test-edit
@route="roles.role.edit"
@models={{array role.type role.name}}
>Edit</dd.Interactive>
{{/if}}
{{#if role.canReadCreds}}
<dd.Interactive data-test-get-creds @route="roles.role.credentials" @models={{array role.type role.name}}>
Get credentials
</dd.Interactive>
{{/if}}
{{#if role.canRotateStaticCreds}}
<dd.Interactive
data-test-rotate-creds
@color="critical"
{{on "click" (fn (mut this.credsToRotate) role)}}
>Rotate credentials</dd.Interactive>
{{/if}}
<dd.Interactive
data-test-details
@route="roles.role.details"
{{! this will force the roles.role model hook to fire since we may only have a partial model loaded in the list view }}
@models={{array role.type role.name}}
>Edit</dd.Interactive>
{{/if}}
{{#if role.canReadCreds}}
<dd.Interactive data-test-get-creds @route="roles.role.credentials" @models={{array role.type role.name}}>
Get credentials
</dd.Interactive>
{{/if}}
{{#if role.canRotateStaticCreds}}
<dd.Interactive
data-test-rotate-creds
@color="critical"
{{on "click" (fn (mut this.credsToRotate) role)}}
>Rotate credentials</dd.Interactive>
{{/if}}
<dd.Interactive
data-test-details
@route="roles.role.details"
{{! this will force the roles.role model hook to fire since we may only have a partial model loaded in the list view }}
@models={{array role.type role.name}}
>Details</dd.Interactive>
{{#if role.canDelete}}
<dd.Interactive
data-test-delete
@color="critical"
{{on "click" (fn (mut this.roleToDelete) role)}}
>Delete</dd.Interactive>
>Details</dd.Interactive>
{{#if role.canDelete}}
<dd.Interactive
data-test-delete
@color="critical"
{{on "click" (fn (mut this.roleToDelete) role)}}
>Delete</dd.Interactive>
{{/if}}
{{/if}}
</Hds::Dropdown>
</Item.menu>
@@ -108,7 +121,9 @@
<Hds::Pagination::Numbered
@currentPage={{@roles.meta.currentPage}}
@currentPageSize={{@roles.meta.pageSize}}
@route="roles"
{{! localName will be either "index" or "subdirectory" }}
@route="roles.{{this.router.currentRoute.localName}}"
@models={{@currentRouteParams}}
@showSizeSelector={{false}}
@totalItems={{@roles.meta.filteredTotal}}
@queryFunction={{this.paginationQueryParams}}

View File

@@ -8,6 +8,7 @@ import { service } from '@ember/service';
import { action } from '@ember/object';
import { getOwner } from '@ember/owner';
import errorMessage from 'vault/utils/error-message';
import { tracked } from '@glimmer/tracking';
import type LdapRoleModel from 'vault/models/ldap/role';
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 RouterService from '@ember/routing/router-service';
import type PaginationService from 'vault/services/pagination';
import { tracked } from '@glimmer/tracking';
interface Args {
roles: Array<LdapRoleModel>;
@@ -29,9 +29,20 @@ export default class LdapRolesPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly pagination: PaginationService;
@tracked credsToRotate: 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 {
const owner = getOwner(this) as EngineOwner;
return owner.mountPoint;
@@ -43,7 +54,8 @@ export default class LdapRolesPageComponent extends Component<Args> {
@action
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ldapBreadcrumbs } from 'ldap/utils/ldap-breadcrumbs';
import type Store from '@ember-data/store';
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 AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
export interface LdapStaticRoleCredentials {
export interface StaticCredentials {
dn: string;
last_vault_rotation: string;
password: string;
@@ -23,7 +24,7 @@ export interface LdapStaticRoleCredentials {
username: string;
type: string;
}
export interface LdapDynamicRoleCredentials {
export interface DynamicCredentials {
distinguished_names: Array<string>;
password: string;
username: string;
@@ -32,13 +33,13 @@ export interface LdapDynamicRoleCredentials {
renewable: boolean;
type: string;
}
interface LdapRoleCredentialsRouteModel {
credentials: undefined | LdapStaticRoleCredentials | LdapDynamicRoleCredentials;
interface RouteModel {
credentials: undefined | StaticCredentials | DynamicCredentials;
error: undefined | AdapterError;
}
interface LdapRoleCredentialsController extends Controller {
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: LdapRoleCredentialsRouteModel;
model: RouteModel;
}
export default class LdapRoleCredentialsRoute extends Route {
@@ -53,19 +54,16 @@ export default class LdapRoleCredentialsRoute extends Route {
return { error };
}
}
setupController(
controller: LdapRoleCredentialsController,
resolvedModel: LdapRoleCredentialsRouteModel,
transition: Transition
) {
setupController(controller: RouteController, resolvedModel: RouteModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
const role = this.modelFor('roles.role') as LdapRoleModel;
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: role.backend, route: 'overview' },
{ label: 'roles', route: 'roles' },
{ label: role.name, route: 'roles.role' },
{ label: 'credentials' },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(role.name, role.type, role.backend),
{ label: 'Credentials' },
];
}
}

View File

@@ -4,29 +4,31 @@
*/
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 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>;
model: LdapRoleModel;
}
export default class LdapRoleEditRoute extends Route {
setupController(
controller: LdapRoleDetailsController,
resolvedModel: LdapRoleModel,
transition: Transition
) {
export default class LdapRolesRoleDetailsRoute extends Route {
@service declare readonly secretMountPath: SecretMountPath;
setupController(controller: RouteController, resolvedModel: LdapRoleModel, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview' },
{ label: 'roles', route: 'roles' },
{ label: resolvedModel.name },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(resolvedModel.name, resolvedModel.type, this.secretMountPath.currentPath, true),
];
}
}

View File

@@ -4,26 +4,28 @@
*/
import Route from '@ember/routing/route';
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';
interface LdapRoleEditController extends Controller {
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: LdapRoleModel;
}
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);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview' },
{ label: 'roles', route: 'roles' },
{ label: resolvedModel.name, route: 'roles.role' },
{ label: 'edit' },
{ label: 'Roles', route: 'roles' },
...ldapBreadcrumbs(resolvedModel.name, resolvedModel.type, resolvedModel.backend),
{ 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 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 {
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
server.post('/:backend/config', (schema, req) => createOrUpdateRecord(schema, req, 'ldapConfigs'));
server.get('/:backend/config', (schema, req) => getRecord(schema, req, 'ldapConfigs'));
// roles
server.post('/:backend/static-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'));
server.get('/:backend/role/:name', (schema, req) => getRecord(schema, req, 'ldapRoles', 'dynamic'));
// if the role is hierarchical the name ends in a forward slash so we make a list request
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/role', (schema) => listRecords(schema, 'ldapRoles', { type: 'dynamic' }));
// role credentials

View File

@@ -7,6 +7,12 @@ export default function (server) {
server.create('ldap-config', { path: 'kubernetes', backend: 'ldap-test' });
server.create('ldap-role', 'static', { name: 'static-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-account-status', {
id: 'bob.johnson',

View File

@@ -11,9 +11,10 @@ import ldapMirageScenario from 'vault/mirage/scenarios/ldap';
import ldapHandlers from 'vault/mirage/handlers/ldap';
import authPage from 'vault/tests/pages/auth';
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 { deleteEngineCmd, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands';
import { LDAP_SELECTORS } from 'vault/tests/helpers/ldap/ldap-selectors';
module('Acceptance | ldap | roles', function (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) {
await click('[data-test-list-item-link]:nth-of-type(1) a');
assert.true(
isURL('roles/dynamic/dynamic-role/details', this.backend),
'Transitions to role details route on list item click'
);
let path;
await click('[data-test-breadcrumb="roles"] a');
await click('[data-test-list-item-link]:nth-of-type(2) a');
assert.true(
isURL('roles/static/static-role/details', this.backend),
'Transitions to role details route on list item click'
);
await click(LDAP_SELECTORS.roleItem('dynamic', 'dynamic-role'));
path = 'roles/dynamic/dynamic-role/details';
assertURL(assert, this.backend, path);
await click(GENERAL.breadcrumbLink('Roles'));
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) {
assert.expect(3);
for (const action of ['edit', 'get-creds', 'details']) {
await click('[data-test-popup-menu-trigger]');
await click(`[data-test-${action}]`);
await click(LDAP_SELECTORS.roleMenu('dynamic', 'dynamic-role'));
await click(LDAP_SELECTORS.action(action));
const uri = action === 'get-creds' ? 'credentials' : action;
assert.true(
isURL(`roles/dynamic/dynamic-role/${uri}`, this.backend),
`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) {
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]');
assert.true(
isURL('roles/dynamic/dynamic-role/credentials', this.backend),
@@ -100,4 +110,60 @@ module('Acceptance | ldap | roles', function (hooks) {
await click('[data-test-tab="roles"]');
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)}`;
};
export const assertURL = (assert, backend, path) => {
assert.strictEqual(currentURL(), baseURL(backend) + path, `url is ${path}`);
};
export const visitURL = (uri, backend = 'ldap-test') => {
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 hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { ldapRoleID } from 'vault/adapters/ldap/role';
module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (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' });
['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', {
modelName: 'ldap/role',
backend: 'ldap-test',
@@ -44,6 +46,8 @@ module('Integration | Component | ldap | Page::Role::CreateAndEdit', function (h
{ label: 'Create' },
];
this.fetchModel = (type, name) => this.store.peekRecord('ldap/role', ldapRoleID(type, name));
this.renderComponent = () => {
return render(
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();
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');
checkFields(['name', 'dn', 'username']);
checkTtl(['rotation_period']);
this.model = this.store.peekRecord('ldap/role', 'dynamic-role');
this.model = this.fetchModel('dynamic', 'dynamic-role');
await this.renderComponent();
checkFields(['name', 'username_template']);
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) {
this.model = this.store.peekRecord('ldap/role', 'static-role');
this.model = this.fetchModel('static', 'static-role');
const spy = sinon.spy(this.model, 'rollbackAttributes');
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');
});
this.model = this.store.peekRecord('ldap/role', 'static-role');
this.model = this.fetchModel('static', 'static-role');
await this.renderComponent();
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 sinon from 'sinon';
import { duration } from 'core/helpers/format-duration';
import { ldapRoleID } from 'vault/adapters/ldap/role';
module('Integration | Component | ldap | Page::Role::Details', function (hooks) {
setupRenderingTest(hooks);
@@ -25,6 +26,7 @@ module('Integration | Component | ldap | Page::Role::Details', function (hooks)
}));
this.renderComponent = (type) => {
const data = this.server.create('ldap-role', type);
data.id = ldapRoleID(type, data.name);
const store = this.owner.lookup('service:store');
store.pushPayload('ldap/role', {
modelName: 'ldap/role',
@@ -32,7 +34,7 @@ module('Integration | Component | ldap | Page::Role::Details', function (hooks)
type,
...data,
});
this.model = store.peekRecord('ldap/role', data.name);
this.model = store.peekRecord('ldap/role', ldapRoleID(type, data.name));
this.breadcrumbs = [
{ label: this.model.backend, route: 'overview' },
{ label: 'roles', route: 'roles' },

View File

@@ -12,6 +12,7 @@ import hbs from 'htmlbars-inline-precompile';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { createSecretsEngine, generateBreadcrumbs } from 'vault/tests/helpers/ldap/ldap-helpers';
import sinon from 'sinon';
import { LDAP_SELECTORS } from 'vault/tests/helpers/ldap/ldap-selectors';
module('Integration | Component | ldap | Page::Roles', function (hooks) {
setupRenderingTest(hooks);
@@ -95,7 +96,9 @@ module('Integration | Component | ldap | Page::Roles', function (hooks) {
await this.renderComponent();
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
.dom('[data-test-role-type-badge="static-test"]')
.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) {
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.pageFilter = 'foo';
@@ -129,10 +138,13 @@ module('Integration | Component | ldap | Page::Roles', function (hooks) {
await fillIn('[data-test-filter-input]', 'bar');
assert.true(
transitionStub.calledWith('vault.cluster.secrets.backend.ldap.roles', {
const [calledRoute, calledParams] = transitionStub.lastCall.args;
assert.strictEqual(calledRoute, currentRouteName);
assert.propEqual(
calledParams,
{
queryParams: { pageFilter: 'bar' },
}),
},
'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 { Response } from 'miragejs';
import sinon from 'sinon';
import { ldapRoleID } from 'vault/adapters/ldap/role';
module('Unit | Adapter | ldap/role', function (hooks) {
setupTest(hooks);
@@ -17,6 +18,18 @@ module('Unit | Adapter | ldap/role', function (hooks) {
this.store = this.owner.lookup('service:store');
this.adapter = this.store.adapterFor('ldap/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) {
@@ -24,7 +37,7 @@ module('Unit | Adapter | ldap/role', function (hooks) {
const assertRequest = (schema, req) => {
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] } };
};
@@ -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']) {
record.type = type;
const record = this.getModel(type);
await record.save();
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']) {
const record = getModel();
record.type = type;
const record = this.getModel(type);
await record.destroyRecord();
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 {
label: string;
route?: string;
icon?: string;
model?: string;
models?: string[];
linkExternal?: boolean;
}
@@ -73,12 +76,6 @@ export interface TtlEvent {
goSafeTimeString: string;
}
export interface Breadcrumb {
label: string;
route?: string;
linkExternal?: boolean;
}
export interface EngineOwner extends Owner {
mountPoint: string;
}

View File

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