From 30d4e21e880cf80056cfe285c5eec15834e12116 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Tue, 5 Nov 2024 16:52:29 -0800 Subject: [PATCH] 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 --- changelog/28824.txt | 6 ++ ui/app/adapters/ldap/role.js | 95 ++++++++++++++---- ui/app/models/ldap/role.js | 3 +- ui/app/serializers/ldap/role.js | 2 - .../core/addon/components/page/breadcrumbs.ts | 10 +- .../ldap/addon/components/page/overview.hbs | 10 +- ui/lib/ldap/addon/components/page/overview.ts | 26 ++++- ui/lib/ldap/addon/components/page/roles.hbs | 81 ++++++++------- ui/lib/ldap/addon/components/page/roles.ts | 16 ++- .../addon/controllers/roles/subdirectory.ts | 10 ++ ui/lib/ldap/addon/routes.js | 2 + ui/lib/ldap/addon/routes/configuration.ts | 12 +-- ui/lib/ldap/addon/routes/configure.ts | 8 +- ui/lib/ldap/addon/routes/overview.ts | 10 +- ui/lib/ldap/addon/routes/roles.ts | 31 ++++++ ui/lib/ldap/addon/routes/roles/create.ts | 8 +- ui/lib/ldap/addon/routes/roles/index.ts | 47 +++------ ui/lib/ldap/addon/routes/roles/role.ts | 10 +- .../addon/routes/roles/role/credentials.ts | 26 +++-- .../ldap/addon/routes/roles/role/details.ts | 26 ++--- ui/lib/ldap/addon/routes/roles/role/edit.ts | 12 ++- .../ldap/addon/routes/roles/subdirectory.ts | 75 ++++++++++++++ .../addon/templates/roles/subdirectory.hbs | 15 +++ ui/lib/ldap/addon/utils/ldap-breadcrumbs.ts | 36 +++++++ ui/mirage/handlers/ldap.js | 29 +++++- ui/mirage/scenarios/ldap.js | 6 ++ .../secrets/backend/ldap/roles-test.js | 98 ++++++++++++++++--- ui/tests/helpers/ldap/ldap-helpers.js | 4 + ui/tests/helpers/ldap/ldap-selectors.ts | 10 ++ .../ldap/page/role/create-and-edit-test.js | 14 ++- .../components/ldap/page/role/details-test.js | 4 +- .../components/ldap/page/roles-test.js | 24 +++-- ui/tests/unit/adapters/ldap/role-test.js | 36 +++---- ui/tests/unit/utils/ldap-breadcrumbs-test.js | 76 ++++++++++++++ ui/types/vault/app-types.ts | 9 +- ui/types/vault/models/ldap/role.d.ts | 2 + 36 files changed, 664 insertions(+), 225 deletions(-) create mode 100644 changelog/28824.txt create mode 100644 ui/lib/ldap/addon/controllers/roles/subdirectory.ts create mode 100644 ui/lib/ldap/addon/routes/roles.ts create mode 100644 ui/lib/ldap/addon/routes/roles/subdirectory.ts create mode 100644 ui/lib/ldap/addon/templates/roles/subdirectory.hbs create mode 100644 ui/lib/ldap/addon/utils/ldap-breadcrumbs.ts create mode 100644 ui/tests/helpers/ldap/ldap-selectors.ts create mode 100644 ui/tests/unit/utils/ldap-breadcrumbs-test.js diff --git a/changelog/28824.txt b/changelog/28824.txt new file mode 100644 index 0000000000..b770f286af --- /dev/null +++ b/changelog/28824.txt @@ -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 +``` diff --git a/ui/app/adapters/ldap/role.js b/ui/app/adapters/ldap/role.js index 5eb1399c76..93bb67db37 100644 --- a/ui/app/adapters/ldap/role.js +++ b/ui/app/adapters/ldap/role.js @@ -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'); } } diff --git a/ui/app/models/ldap/role.js b/ui/app/models/ldap/role.js index bb8ec1c30d..4c2dc68c2f 100644 --- a/ui/app/models/ldap/role.js +++ b/ui/app/models/ldap/role.js @@ -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', diff --git a/ui/app/serializers/ldap/role.js b/ui/app/serializers/ldap/role.js index 4d3e76a44c..023cbe75c9 100644 --- a/ui/app/serializers/ldap/role.js +++ b/ui/app/serializers/ldap/role.js @@ -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; diff --git a/ui/lib/core/addon/components/page/breadcrumbs.ts b/ui/lib/core/addon/components/page/breadcrumbs.ts index 76eb2a2e11..f8540f7c7e 100644 --- a/ui/lib/core/addon/components/page/breadcrumbs.ts +++ b/ui/lib/core/addon/components/page/breadcrumbs.ts @@ -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; } -interface Breadcrumb { - label: string; - route?: string; // Do not provide for current route - icon?: string; - model?: string; - models?: string[]; - linkToExternal?: boolean; -} /** * @module Page::Breadcrumbs diff --git a/ui/lib/ldap/addon/components/page/overview.hbs b/ui/lib/ldap/addon/components/page/overview.hbs index ed951c4335..12d00718bc 100644 --- a/ui/lib/ldap/addon/components/page/overview.hbs +++ b/ui/lib/ldap/addon/components/page/overview.hbs @@ -53,7 +53,10 @@ class="is-flex-half" />
- + <:content>
; } +interface Option { + id: string; + name: string; + type: string; +} + export default class LdapLibrariesPageComponent extends Component { @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) { - const model = this.args.roles.find((role) => role.name === roleName); - this.selectedRole = model; + async selectRole([option]: Array