Skip to content
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

Backport 1.18.x: UI LDAP Hierarchical roles #28842

Merged
merged 5 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/28824.txt
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
```
93 changes: 76 additions & 17 deletions ui/app/adapters/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
_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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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');
}
}
3 changes: 2 additions & 1 deletion ui/app/models/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 0 additions & 2 deletions ui/app/serializers/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 2 additions & 8 deletions ui/lib/core/addon/components/page/breadcrumbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions ui/lib/ldap/addon/components/page/overview.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,25 @@
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
class="is-flex-1"
@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
Expand Down
26 changes: 23 additions & 3 deletions ui/lib/ldap/addon/components/page/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
82 changes: 49 additions & 33 deletions ui/lib/ldap/addon/components/page/roles.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -43,48 +43,62 @@
{{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.Interactive @text="Edit" data-test-edit @route="roles.role.edit" @models={{array role.type role.name}} />
{{/if}}
{{#if role.canReadCreds}}
<dd.Interactive
@text="Get credentials"
data-test-get-creds
@route="roles.role.credentials"
@models={{array role.type role.name}}
/>
{{/if}}
{{#if role.canRotateStaticCreds}}
<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
@text="Rotate credentials"
data-test-rotate-creds
@color="critical"
{{on "click" (fn (mut this.credsToRotate) role)}}
@text="Content"
data-test-subdirectory
@route="roles.subdirectory"
@models={{array role.type (concat role.path_to_role role.name)}}
/>
{{/if}}
<dd.Interactive
@text="Details"
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}}
/>
{{#if role.canDelete}}
{{else}}
{{#if role.canEdit}}
<dd.Interactive @text="Edit" data-test-edit @route="roles.role.edit" @models={{array role.type role.name}} />
{{/if}}
{{#if role.canReadCreds}}
<dd.Interactive
@text="Get credentials"
data-test-get-creds
@route="roles.role.credentials"
@models={{array role.type role.name}}
/>
{{/if}}
{{#if role.canRotateStaticCreds}}
<dd.Interactive
@text="Rotate credentials"
data-test-rotate-creds
@color="critical"
{{on "click" (fn (mut this.credsToRotate) role)}}
/>
{{/if}}
<dd.Interactive
@text="Delete"
data-test-delete
@color="critical"
{{on "click" (fn (mut this.roleToDelete) role)}}
@text="Details"
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}}
/>
{{#if role.canDelete}}
<dd.Interactive
@text="Delete"
data-test-delete
@color="critical"
{{on "click" (fn (mut this.roleToDelete) role)}}
/>
{{/if}}
{{/if}}
</Hds::Dropdown>
</Item.menu>
Expand All @@ -110,7 +124,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}}
Expand Down
Loading
Loading