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