UI - kv v2 graceful degrade (#5879)

* turns out sourcemaps are useful

* add test for restricted policy in kv v2

* only include version param on fetch if it's encoded in the id

* rename some vars for clarity and use model.id when persisting a secret

* fix delete attributes on the models

* allow data edit when there's metadata access is disallowed

* add tests for edit with restricted policy

* hide metadata fields if you can't edit them
This commit is contained in:
Matthew Irish 2018-12-03 08:22:13 -06:00 committed by GitHub
parent c2e87c20d8
commit af8eda9322
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 145 additions and 46 deletions

View file

@ -16,12 +16,12 @@ export default ApplicationAdapter.extend({
urlForFindRecord(id) { urlForFindRecord(id) {
let [backend, path, version] = JSON.parse(id); let [backend, path, version] = JSON.parse(id);
return this._url(backend, path) + `?version=${version}`; let base = this._url(backend, path);
return version ? base + `?version=${version}` : base;
}, },
urlForQueryRecord(id) { urlForQueryRecord(id) {
let [backend, path, version] = JSON.parse(id); return this.urlForFindRecord(id);
return this._url(backend, path) + `?version=${version}`;
}, },
findRecord() { findRecord() {

View file

@ -109,7 +109,7 @@ export default Component.extend(FocusOnInsertMixin, {
'model.id', 'model.id',
'mode' 'mode'
), ),
canDelete: alias('updatePath.canDelete'), canDelete: alias('model.canDelete'),
canEdit: alias('updatePath.canUpdate'), canEdit: alias('updatePath.canUpdate'),
v2UpdatePath: maybeQueryRecord( v2UpdatePath: maybeQueryRecord(
@ -181,19 +181,21 @@ export default Component.extend(FocusOnInsertMixin, {
// successCallback is called in the context of the component // successCallback is called in the context of the component
persistKey(successCallback) { persistKey(successCallback) {
let secret = this.model; let secret = this.model;
let model = this.modelForData; let secretData = this.modelForData;
let isV2 = this.isV2; let isV2 = this.isV2;
let key = model.get('path') || model.id; let key = secretData.get('path') || secret.id;
if (key.startsWith('/')) { if (key.startsWith('/')) {
key = key.replace(/^\/+/g, ''); key = key.replace(/^\/+/g, '');
model.set(model.pathAttr, key); secretData.set(secretData.pathAttr, key);
} }
return model.save().then(() => { return secretData.save().then(() => {
if (!model.isError) { if (!secretData.isError) {
if (isV2 && Object.keys(secret.changedAttributes()).length) { if (isV2) {
secret.set('id', key); secret.set('id', key);
}
if (isV2 && Object.keys(secret.changedAttributes()).length) {
// save secret metadata // save secret metadata
secret secret
.save() .save()
@ -296,8 +298,8 @@ export default Component.extend(FocusOnInsertMixin, {
return; return;
} }
this.persistKey(key => { this.persistKey(() => {
this.transitionToRoute(SHOW_ROUTE, key); this.transitionToRoute(SHOW_ROUTE, this.model.id);
}); });
}, },

View file

@ -33,6 +33,6 @@ export default Model.extend(KeyMixin, {
secretPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'), secretPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'),
canEdit: alias('versionPath.canUpdate'), canEdit: alias('versionPath.canUpdate'),
canDelete: alias('secretPath.canUpdate'), canDelete: alias('secretPath.canDelete'),
canRead: alias('secretPath.canRead'), canRead: alias('secretPath.canRead'),
}); });

View file

@ -27,6 +27,6 @@ export default DS.Model.extend(KeyMixin, {
backend: attr('string'), backend: attr('string'),
secretPath: lazyCapabilities(apiPath`${'backend'}/${'id'}`, 'backend', 'id'), secretPath: lazyCapabilities(apiPath`${'backend'}/${'id'}`, 'backend', 'id'),
canEdit: alias('secretPath.canUpdate'), canEdit: alias('secretPath.canUpdate'),
canDelete: alias('secretPath.canUpdate'), canDelete: alias('secretPath.canDelete'),
canRead: alias('secretPath.canRead'), canRead: alias('secretPath.canRead'),
}); });

View file

@ -64,6 +64,7 @@ export default Route.extend(UnloadModelRoute, {
model(params) { model(params) {
let { secret } = params; let { secret } = params;
const { backend } = this.paramsFor('vault.cluster.secrets.backend'); const { backend } = this.paramsFor('vault.cluster.secrets.backend');
let backendModel = this.modelFor('vault.cluster.secrets.backend', backend);
const modelType = this.modelType(backend, secret); const modelType = this.modelType(backend, secret);
if (!secret) { if (!secret) {
@ -73,26 +74,50 @@ export default Route.extend(UnloadModelRoute, {
secret = secret.replace('cert/', ''); secret = secret.replace('cert/', '');
} }
return hash({ return hash({
secret: this.store.queryRecord(modelType, { id: secret, backend }).then(resp => { secret: this.store
if (modelType === 'secret-v2') { .queryRecord(modelType, { id: secret, backend })
let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); .then(secretModel => {
let targetVersion = parseInt(params.version || resp.currentVersion, 10); if (modelType === 'secret-v2') {
let version = resp.versions.findBy('version', targetVersion); let targetVersion = parseInt(params.version || secretModel.currentVersion, 10);
// 404 if there's no version let version = secretModel.versions.findBy('version', targetVersion);
if (!version) { // 404 if there's no version
let error = new DS.AdapterError(); if (!version) {
set(error, 'httpStatus', 404); let error = new DS.AdapterError();
throw error; set(error, 'httpStatus', 404);
} throw error;
resp.set('engine', backendModel); }
secretModel.set('engine', backendModel);
return version.reload().then(() => { return version.reload().then(() => {
resp.set('selectedVersion', version); secretModel.set('selectedVersion', version);
return resp; return secretModel;
}); });
} }
return resp; return secretModel;
}), })
.catch(err => {
//don't have access to the metadata, so we'll make
//a stub metadata model and try to load the version
if (modelType === 'secret-v2' && err.httpStatus === 403) {
let secretModel = this.store.createRecord('secret-v2');
secretModel.setProperties({
engine: backendModel,
id: secret,
// so we know it's a stub model and won't be saving it
// because we don't have access to that endpoint
isStub: true,
});
let targetVersion = params.version ? parseInt(params.version, 10) : null;
let versionId = targetVersion ? [backend, secret, targetVersion] : [backend, secret];
return this.store
.findRecord('secret-v2-version', JSON.stringify(versionId), { reload: true })
.then(versionModel => {
secretModel.set('selectedVersion', versionModel);
return secretModel;
});
}
throw err;
}),
capabilities: this.capabilities(secret), capabilities: this.capabilities(secret),
}); });
}, },

View file

@ -17,7 +17,8 @@ export default ApplicationSerializer.extend({
}, },
serialize(snapshot) { serialize(snapshot) {
let secret = snapshot.belongsTo('secret'); let secret = snapshot.belongsTo('secret');
let version = secret.attr('currentVersion') || 0; let version = secret.record.isStub ? snapshot.attr('version') : secret.attr('currentVersion');
version = version || 0;
return { return {
data: snapshot.attr('secretData'), data: snapshot.attr('secretData'),
options: { options: {

View file

@ -1,5 +1,5 @@
{{#if (and (or @model.isNew @canEditV2Secret) @isV2)}} {{#if (and (or @model.isNew @canEditV2Secret) @isV2 (not @model.isStub))}}
<div class="form-section box is-shadowless is-fullwidth"> <div data-test-metadata-fields class="form-section box is-shadowless is-fullwidth">
<label class="title is-5"> <label class="title is-5">
Secret Metadata Secret Metadata
</label> </label>

View file

@ -2,7 +2,7 @@
<div class="box is-sideless is-fullwidth is-marginless is-paddingless"> <div class="box is-sideless is-fullwidth is-marginless is-paddingless">
<MessageError @model={{model}} @errorMessage={{error}} /> <MessageError @model={{model}} @errorMessage={{error}} />
<NamespaceReminder @mode="edit" @noun="secret" /> <NamespaceReminder @mode="edit" @noun="secret" />
{{#if (not-eq model.selectedVersion.version model.currentVersion)}} {{#if (and (not model.isStub) (not-eq model.selectedVersion.version model.currentVersion))}}
<div class="form-section"> <div class="form-section">
<AlertBanner <AlertBanner
@type="warning" @type="warning"
@ -32,6 +32,7 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button <button
data-test-secret-save
type="submit" type="submit"
disabled={{buttonDisabled}} disabled={{buttonDisabled}}
class="button is-primary" class="button is-primary"

View file

@ -22,7 +22,7 @@ module.exports = function(defaults) {
hinting: isTest, hinting: isTest,
tests: isTest, tests: isTest,
sourcemaps: { sourcemaps: {
enabled: false, enabled: !isProd,
}, },
sassOptions: { sassOptions: {
sourceMap: false, sourceMap: false,

View file

@ -9,16 +9,24 @@ import listPage from 'vault/tests/pages/secrets/backend/list';
import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend'; import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend';
import apiStub from 'vault/tests/helpers/noop-all-api-requests'; import apiStub from 'vault/tests/helpers/noop-all-api-requests';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
import logout from 'vault/tests/pages/logout';
import withFlash from 'vault/tests/helpers/with-flash'; import withFlash from 'vault/tests/helpers/with-flash';
import consoleClass from 'vault/tests/pages/components/console/ui-panel'; import consoleClass from 'vault/tests/pages/components/console/ui-panel';
const consoleComponent = create(consoleClass); const consoleComponent = create(consoleClass);
let writeSecret = async function(backend, path, key, val) {
await listPage.visitRoot({ backend });
await listPage.create();
return editPage.createSecret(path, key, val);
};
module('Acceptance | secrets/secret/create', function(hooks) { module('Acceptance | secrets/secret/create', function(hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
hooks.beforeEach(function() { hooks.beforeEach(async function() {
this.server = apiStub({ usePassthrough: true }); this.server = apiStub({ usePassthrough: true });
await logout.visit();
return authPage.login(); return authPage.login();
}); });
@ -32,6 +40,7 @@ module('Acceptance | secrets/secret/create', function(hooks) {
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.list-root', 'navigates to the list page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.list-root', 'navigates to the list page');
await listPage.create(); await listPage.create();
assert.ok(editPage.hasMetadataFields, 'shows the metadata form');
await editPage.createSecret(path, 'foo', 'bar'); await editPage.createSecret(path, 'foo', 'bar');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
@ -44,11 +53,7 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await mountSecrets.visit(); await mountSecrets.visit();
await mountSecrets.enable('kv', enginePath); await mountSecrets.enable('kv', enginePath);
await consoleComponent.runCommands(`write ${enginePath}/config cas_required=true`); await consoleComponent.runCommands(`write ${enginePath}/config cas_required=true`);
await writeSecret(enginePath, secretPath, 'foo', 'bar');
await listPage.visitRoot({ backend: enginePath });
await listPage.create();
await editPage.createSecret(secretPath, 'foo', 'bar');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
assert.ok(showPage.editIsPresent, 'shows the edit button'); assert.ok(showPage.editIsPresent, 'shows the edit button');
}); });
@ -101,4 +106,64 @@ module('Acceptance | secrets/secret/create', function(hooks) {
'saves the content' 'saves the content'
); );
}); });
test('version 2 with restricted policy still allows creation', async function(assert) {
let backend = 'kv-v2';
const V2_POLICY = `'
path "kv-v2/metadata/*" {
capabilities = ["list"]
}
path "kv-v2/data/secret" {
capabilities = ["create", "read", "update"]
}
'`;
await consoleComponent.runCommands([
`write sys/mounts/${backend} type=kv options=version=2`,
`write sys/policies/acl/kv-v2-degrade policy=${V2_POLICY}`,
// delete any kv previously written here so that tests can be re-run
'delete kv-v2/metadata/secret',
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
let userToken = consoleComponent.lastLogOutput;
await logout.visit();
await authPage.login(userToken);
await writeSecret(backend, 'secret', 'foo', 'bar');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
assert.ok(showPage.editIsPresent, 'shows the edit button');
await logout.visit();
});
test('version 2 with restricted policy still allows edit', async function(assert) {
let backend = 'kv-v2';
const V2_POLICY = `'
path "kv-v2/metadata/*" {
capabilities = ["list"]
}
path "kv-v2/data/secret" {
capabilities = ["create", "read", "update"]
}
'`;
await consoleComponent.runCommands([
`write sys/mounts/${backend} type=kv options=version=2`,
`write sys/policies/acl/kv-v2-degrade policy=${V2_POLICY}`,
// delete any kv previously written here so that tests can be re-run
'delete kv-v2/metadata/secret',
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
let userToken = consoleComponent.lastLogOutput;
await writeSecret(backend, 'secret', 'foo', 'bar');
await logout.visit();
await authPage.login(userToken);
await editPage.visitEdit({ backend, id: 'secret' });
assert.notOk(editPage.hasMetadataFields, 'hides the metadata form');
await editPage.editSecret('bar', 'baz');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
assert.ok(showPage.editIsPresent, 'shows the edit button');
await logout.visit();
});
}); });

View file

@ -1,5 +1,5 @@
import { Base } from '../create'; import { Base } from '../create';
import { clickable, visitable, create, fillable } from 'ember-cli-page-object'; import { isPresent, clickable, visitable, create, fillable } from 'ember-cli-page-object';
import { codeFillable } from 'vault/tests/pages/helpers/codemirror'; import { codeFillable } from 'vault/tests/pages/helpers/codemirror';
export default create({ export default create({
...Base, ...Base,
@ -12,17 +12,22 @@ export default create({
visitEdit: visitable('/vault/secrets/:backend/edit/:id'), visitEdit: visitable('/vault/secrets/:backend/edit/:id'),
visitEditRoot: visitable('/vault/secrets/:backend/edit'), visitEditRoot: visitable('/vault/secrets/:backend/edit'),
toggleJSON: clickable('[data-test-secret-json-toggle]'), toggleJSON: clickable('[data-test-secret-json-toggle]'),
hasMetadataFields: isPresent('[data-test-metadata-fields]'),
editor: { editor: {
fillIn: codeFillable('[data-test-component="json-editor"]'), fillIn: codeFillable('[data-test-component="json-editor"]'),
}, },
deleteSecret() { deleteSecret() {
return this.deleteBtn().confirmBtn(); return this.deleteBtn().confirmBtn();
}, },
createSecret: async function(path, key, value) { createSecret: async function(path, key, value) {
return this.path(path) return this.path(path)
.secretKey(key) .secretKey(key)
.secretValue(value) .secretValue(value)
.save(); .save();
}, },
editSecret: async function(key, value) {
return this.secretKey(key)
.secretValue(value)
.save();
},
}); });