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

UI: LDAP Hierarchical roles #28824

Merged
merged 17 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
84 changes: 74 additions & 10 deletions ui/app/adapters/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,92 @@
* SPDX-License-Identifier: BUSL-1.1
*/

import NamedPathAdapter from 'vault/adapters/named-path';
import ApplicationAdapter from 'vault/adapters/application';
Copy link
Contributor Author

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

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}`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;

// 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) {
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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we're no longer extending the NamedPathAdapter, had to add these methods to manually return ID info

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: {
...data,
...this._constructRecord({ backend, name, type }),
},
};
});
}

createRecord() {
return this.createOrUpdate(...arguments);
}

updateRecord() {
return this.createOrUpdate(...arguments);
}
urlForDeleteRecord(name, modelName, snapshot) {
const { backend, type } = snapshot.record;

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));
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,10 +121,28 @@ 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 }));
return this.ajax(url, 'GET').then((resp) => ({
...resp.data,
...this._constructRecord({ backend, name, type: roleType }),
}));
}

fetchCredentials(backend, type, name) {
Expand Down
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
9 changes: 4 additions & 5 deletions ui/lib/core/addon/components/search-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,13 @@ export default class SearchSelect extends Component {
}

shouldShowCreate(id, searchResults) {
if (searchResults && searchResults.length && searchResults[0].groupName) {
if (this.args.disallowNewItems) return false;

if (searchResults?.length && searchResults[0].groupName) {
return !searchResults.some((group) => group.options.find((opt) => opt.id === id));
}
const existingOption =
this.dropdownOptions && this.dropdownOptions.find((opt) => opt.id === id || opt.name === id);
if (this.args.disallowNewItems && !existingOption) {
return false;
}
this.dropdownOptions && this.dropdownOptions.some((opt) => opt.id === id || opt.name === id);
return !existingOption;
}

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
25 changes: 22 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,34 @@ 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// this is to avoid more changes to search select and is okay because we are only selecting one item
.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
81 changes: 48 additions & 33 deletions ui/lib/ldap/addon/components/page/roles.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Does this need fn wrapped around, so it's clear you're passing role.name into this.isHierarchical()

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Screenshot 2024-11-05 at 4 19 15 PM

Copy link
Contributor

Choose a reason for hiding this comment

The 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>
Expand All @@ -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}}
Expand Down
16 changes: 14 additions & 2 deletions ui/lib/ldap/addon/components/page/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ 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';
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>;
Expand All @@ -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;
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions ui/lib/ldap/addon/controllers/roles/subdirectory.ts
Original file line number Diff line number Diff line change
@@ -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'];
}
Loading
Loading