Ui/transform templates (#9981)

Add CRUD capabilities on transform templates. Disallow read or edit for built-ins.
This commit is contained in:
Chelsea Shaw
2020-09-17 14:08:06 -05:00
committed by GitHub
parent f337e3165e
commit f4cb33e4d4
13 changed files with 465 additions and 88 deletions

View File

@@ -55,12 +55,11 @@ export default ApplicationAdapter.extend({
queryRecord(store, type, query) {
return this.ajax(this.url(query.backend, type.modelName, query.id), 'GET').then(result => {
// CBS TODO: Add name to response and unmap name <> id on models
return {
id: query.id,
...result,
};
});
},
// buildUrl(modelName, id, snapshot, requestType, query, returns) {},
});

View File

@@ -0,0 +1,32 @@
/**
* @module TransformListItem
* TransformListItem components are used for the list items for the Transform Secret Engines for all but Transformations.
* This component automatically handles read-only list items if capabilities are not granted or the item is internal only.
*
* @example
* ```js
* <TransformListItem @item={item} @itemPath="role/my-item" @itemType="role" />
* ```
* @param {object} item - item refers to the model item used on the list item partial
* @param {string} itemPath - usually the id of the item, but can be prefixed with the model type (see transform/role)
* @param {string} [itemType] - itemType is used to calculate whether an item is readable or
*/
import { computed } from '@ember/object';
import Component from '@ember/component';
export default Component.extend({
item: null,
itemPath: '',
itemType: '',
itemViewable: computed('item', 'itemType', function() {
const item = this.get('item');
if (this.itemType === 'alphabet' || this.itemType === 'template') {
return !item.get('id').startsWith('builtin/');
}
return true;
}),
backendType: 'transform',
});

View File

@@ -0,0 +1,3 @@
import TransformBase from './transform-edit-base';
export default TransformBase.extend({});

View File

@@ -80,15 +80,14 @@ const SECRET_BACKENDS = {
editComponent: 'transform-role-edit',
},
{
name: 'templates',
name: 'template',
modelPrefix: 'template/',
label: 'Templates',
searchPlaceholder: 'Filter templates',
item: 'templates',
item: 'template',
create: 'Create template',
tab: 'templates',
tab: 'template',
editComponent: 'transform-template-edit',
hideCreate: true,
},
{
name: 'alphabets',

View File

@@ -1,7 +1,52 @@
import { computed } from '@ember/object';
import DS from 'ember-data';
import { apiPath } from 'vault/macros/lazy-capabilities';
import attachCapabilities from 'vault/lib/attach-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
export default DS.Model.extend({
name: DS.attr('string'),
alphabet: DS.belongsTo('transform/alphabet'),
transformations: DS.hasMany('transformation'),
const { attr } = DS;
const Model = DS.Model.extend({
idPrefix: 'template/',
idForNav: computed('id', 'idPrefix', function() {
let modelId = this.id || '';
return `${this.idPrefix}${modelId}`;
}),
name: attr('string', {
label: 'Name',
fieldValue: 'id',
readOnly: true,
subText:
'Templates allow Vault to determine what and how to capture the value to be transformed. This cannot be edited later.',
}),
type: attr('string', { defaultValue: 'regex' }),
pattern: attr('string', {
subText: 'The templates pattern defines the data format. Expressed in regex.',
}),
alphabet: attr('array', {
subText:
'Alphabet defines a set of characters (UTF-8) that is used for FPE to determine the validity of plaintext and ciphertext values. You can choose a built-in one, or create your own.',
editType: 'searchSelect',
fallbackComponent: 'string-list',
label: 'Alphabet',
models: ['transform/alphabet'],
selectLimit: 1,
}),
attrs: computed('pattern', 'alphabet', function() {
let keys = ['name', 'pattern', 'alphabet'];
return expandAttributeMeta(this, keys);
}),
editableAttrs: computed('pattern', 'alphabet', function() {
let keys = ['pattern', 'alphabet'];
return expandAttributeMeta(this, keys);
}),
backend: attr('string', { readOnly: true }),
});
export default attachCapabilities(Model, {
updatePath: apiPath`${'backend'}/template/${'id'}`,
});

View File

@@ -28,7 +28,7 @@ export default Route.extend({
case 'role':
modelType = 'transform/role';
break;
case 'templates':
case 'template':
modelType = 'transform/template';
break;
case 'alphabets':

View File

@@ -1,4 +1,5 @@
import ApplicationSerializer from '../application';
export default ApplicationSerializer.extend({
extractLazyPaginatedData(payload) {
let ret;

View File

@@ -0,0 +1,34 @@
import ApplicationSerializer from '../application';
export default ApplicationSerializer.extend({
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
payload.data.name = payload.id;
if (payload.data.alphabet) {
payload.data.alphabet = [payload.data.alphabet];
}
return this._super(store, primaryModelClass, payload, id, requestType);
},
serialize() {
let json = this._super(...arguments);
if (json.alphabet && Array.isArray(json.alphabet)) {
// Templates should only ever have one alphabet
json.alphabet = json.alphabet[0];
}
return json;
},
extractLazyPaginatedData(payload) {
let ret;
ret = payload.data.keys.map(key => {
let model = {
id: key,
};
if (payload.backend) {
model.backend = payload.backend;
}
return model;
});
return ret;
},
});

View File

@@ -0,0 +1,66 @@
{{#if (and itemViewable item.updatePath.canRead)}}
{{#linked-block
"vault.cluster.secrets.backend.show"
itemPath
class="list-item-row"
data-test-secret-link=itemPath
encode=true
queryParams=(secret-query-params backendType)
}}
<div class="columns is-mobile">
<div class="column is-10">
<SecretLink
@mode="show"
@secret={{item.id}}
@queryParams={{query-params type=modelType}}
@class="has-text-black has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</SecretLink>
</div>
<div class="column has-text-right">
{{#if (or item.updatePath.canRead item.updatePath.canUpdate)}}
<PopupMenu name="secret-menu">
<nav class="menu">
<ul class="menu-list">
{{#if item.updatePath.canRead}}
<li class="action">
<SecretLink
@mode="show"
@secret={{itemPath}}
@class="has-text-black has-text-weight-semibold">
Details
</SecretLink>
</li>
{{/if}}
{{#if item.updatePath.canUpdate}}
<li class="action">
<SecretLink
@mode="edit"
@secret={{itemPath}}
@class="has-text-black has-text-weight-semibold">
Edit
</SecretLink>
</li>
{{/if}}
</ul>
</nav>
</PopupMenu>
{{/if}}
</div>
</div>
{{/linked-block}}
{{else}}
<div class="list-item-row" data-test-view-only-list-item>
<div class="columns is-mobile">
<div class="column is-12 has-text-grey has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</div>
</div>
</div>
{{/if}}

View File

@@ -0,0 +1,123 @@
<PageHeader as |p|>
<p.top>
{{key-value-header
baseKey=(hash display=model.id id=model.idForNav)
path="vault.cluster.secrets.backend.list"
mode=mode
root=root
showCurrent=true
}}
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-secret-header="true">
{{#if (eq mode "create") }}
Create Template
{{else if (eq mode "edit")}}
Edit Template
{{else}}
Template <code>{{model.id}}</code>
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if (eq mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if capabilities.canDelete}}
<button
class="toolbar-link"
onclick={{action "delete"}}
data-test-transformation-template-delete
>
Delete template
</button>
{{/if}}
{{#if capabilities.canUpdate }}
<ToolbarSecretLink
@secret={{concat model.idPrefix model.id}}
@mode="edit"
@data-test-edit-link=true
@replace=true
>
Edit template
</ToolbarSecretLink>
{{/if}}
</ToolbarActions>
</Toolbar>
{{/if}}
{{#if (or (eq mode 'edit') (eq mode 'create'))}}
<form onsubmit={{action "createOrUpdate" mode}}>
<div class="box is-sideless is-fullwidth is-marginless">
{{message-error model=model}}
<NamespaceReminder @mode={{mode}} @noun="Transform role" />
{{#each model.attrs as |attr|}}
{{#if (and (eq attr.name 'name') (eq mode 'edit')) }}
<label for="{{attr.name}}" class="is-label">
{{attr.options.label}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
{{/if}}
<input
data-test-input={{attr.name}}
id={{attr.name}}
autocomplete="off"
spellcheck="false"
value={{or (get model attr.name) attr.options.defaultValue}}
readonly
class="field input is-readOnly"
type={{attr.type}}
/>
{{else}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/if}}
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{buttonDisabled}}
class="button is-primary"
data-test-role-transform-create=true
>
{{#if (eq mode 'create')}}
Create role
{{else if (eq mode 'edit')}}
Save
{{/if}}
</button>
{{#secret-link
mode=(if (eq mode "create") "list" "show")
class="button"
secret=(concat model.idPrefix model.id)
}}
Cancel
{{/secret-link}}
</div>
</div>
</form>
{{else}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each model.attrs as |attr|}}
{{#if (eq attr.type "object")}}
{{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(stringify (get model attr.name))}}
{{else if (eq attr.type "array")}}
{{info-table-row
label=(capitalize (or attr.options.label (humanize (dasherize attr.name))))
value=(get model attr.name)
type=attr.type
viewAll=attr.name
}}
{{else}}
{{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}}
{{/if}}
{{/each}}
</div>
{{/if}}

View File

@@ -1,77 +1,5 @@
{{!-- TODO do not let click if !canRead --}}
{{#if (eq options.item "role")}}
{{#let (concat options.modelPrefix item.id) as |itemPath|}}
{{#linked-block
"vault.cluster.secrets.backend.show"
itemPath
class="list-item-row"
data-test-secret-link=itemPath
encode=true
queryParams=(secret-query-params backendModel.type)
}}
<div class="columns is-mobile">
<div class="column is-10">
<SecretLink
@mode="show"
@secret={{item.id}}
@queryParams={{if (eq backendModel.type "transform") (query-params tab="actions") ""}}
@class="has-text-black has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</SecretLink>
</div>
<div class="column has-text-right">
{{#if (or item.updatePath.canRead item.updatePath.canUpdate)}}
<PopupMenu name="secret-menu">
<nav class="menu">
<ul class="menu-list">
{{#if (or item.versionPath.isLoading item.secretPath.isLoading)}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
{{#if item.updatePath.canRead}}
<li class="action">
<SecretLink
@mode="show"
@secret={{itemPath}}
@class="has-text-black has-text-weight-semibold">
Details
</SecretLink>
</li>
{{/if}}
{{#if item.updatePath.canUpdate}}
<li class="action">
<SecretLink
@mode="edit"
@secret={{itemPath}}
@class="has-text-black has-text-weight-semibold">
Edit
</SecretLink>
</li>
{{/if}}
{{/if}}
</ul>
</nav>
</PopupMenu>
{{/if}}
</div>
</div>
{{/linked-block}}
{{/let}}
{{else}}
<div class="list-item-row">
<div class="columns is-mobile">
<div class="column is-12 has-text-grey has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</div>
</div>
</div>
{{/if}}
<TransformListItem
@item={{item}}
@itemPath={{concat options.modelPrefix item.id}}
@itemType={{options.item}}
/>

View File

@@ -0,0 +1,121 @@
import EmberObject from '@ember/object';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, findAll, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | transform-list-item', function(hooks) {
setupRenderingTest(hooks);
test('it renders un-clickable item if no read capability', async function(assert) {
let item = EmberObject.create({
id: 'foo',
updatePath: {
canRead: false,
canDelete: true,
canUpdate: true,
},
});
this.set('itemPath', 'role/foo');
this.set('itemType', 'role');
this.set('item', item);
await render(hbs`<TransformListItem
@item={{item}}
@itemPath={{itemPath}}
@itemType={{itemType}}
/>`);
assert.dom('[data-test-view-only-list-item]').exists('shows view only list item');
assert.dom('[data-test-view-only-list-item]').hasText(item.id, 'has correct label');
});
test('it is clickable with details menu item if read capability', async function(assert) {
let item = EmberObject.create({
id: 'foo',
updatePath: {
canRead: true,
canDelete: false,
canUpdate: false,
},
});
this.set('itemPath', 'template/foo');
this.set('itemType', 'template');
this.set('item', item);
await render(hbs`<TransformListItem
@item={{item}}
@itemPath={{itemPath}}
@itemType={{itemType}}
/>`);
assert.dom('[data-test-secret-link="template/foo"]').exists('shows clickable list item');
await click('button.popup-menu-trigger');
assert.equal(findAll('.popup-menu-content li').length, 1, 'has one option');
});
test('it has details and edit menu item if read & edit capabilities', async function(assert) {
let item = EmberObject.create({
id: 'foo',
updatePath: {
canRead: true,
canDelete: true,
canUpdate: true,
},
});
this.set('itemPath', 'alphabet/foo');
this.set('itemType', 'alphabet');
this.set('item', item);
await render(hbs`<TransformListItem
@item={{item}}
@itemPath={{itemPath}}
@itemType={{itemType}}
/>`);
assert.dom('[data-test-secret-link="alphabet/foo"]').exists('shows clickable list item');
await click('button.popup-menu-trigger');
assert.equal(findAll('.popup-menu-content li').length, 2, 'has both options');
});
test('it is not clickable if built-in template with all capabilities', async function(assert) {
let item = EmberObject.create({
id: 'builtin/foo',
updatePath: {
canRead: true,
canDelete: true,
canUpdate: true,
},
});
this.set('itemPath', 'template/builtin/foo');
this.set('itemType', 'template');
this.set('item', item);
await render(hbs`<TransformListItem
@item={{item}}
@itemPath={{itemPath}}
@itemType={{itemType}}
/>`);
assert.dom('[data-test-view-only-list-item]').exists('shows view only list item');
assert.dom('[data-test-view-only-list-item]').hasText(item.id, 'has correct label');
});
test('it is not clickable if built-in alphabet', async function(assert) {
let item = EmberObject.create({
id: 'builtin/foo',
updatePath: {
canRead: true,
canDelete: true,
canUpdate: true,
},
});
this.set('itemPath', 'alphabet/builtin/foo');
this.set('itemType', 'alphabet');
this.set('item', item);
await render(hbs`<TransformListItem
@item={{item}}
@itemPath={{itemPath}}
@itemType={{itemType}}
/>`);
assert.dom('[data-test-view-only-list-item]').exists('shows view only list item');
assert.dom('[data-test-view-only-list-item]').hasText(item.id, 'has correct label');
});
});

View File

@@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | transform-template-edit', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{transform-template-edit}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#transform-template-edit}}
template block text
{{/transform-template-edit}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});