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

LDAP Roles #22070

Merged
Merged
Show file tree
Hide file tree
Changes from 2 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
44 changes: 38 additions & 6 deletions ui/app/adapters/ldap/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@

import NamedPathAdapter from 'vault/adapters/named-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { inject as service } from '@ember/service';

export default class LdapRoleAdapter extends NamedPathAdapter {
@service flashMessages;

getURL(backend, path, name) {
const base = `${this.buildURL()}/${encodePath(backend)}/${path}`;
return name ? `${base}/${name}` : base;
Expand All @@ -26,12 +29,41 @@ export default class LdapRoleAdapter extends NamedPathAdapter {
return this.getURL(backend, this.pathForRoleType(type), name);
}

query(store, type, query) {
const { backend, type: roleType } = query;
const url = this.getURL(backend, this.pathForRoleType(roleType));
return this.ajax(url, 'GET', { data: { list: true } }).then((resp) => {
return resp.data.keys.map((name) => ({ name, backend, type: roleType }));
});
async query(store, type, query, recordArray, options) {
const { partialErrorInfo } = options.adapterOptions || {};
const { backend } = query;
const roles = [];
const errors = [];

for (const roleType of ['static', 'dynamic']) {
const url = this.getURL(backend, this.pathForRoleType(roleType));
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
try {
const models = await this.ajax(url, 'GET', { data: { list: true } }).then((resp) => {
return resp.data.keys.map((name) => ({ name, backend, type: roleType }));
});
roles.addObjects(models);
} catch (error) {
if (error.httpStatus !== 404) {
errors.push(error);
}
}
}

if (errors.length) {
const message = `Error fetching roles from ${errors.map((e) => e.path).join(' and ')}`;
const errorMessages = errors.map((e) => e.errors).flat();
if (errors.length === 2) {
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
// throw error as normal if both requests fail
// ignore status code and concat errors to be displayed in Page::Error component with generic message
throw { message, errors: errorMessages };
} else if (partialErrorInfo) {
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
// if only one request fails, surface the error to the user an info level flash message
// this may help for permissions errors where a users policy may be incorrect
this.flashMessages.info(`${message}. ${errorMessages.join(', ')}`);
}
}

return roles.sortBy('name');
}
queryRecord(store, type, query) {
const { backend, name, type: roleType } = query;
Expand Down
2 changes: 1 addition & 1 deletion ui/app/models/ldap/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default class LdapConfigModel extends Model {
@attr('string', {
label: 'Administrator Distinguished Name',
subText:
'Distinguished name of the adminstrator to bind (Bind DN) when performing user and group search. Example: cn=vault,ou=Users,dc=example,dc=com.',
'Distinguished name of the administrator to bind (Bind DN) when performing user and group search. Example: cn=vault,ou=Users,dc=example,dc=com.',
})
binddn;

Expand Down
98 changes: 98 additions & 0 deletions ui/lib/ldap/addon/components/page/roles.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarFilters>
{{#if (and (not @promptConfig) @roles)}}
<div class="field">
<p class="control has-icons-left">
<Input class="filter input" placeholder="Filter roles" data-test-roles-filter {{on "input" this.onFilterInput}} />
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
{{/if}}
</:toolbarFilters>
<:toolbarActions>
{{#if @promptConfig}}
<ToolbarLink @route="configure" data-test-toolbar-action="config">
Configure LDAP
</ToolbarLink>
{{else}}
<ToolbarLink @route="roles.create" @type="add" data-test-toolbar-action="role">
Create role
</ToolbarLink>
{{/if}}
</:toolbarActions>
</TabPageHeader>

{{#if @promptConfig}}
<ConfigCta />
{{else if (not this.filteredRoles)}}
{{#if this.filterValue}}
<EmptyState @title="There are no roles matching &quot;{{this.filterValue}}&quot;" />
{{else}}
<EmptyState
data-test-config-cta
@title="No roles created yet"
@message="Roles in Vault will allow you to manage LDAP credentials. Create a role to get started."
>
<LinkTo class="has-top-margin-xs" @route="roles.create">
Create role
</LinkTo>
</EmptyState>
{{/if}}
{{else}}
<div class="has-bottom-margin-s">
{{#each this.filteredRoles as |role|}}
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "roles.role.details" role.type role.name}} as |Item|>
<Item.content>
<Icon @name="user" />
<span data-test-role={{role.name}}>{{role.name}}</span>
<Hds::Badge @text={{role.type}} data-test-role-type-badge={{role.name}} />
</Item.content>
<Item.menu as |Menu|>
{{#if role.rolePath.isLoading}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
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}}
@disabled={{not role.canRead}}
>
Details
</LinkTo>
</li>
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-edit
@route="roles.role.edit"
@models={{array role.type role.name}}
@disabled={{not role.canEdit}}
>
Edit
</LinkTo>
</li>
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
{{#if role.canDelete}}
<li class="action">
<Menu.Message
data-test-delete
@id={{role.id}}
@triggerText="Delete"
@title="Are you sure?"
@message="Deleting this role means that you’ll need to recreate it in order to generate credentials again."
@onConfirm={{fn this.onDelete role}}
/>
</li>
{{/if}}
{{/if}}
</Item.menu>
</ListItem>
{{/each}}
</div>
{{/if}}
64 changes: 64 additions & 0 deletions ui/lib/ldap/addon/components/page/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { getOwner } from '@ember/application';
import { debounce } from '@ember/runloop';
import errorMessage from 'vault/utils/error-message';

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 { HTMLElementEvent } from 'vault/forms';

interface Args {
roles: Array<LdapRoleModel>;
promptConfig: boolean;
backendModel: SecretEngineModel;
filterValue: string;
breadcrumbs: Array<Breadcrumb>;
}

export default class LdapRolesPageComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;

@tracked filterValue = '';

get mountPoint(): string {
const owner = getOwner(this) as EngineOwner;
return owner.mountPoint;
}

get filteredRoles() {
const { roles } = this.args;
return this.filterValue
? roles.filter((role) => role.name.toLowerCase().includes(this.filterValue.toLowerCase()))
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
: roles;
}

@action onFilterInput(event: HTMLElementEvent<HTMLInputElement>) {
const setFilter = () => {
this.filterValue = event.target.value;
};
debounce(this, setFilter, 200);
}

@action
async onDelete(model: LdapRoleModel) {
try {
const message = `Successfully deleted role ${model.name}.`;
await model.destroyRecord();
this.args.roles.removeObject(model);
this.flashMessages.success(message);
} catch (error) {
const message = errorMessage(error, 'Error deleting role. Please try again or contact support.');
this.flashMessages.danger(message);
}
}
}
32 changes: 32 additions & 0 deletions ui/lib/ldap/addon/routes/error.ts
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

import type Transition from '@ember/routing/transition';
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
import type SecretEngineModel from 'vault/models/secret-engine';
import type { Breadcrumb } from 'vault/vault/app-types';
import type Controller from '@ember/controller';
import type SecretMountPath from 'vault/services/secret-mount-path';

interface LdapErrorController extends Controller {
breadcrumbs: Array<Breadcrumb>;
backend: SecretEngineModel;
}

export default class LdapErrorRoute extends Route {
@service declare readonly secretMountPath: SecretMountPath;

setupController(controller: LdapErrorController, resolvedModel: AdapterError, transition: Transition) {
super.setupController(controller, resolvedModel, transition);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' },
];
controller.backend = this.modelFor('application') as SecretEngineModel;
}
}
61 changes: 61 additions & 0 deletions ui/lib/ldap/addon/routes/roles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
import { hash } from 'rsvp';

import type Store from '@ember-data/store';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Transition from '@ember/routing/transition';
import type LdapRoleModel from 'vault/models/ldap/role';
import type SecretEngineModel from 'vault/models/secret-engine';
import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/vault/app-types';

interface LdapRolesRouteModel {
backendModel: SecretEngineModel;
promptConfig: boolean;
roles: Array<LdapRoleModel>;
}
interface LdapConfigurationController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: LdapRolesRouteModel;
}

@withConfig('ldap/config')
export default class LdapConfigurationRoute extends Route {
@service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath;

declare promptConfig: boolean;

async model() {
const backendModel = this.modelFor('application') as SecretEngineModel;
return hash({
backendModel,
promptConfig: this.promptConfig,
roles: this.store.query(
'ldap/role',
{ backend: backendModel.id },
{ adapterOptions: { partialErrorInfo: true } }
),
});
}

setupController(
controller: LdapConfigurationController,
resolvedModel: LdapRolesRouteModel,
transition: Transition
) {
super.setupController(controller, resolvedModel, transition);

controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backendModel.id },
];
}
}
2 changes: 1 addition & 1 deletion ui/lib/ldap/addon/routes/roles/role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface LdapRoleRouteParams {
type: string;
}

export default class LdapRoleEditRoute extends Route {
export default class LdapRoleRoute extends Route {
@service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath;

Expand Down
3 changes: 3 additions & 0 deletions ui/lib/ldap/addon/templates/error.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<TabPageHeader @model={{this.backend}} @breadcrumbs={{this.breadcrumbs}} />

<Page::Error @error={{this.model}} />
6 changes: 6 additions & 0 deletions ui/lib/ldap/addon/templates/roles/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Page::Roles
@roles={{this.model.roles}}
@promptConfig={{this.model.promptConfig}}
@backendModel={{this.model.backendModel}}
@breadcrumbs={{this.breadcrumbs}}
/>
Loading