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

Kubernetes Secrets Engine Create/Edit Views #18271

Merged
Merged
Show file tree
Hide file tree
Changes from 4 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: 3 additions & 3 deletions ui/app/adapters/kubernetes/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export default class KubernetesRoleAdapter extends NamedPathAdapter {
queryRecord(store, type, query) {
const { backend, name } = query;
return this.ajax(this.getURL(backend, name), 'GET').then((resp) => {
resp.backend = backend;
resp.name = name;
return resp;
resp.data.backend = backend;
resp.data.name = name;
return resp.data;
});
}
}
18 changes: 11 additions & 7 deletions ui/app/models/kubernetes/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Model, { attr } from '@ember-data/model';
import { withModelValidations } from 'vault/decorators/model-validations';
import { withFormFields } from 'vault/decorators/model-form-fields';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { tracked } from '@glimmer/tracking';

const validations = {
name: [{ type: 'presence', message: 'Name is required' }],
Expand Down Expand Up @@ -52,7 +53,7 @@ export default class KubernetesRoleModel extends Model {
})
serviceAccountName;

@attr('array', {
@attr('string', {
label: 'Allowed Kubernetes namespaces',
subText:
'A list of the valid Kubernetes namespaces in which this role can be used for creating service accounts. If set to "*" all namespaces are allowed.',
Expand Down Expand Up @@ -85,6 +86,7 @@ export default class KubernetesRoleModel extends Model {

@attr('string') generatedRoleRules;

@tracked _generationPreference;
get generationPreference() {
// when the user interacts with the radio cards the value will be set to the pseudo prop which takes precedence
if (this._generationPreference) {
Expand All @@ -102,20 +104,22 @@ export default class KubernetesRoleModel extends Model {
return pref;
}
set generationPreference(pref) {
// unset related model props
// unset model props specific to filteredFormFields when changing preference
// only one of service_account_name, kubernetes_role_name or generated_role_rules can be set
// these correspond to the 3 options for role generation
this.serviceAccountName = null;
this.kubernetesRoleName = null;
this.generatedRoleRules = null;
const props = {
basic: ['kubernetesRoleType', 'kubernetesRoleName', 'generatedRoleRules', 'nameTemplate'],
expanded: ['serviceAccountName', 'generatedRoleRules'],
full: ['serviceAccountName', 'kubernetesRoleName'],
}[pref];
props.forEach((prop) => (this[prop] = null));
this._generationPreference = pref;
}

get filteredFormFields() {
// return different form fields based on generationPreference
const hiddenFieldIndices = {
basic: [2, 3, 7], // kubernetesRoleType, kubernetesRoleName and nameTemplate
expanded: [1, 7], // serviceAccountName and nameTemplate
expanded: [1], // serviceAccountName
full: [1, 3], // serviceAccountName and kubernetesRoleName
}[this.generationPreference];

Expand Down
1 change: 1 addition & 0 deletions ui/lib/core/app/components/json-editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'core/components/json-editor';
1 change: 1 addition & 0 deletions ui/lib/core/app/components/kv-object-editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'core/components/kv-object-editor';
1 change: 1 addition & 0 deletions ui/lib/core/app/modifiers/code-mirror.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'core/modifiers/code-mirror';
4 changes: 3 additions & 1 deletion ui/lib/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"ember-wormhole": "*",
"escape-string-regexp": "*",
"@hashicorp/ember-flight-icons": "*",
"@hashicorp/flight-icons": "*"
"@hashicorp/flight-icons": "*",
"codemirror": "*",
"ember-modifier": "*"
}
}
139 changes: 139 additions & 0 deletions ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
{{if @model.isNew "Create role" "Edit role"}}
</h1>
</p.levelLeft>
</PageHeader>

<hr class="is-marginless has-background-gray-200" />

<p class="has-top-margin-m">
A role in Vault dictates what will be generated for Kubernetes and what kind of rules will be used to do so. It is not a
Kubernetes role.
</p>

<div class="is-flex-row has-top-margin-s">
{{#each this.generationPreferences as |pref|}}
<RadioCard
@title={{pref.title}}
@description={{pref.description}}
@icon="token"
@value={{pref.value}}
@groupValue={{@model.generationPreference}}
@onChange={{fn (mut @model.generationPreference)}}
data-test-radio-card={{pref.value}}
/>
{{/each}}
</div>

<div class="has-top-margin-xl has-bottom-margin-l">
<h2 class="title is-4">
Role options
</h2>
<hr class="is-marginless has-background-gray-200" />
</div>

{{#if @model.generationPreference}}
<form id="role" {{on "submit" this.onSave}} data-test-policy-form>
{{#each @model.filteredFormFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}

<div class="has-bottom-margin-s">
<ToggleButton
data-test-field="annotations"
@isOpen={{this.showAnnotations}}
@openLabel="Hide annotations and labels"
@closedLabel="Annotations and labels"
@onClick={{fn (mut this.showAnnotations)}}
/>
{{#if this.showAnnotations}}
<div class="box" data-test-annotations>
{{#each this.extraFields as |field|}}
<div class={{if (eq field.type "labels") "has-top-margin-xl"}}>
<h2 class="title is-4">Extra {{field.type}}</h2>
<p>
{{field.description}}
See
<ExternalLink @href="https://kubernetes.io/docs/concepts/overview/working-with-objects/{{field.type}}/">
Kubernetes
{{singularize field.type}}
documentation here
</ExternalLink>.
</p>
<KvObjectEditor
class="has-top-margin-m"
data-test-kv={{field.type}}
@value={{get @model field.key}}
@onChange={{fn (mut (get @model field.key))}}
/>
</div>
<hr />
{{/each}}
</div>
{{/if}}
</div>

{{#if (eq @model.generationPreference "full")}}
<div class="has-top-margin-m has-bottom-margin-l" data-test-generated-role-rules>
<h2 class="title is-4">
Generated role rules
</h2>
<FormFieldLabel
for="templates"
@label="Role rules template"
@subText="Start with a template for role rules based on your use case"
/>
<div class="select is-fullwidth">
<select id="templates" data-test-select-template {{on "change" this.selectTemplate}}>
{{#each this.roleRulesTemplates as |template|}}
<option selected={{eq this.selectedTemplateId template.id}} value={{template.id}}>
{{template.label}}
</option>
{{/each}}
</select>
</div>
{{#let (find-by "id" this.selectedTemplateId this.roleRulesTemplates) as |template|}}
<JsonEditor
class="has-top-margin-l"
data-test-rules
@title="Role rules"
@value={{template.rules}}
@mode="ruby"
@valueUpdated={{fn (mut template.rules)}}
@helpText={{this.roleRulesHelpText}}
>
<button type="button" class="toolbar-link" onClick={{this.resetRoleRules}} data-test-restore-example>
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
Restore example
<Icon @name="reload" />
</button>
</JsonEditor>
{{/let}}
</div>
{{/if}}
</form>
{{else}}
<EmptyState
class="is-shadowless"
@title="Choose an option above"
@message="To configure a Vault role, choose what should be generated in Kubernetes by Vault."
/>
{{/if}}

<hr class="is-marginless has-background-gray-200" />

<div class="has-top-margin-l has-bottom-margin-s">
<button
type="submit"
form="role"
class="button is-primary"
disabled={{or (not @model.generationPreference) this.save.isRunning}}
data-test-save
>
Save
</button>
<button type="button" class="button has-left-margin-s" data-test-cancel {{on "click" this.cancel}}>
Back
</button>
</div>
130 changes: 130 additions & 0 deletions ui/lib/kubernetes/addon/components/page/role/create-and-edit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { getRules } from '../../../utils/generated-role-rules';
import { htmlSafe } from '@ember/template';
import errorMessage from 'vault/utils/error-message';

export default class CreateAndEditRolePageComponent extends Component {
@service router;
@service flashMessages;

@tracked roleRulesTemplates;
@tracked selectedTemplateId = '1';
@tracked modelValidations;

constructor() {
super(...arguments);
// first check if generatedRoleRules matches one of the templates, the user may have chosen a template and not made changes
// in this case we need to select the corresponding template in the dropdown
// if there is no match then replace the example rules with the user defined value for no template option
const { generatedRoleRules } = this.args.model;
const rulesTemplates = getRules();
if (generatedRoleRules) {
const template = rulesTemplates.findBy('rules', generatedRoleRules);
if (template) {
this.selectedTemplateId = template.id;
} else {
rulesTemplates.findBy('1').rules = generatedRoleRules;
}
}
this.roleRulesTemplates = rulesTemplates;
}

get generationPreferences() {
return [
{
title: 'Generate token only using existing service account',
description:
'Enter a service account that already exists in Kubernetes and Vault will dynamically generate a token.',
value: 'basic',
},
{
title: 'Generate token, service account, and role binding objects',
description:
'Enter a pre-existing role (or ClusterRole) to use. Vault will generate a token, a service account and role binding objects.',
value: 'expanded',
},
{
title: 'Generate entire Kubernetes object chain',
description:
'Vault will generate the entire chain— a role, a token, a service account, and role binding objects— based on rules you supply.',
value: 'full',
},
];
}

get extraFields() {
return [
{
type: 'annotations',
key: 'extraAnnotations',
description: 'Attach arbitrary non-identifying metadata to objects.',
},
{
type: 'labels',
key: 'extraLabels',
description:
'Labels specify identifying attributes of objects that are meaningful and relevant to users.',
},
];
}

get roleRulesHelpText() {
const message =
'This specifies the Role or ClusterRole rules to use when generating a role. Kubernetes documentation is';
const link =
'<a href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/" target="_blank" rel="noopener noreferrer">available here</>';
return htmlSafe(`${message} ${link}.`);
}

@action
resetRoleRules() {
this.roleRulesTemplates = getRules();
}

@action
selectTemplate(event) {
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
this.selectedTemplateId = event.target.value;
this.args.model.generationPreferences;
zofskeez marked this conversation as resolved.
Show resolved Hide resolved
}

@task
@waitFor
*save() {
try {
yield this.args.model.save();
this.router.transitionTo(
'vault.cluster.secrets.backend.kubernetes.roles.role.details',
this.args.model.name
);
} catch (error) {
const message = errorMessage(error, 'Error saving role. Please try again or contact support');
this.flashMessages.danger(message);
}
}

@action
async onSave(event) {
event.preventDefault();
const { isValid, state } = await this.args.model.validate();
if (isValid) {
this.modelValidations = null;
this.save.perform();
} else {
this.flashMessages.info('Save not performed. Check form for errors');
this.modelValidations = state;
}
}

@action
cancel() {
const { model } = this.args;
const method = model.isNew ? 'unloadRecord' : 'rollbackAttributes';
model[method]();
this.router.transitionTo('vault.cluster.secrets.backend.kubernetes.roles');
}
}
11 changes: 10 additions & 1 deletion ui/lib/kubernetes/addon/routes/roles/create.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class KubernetesRolesCreateRoute extends Route {}
export default class KubernetesRolesCreateRoute extends Route {
@service store;
@service secretMountPath;

model() {
const backend = this.secretMountPath.get();
return this.store.createRecord('kubernetes/role', { backend });
}
}
12 changes: 11 additions & 1 deletion ui/lib/kubernetes/addon/routes/roles/role/edit.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class KubernetesRoleEditRoute extends Route {}
export default class KubernetesRoleEditRoute extends Route {
@service store;
@service secretMountPath;

model() {
const backend = this.secretMountPath.get();
const { name } = this.paramsFor('roles.role');
return this.store.queryRecord('kubernetes/role', { backend, name });
}
}
2 changes: 1 addition & 1 deletion ui/lib/kubernetes/addon/templates/roles/create.hbs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Roles Create
<Page::Role::CreateAndEdit @model={{this.model}} />
2 changes: 1 addition & 1 deletion ui/lib/kubernetes/addon/templates/roles/role/edit.hbs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Role Edit
<Page::Role::CreateAndEdit @model={{this.model}} />
Loading