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:
parent
c2e87c20d8
commit
af8eda9322
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue