-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UI: LDAP Hierarchical roles #28824
UI: LDAP Hierarchical roles #28824
Changes from all commits
e781dcc
cba2b0d
0679b42
c1ea53d
437dfca
52cf666
0284282
1f943d3
6d586b4
31c2ef3
b8c9b64
daca66d
c6672d0
46d82b9
e547d61
8efcdea
7688cb9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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}`; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is because ember data likes IDs and Vault doesn't return them. Pulled this into a helper here so it can be reused in tests and mirage so this didn't have to be updated in multiple places |
||
|
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added |
||
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); | ||
_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 }) }; | ||
}); | ||
} | ||
urlForDeleteRecord(name, modelName, snapshot) { | ||
const { backend, type } = snapshot.record; | ||
return this.getURL(backend, this.pathForRoleType(type), name); | ||
|
||
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'); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
// 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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)}} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 Does this need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is actually a template helper so it has slightly different syntax 😄 Docs There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL, thank you! |
||
<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}} | ||
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-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> | ||
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> | ||
{{/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}} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can't use
name
as the primaryKey because static and dynamic roles can technically have the same name