Ui/transform role edit updates transformations (#9910)

* Update transform role delete button to be ConfirmAction with dropdown

* Set backend on fetched record so that it saves correctly

* Update transformation after role transformations changed works

* Clean up transform adapter

* Add role to allowed_roles on added transformations and remove from removed transformations on role save, with flash message

* Add backend to transform role model, and update serializer to add backend to paginated results

* Clean up error message handling

* Connect backend to transform roles list response

* Capabilities on transform roles is correct

* Fix cancel button on transform role edit location

* Fix model path

* Remove unnecessary tab param from controller

* Add backend to transform model
This commit is contained in:
Chelsea Shaw 2020-09-11 12:29:20 -05:00 committed by GitHub
parent cb52dec2a2
commit 2ea627aed3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 236 additions and 76 deletions

View File

@ -66,22 +66,17 @@ export default ApplicationAdapter.extend({
results.forEach(result => {
if (result.value) {
if (result.value.data.roles) {
// TODO: Check if this is needed and remove if not
resp.data = assign({}, resp.data, { zero_address_roles: result.value.data.roles });
} else {
let d = result.value.data;
if (d.templates) {
// In Transformations data goes up as "template", but comes down as "templates"
// To keep the keys consistent we're translating here
d = {
...d,
template: d.templates,
};
delete d.templates;
}
resp.data = assign({}, resp.data, d);
let d = result.value.data;
if (d.templates) {
// In Transformations data goes up as "template", but comes down as "templates"
// To keep the keys consistent we're translating here
d = {
...d,
template: d.templates,
};
delete d.templates;
}
resp.data = assign({}, resp.data, d);
}
});
return resp;

View File

@ -42,7 +42,10 @@ export default ApplicationAdapter.extend({
fetchByQuery(query) {
const { backend, modelName, id } = query;
return this.ajax(this.url(backend, modelName, id), 'GET').then(resp => {
return resp;
return {
...resp,
backend,
};
});
},

View File

@ -1,22 +1,17 @@
import { inject as service } from '@ember/service';
import { or } from '@ember/object/computed';
import { isBlank } from '@ember/utils';
import { task, waitForEvent } from 'ember-concurrency';
import Component from '@ember/component';
import { set, get } from '@ember/object';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
import keys from 'vault/lib/keycodes';
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
export default Component.extend(FocusOnInsertMixin, {
router: service(),
wizard: service(),
mode: null,
// TODO: Investigate if we need all of these
emptyData: '{\n}',
onDataChange() {},
onRefresh() {},
model: null,
@ -34,15 +29,6 @@ export default Component.extend(FocusOnInsertMixin, {
}
},
waitForKeyUp: task(function*() {
while (true) {
let event = yield waitForEvent(document.body, 'keyup');
this.onEscape(event);
}
})
.on('didInsertElement')
.cancelOn('willDestroyElement'),
transitionToRoute() {
this.get('router').transitionTo(...arguments);
},
@ -54,45 +40,33 @@ export default Component.extend(FocusOnInsertMixin, {
}
return modelPrefix;
},
onEscape(e) {
if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') {
return;
}
this.transitionToRoute(LIST_ROOT_ROUTE);
},
hasDataChanges() {
get(this, 'onDataChange')(get(this, 'model.hasDirtyAttributes'));
},
persist(method, successCallback) {
const model = get(this, 'model');
return model[method]().then(() => {
if (!get(model, 'isError')) {
if (this.get('wizard.featureState') === 'role') {
this.get('wizard').transitionFeatureMachine('role', 'CONTINUE', this.get('backendType'));
}
successCallback(model);
}
successCallback(model);
});
},
applyChanges(type, callback = () => {}) {
const modelId = this.get('model.id') || this.get('model.name'); // transform comes in as model.name
const modelPrefix = this.modelPrefixFromType(this.get('model.constructor.modelName'));
// prevent from submitting if there's no key
// maybe do something fancier later
if (type === 'create' && isBlank(modelId)) {
return;
}
this.persist('save', () => {
callback();
this.transitionToRoute(SHOW_ROUTE, `${modelPrefix}${modelId}`);
});
},
actions: {
createOrUpdate(type, event) {
event.preventDefault();
const modelId = this.get('model.id') || this.get('model.name'); // transform comes in as model.name
const modelPrefix = this.modelPrefixFromType(this.get('model.constructor.modelName'));
// prevent from submitting if there's no key
// maybe do something fancier later
if (type === 'create' && isBlank(modelId)) {
return;
}
this.persist('save', () => {
this.hasDataChanges();
this.transitionToRoute(SHOW_ROUTE, `${modelPrefix}${modelId}`);
});
this.applyChanges(type);
},
setValue(key, event) {

View File

@ -1,3 +1,130 @@
import TransformBase from './transform-edit-base';
import { inject as service } from '@ember/service';
export default TransformBase.extend({});
const addToList = (list, itemToAdd) => {
if (!list || !Array.isArray(list)) return list;
list.push(itemToAdd);
return list.uniq();
};
const removeFromList = (list, itemToRemove) => {
if (!list) return list;
const index = list.indexOf(itemToRemove);
if (index < 0) return list;
const newList = list.removeAt(index, 1);
return newList.uniq();
};
export default TransformBase.extend({
store: service(),
flashMessages: service(),
initialTransformations: null,
init() {
this._super(...arguments);
this.set('initialTransformations', this.get('model.transformations'));
},
handleUpdateTransformations(updateTransformations, roleId, type = 'update') {
if (!updateTransformations) return;
const backend = this.get('model.backend');
const promises = updateTransformations.map(transform => {
return this.store
.queryRecord('transform', {
backend,
id: transform.id,
})
.then(function(transformation) {
let roles = transformation.allowed_roles;
if (transform.action === 'ADD') {
roles = addToList(roles, roleId);
} else if (transform.action === 'REMOVE') {
roles = removeFromList(roles, roleId);
}
transformation.setProperties({
backend,
allowed_roles: roles,
});
return transformation
.save()
.then(() => {
return 'Successfully saved';
})
.catch(e => {
return { errorStatus: e.httpStatus, ...transform };
});
});
});
Promise.all(promises).then(res => {
let hasError = res.find(r => !!r.errorStatus);
if (hasError) {
let errorAdding = res.find(r => r.errorStatus === 403 && r.action === 'ADD');
let errorRemoving = res.find(r => r.errorStatus === 403 && r.action === 'REMOVE');
let message =
'The edits to this role were successful, but allowed_roles for its transformations was not edited due to a lack of permissions.';
if (type === 'create') {
message =
'Transformations have been attached to this role, but the role was not added to those transformations allowed_roles due to a lack of permissions.';
} else if (errorAdding && errorRemoving) {
message =
'This role was edited to both add and remove transformations; however, this role was not added or removed from those transformations allowed_roles due to a lack of permissions.';
} else if (errorAdding) {
message =
'This role was edited to include new transformations, but this role was not added to those transformations allowed_roles due to a lack of permissions.';
} else if (errorRemoving) {
message =
'This role was edited to remove transformations, but this role was not removed from those transformations allowed_roles due to a lack of permissions.';
}
this.get('flashMessages').stickyInfo(message);
}
});
},
actions: {
createOrUpdate(type, event) {
event.preventDefault();
this.applyChanges('save', () => {
const roleId = this.get('model.id');
const newModelTransformations = this.get('model.transformations');
if (!this.initialTransformations) {
this.handleUpdatedTransformations(
newModelTransformations.map(t => ({
id: t,
action: 'ADD',
})),
roleId,
type
);
return;
}
const updateTransformations = [...newModelTransformations, ...this.initialTransformations]
.map(t => {
if (this.initialTransformations.indexOf(t) < 0) {
return {
id: t,
action: 'ADD',
};
}
if (newModelTransformations.indexOf(t) < 0) {
return {
id: t,
action: 'REMOVE',
};
}
return null;
})
.filter(t => !!t);
this.handleUpdateTransformations(updateTransformations, roleId);
});
},
},
});

View File

@ -90,9 +90,12 @@ const Model = DS.Model.extend({
transformFieldAttrs: computed('transformAttrs', function() {
return expandAttributeMeta(this, this.get('transformAttrs'));
}),
backend: attr('string', {
readOnly: true,
}),
});
export default attachCapabilities(Model, {
// TODO: Update to dynamic backend name
updatePath: apiPath`transform/transformation/${'id'}`,
updatePath: apiPath`${'backend'}/transformation/${'id'}`,
});

View File

@ -36,6 +36,8 @@ const Model = DS.Model.extend({
let keys = ['name', 'transformations'];
return expandAttributeMeta(this, keys);
}),
backend: attr('string', { readOnly: true }),
});
export default attachCapabilities(Model, {

View File

@ -54,7 +54,7 @@ export default Route.extend({
beforeModel() {
let secret = this.secretParam();
let backend = this.enginePathParam();
let { tab } = this.paramsFor('vault.cluster.secrets.backend');
let { tab } = this.paramsFor('vault.cluster.secrets.backend.list-root');
let secretEngine = this.store.peekRecord('secret-engine', backend);
let type = secretEngine && secretEngine.get('engineType');
if (!type || !SUPPORTED_BACKENDS.includes(type)) {

View File

@ -17,7 +17,7 @@ export default Route.extend(UnloadModelRoute, {
let { backend } = this.paramsFor('vault.cluster.secrets.backend');
return backend;
},
capabilities(secret) {
capabilities(secret, modelType) {
const backend = this.enginePathParam();
let backendModel = this.modelFor('vault.cluster.secrets.backend');
let backendType = backendModel.engineType;
@ -28,12 +28,38 @@ export default Route.extend(UnloadModelRoute, {
path = backend + '/keys/' + secret;
} else if (backendType === 'ssh' || backendType === 'aws') {
path = backend + '/roles/' + secret;
} else if (modelType.startsWith('transform/')) {
path = this.buildTransformPath(backend, secret, modelType);
} else {
path = backend + '/' + secret;
}
return this.store.findRecord('capabilities', path);
},
buildTransformPath(backend, secret, modelType) {
let noun = modelType.split('/')[1];
return `${backend}/${noun}/${secret}`;
},
modelTypeForTransform(secretName) {
if (!secretName) return 'transform';
if (secretName.startsWith('role/')) {
return 'transform/role';
}
if (secretName.startsWith('template/')) {
return 'transform/template';
}
if (secretName.startsWith('alphabet/')) {
return 'transform/alphabet';
}
return 'transform'; // TODO: transform/transformation;
},
transformSecretName(secret, modelType) {
const noun = modelType.split('/')[1];
return secret.replace(`${noun}/`, '');
},
backendType() {
return this.modelFor('vault.cluster.secrets.backend').get('engineType');
},
@ -71,7 +97,7 @@ export default Route.extend(UnloadModelRoute, {
let types = {
transit: 'transit-key',
ssh: 'role-ssh',
transform: secret && secret.startsWith('role/') ? 'transform/role' : 'transform', // CBS TODO: switch out better
transform: this.modelTypeForTransform(secret),
aws: 'role-aws',
pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki',
cubbyhole: 'secret',
@ -199,12 +225,11 @@ export default Route.extend(UnloadModelRoute, {
secret = secret.replace('cert/', '');
}
if (modelType.startsWith('transform/')) {
// CBS TODO: we'll have more things to replace than just role/
secret = secret.replace('role/', '');
secret = this.transformSecretName(secret, modelType);
}
let secretModel;
let capabilities = this.capabilities(secret);
let capabilities = this.capabilities(secret, modelType);
try {
secretModel = await this.store.queryRecord(modelType, { id: secret, backend });
} catch (err) {

View File

@ -16,4 +16,18 @@ export default ApplicationSerializer.extend({
}
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,17 @@
import ApplicationSerializer from '../application';
export default ApplicationSerializer.extend({
extractLazyPaginatedData(payload) {
// TODO: do this for transform too?
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

@ -24,17 +24,17 @@
{{#if (eq mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if (or capabilities.canUpdate capabilities.canDelete)}}
<div class="toolbar-separator" />
{{/if}}
{{#if capabilities.canDelete}}
<a
class="toolbar-link"
onclick={{action "delete"}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action "delete"}}
@confirmTitle="Are you sure?"
@confirmMessage="Deleting this role means that youll need to recreate it and reassign any existing transformations to use it again."
@confirmButtonText="Delete"
data-test-transformation-role-delete
>
Delete role
</a>
</ConfirmAction>
{{/if}}
{{#if capabilities.canUpdate }}
<ToolbarSecretLink
@ -80,7 +80,7 @@
{{#secret-link
mode=(if (eq mode "create") "list" "show")
class="button"
secret=model.id
secret=(concat "role/" model.id)
}}
Cancel
{{/secret-link}}