diff --git a/ui/app/adapters/secret-v2-version.js b/ui/app/adapters/secret-v2-version.js index 1e2177dc05cf..d981898da2f3 100644 --- a/ui/app/adapters/secret-v2-version.js +++ b/ui/app/adapters/secret-v2-version.js @@ -74,7 +74,7 @@ export default ApplicationAdapter.extend({ return this.ajax(this._url(backend, path, deleteType), 'POST', { data: { versions: [version] } }).then( () => { let model = store.peekRecord('secret-v2-version', id); - return model && model.reload(); + return model && model.rollbackAttributes() && model.reload(); } ); }, diff --git a/ui/app/components/secret-edit.js b/ui/app/components/secret-edit.js index 843588dc80f8..c2634900a32c 100644 --- a/ui/app/components/secret-edit.js +++ b/ui/app/components/secret-edit.js @@ -156,6 +156,22 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { return this.secretDataIsAdvanced || this.preferAdvancedEdit; }), + isWriteWithoutRead: computed( + 'model.{failedServerRead,selectedVersion.failedServerRead}', + 'isV2', + function() { + // if the version couldn't be read from the server + if (this.isV2 && this.model.selectedVersion.failedServerRead) { + return true; + } + // if the model couldn't be read from the server + if (!this.isV2 && this.model.failedServerRead) { + return true; + } + return false; + } + ), + transitionToRoute() { return this.router.transitionTo(...arguments); }, diff --git a/ui/app/components/secret-version-menu.js b/ui/app/components/secret-version-menu.js index 5b13c6cd214b..f698870d05d1 100644 --- a/ui/app/components/secret-version-menu.js +++ b/ui/app/components/secret-version-menu.js @@ -8,6 +8,7 @@ export default Component.extend({ store: service(), version: null, useDefaultTrigger: false, + onRefresh() {}, deleteVersionPath: maybeQueryRecord( 'capabilities', @@ -52,7 +53,8 @@ export default Component.extend({ deleteVersion(deleteType = 'destroy') { return this.store .adapterFor('secret-v2-version') - .v2DeleteOperation(this.store, this.version.id, deleteType); + .v2DeleteOperation(this.store, this.version.id, deleteType) + .then(this.onRefresh); }, }, }); diff --git a/ui/app/models/secret.js b/ui/app/models/secret.js index 02d5a61c4ad4..2e7981aabf09 100644 --- a/ui/app/models/secret.js +++ b/ui/app/models/secret.js @@ -19,7 +19,7 @@ export default DS.Model.extend(KeyMixin, { isAdvancedFormat: computed('secretData', function() { const data = this.get('secretData'); - return Object.keys(data).some(key => typeof data[key] !== 'string'); + return data && Object.keys(data).some(key => typeof data[key] !== 'string'); }), helpText: attr('string'), diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index f8c84e34d2be..d7b539267d66 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -1,5 +1,5 @@ import { set } from '@ember/object'; -import { hash, resolve } from 'rsvp'; +import { resolve } from 'rsvp'; import { inject as service } from '@ember/service'; import DS from 'ember-data'; import Route from '@ember/routing/route'; @@ -21,12 +21,11 @@ export default Route.extend(UnloadModelRoute, { capabilities(secret) { const backend = this.enginePathParam(); let backendModel = this.modelFor('vault.cluster.secrets.backend'); - let backendType = backendModel.get('engineType'); - if (backendType === 'kv' || backendType === 'cubbyhole' || backendType === 'generic') { - return resolve({}); - } + let backendType = backendModel.engineType; let path; - if (backendType === 'transit') { + if (backendModel.isV2KV) { + path = `${backend}/data/${secret}`; + } else if (backendType === 'transit') { path = backend + '/keys/' + secret; } else if (backendType === 'ssh' || backendType === 'aws') { path = backend + '/roles/' + secret; @@ -43,9 +42,6 @@ export default Route.extend(UnloadModelRoute, { templateName: 'vault/cluster/secrets/backend/secretEditLayout', beforeModel() { - // currently there is no recursive delete for folders in vault, so there's no need to 'edit folders' - // perhaps in the future we could recurse _for_ users, but for now, just kick them - // back to the list let secret = this.secretParam(); return this.buildModel(secret).then(() => { const parentKey = utils.parentKeyForKey(secret); @@ -86,10 +82,106 @@ export default Route.extend(UnloadModelRoute, { return types[type]; }, - model(params) { - let secret = this.secretParam(); + getTargetVersion(currentVersion, paramsVersion) { + if (currentVersion) { + // we have the secret metadata, so we can read the currentVersion but give priority to any + // version passed in via the url + return parseInt(paramsVersion || currentVersion, 10); + } else { + // we've got a stub model because don't have read access on the metadata endpoint + return paramsVersion ? parseInt(paramsVersion, 10) : null; + } + }, + + async fetchV2Models(capabilities, secretModel, params) { let backend = this.enginePathParam(); let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); + let targetVersion = this.getTargetVersion(secretModel.currentVersion, params.version); + + // if we have the metadata, a list of versions are part of the payload + let version = secretModel.versions && secretModel.versions.findBy('version', targetVersion); + // if it didn't fail the server read, and the version is not attached to the metadata, + // this should 404 + if (!version && secretModel.failedServerRead !== true) { + let error = new DS.AdapterError(); + set(error, 'httpStatus', 404); + throw error; + } + // manually set the related model + secretModel.set('engine', backendModel); + + secretModel.set( + 'selectedVersion', + await this.fetchV2VersionModel(capabilities, secretModel, version, targetVersion) + ); + return secretModel; + }, + + async fetchV2VersionModel(capabilities, secretModel, version, targetVersion) { + let secret = this.secretParam(); + let backend = this.enginePathParam(); + + // v2 versions have a composite ID, we generated one here if we need to manually set it + // after a failed fetch later; + let versionId = targetVersion ? [backend, secret, targetVersion] : [backend, secret]; + + let versionModel; + try { + if (secretModel.failedServerRead) { + // we couldn't read metadata, so we want to directly fetch the version + versionModel = await this.store.findRecord('secret-v2-version', JSON.stringify(versionId), { + reload: true, + }); + } else { + // we may have previously errored, so roll it back here + version.rollbackAttributes(); + // if metadata read was successful, the version we have is only a partial model + // trigger reload to fetch the whole version model + versionModel = await version.reload(); + } + } catch (error) { + // cannot read the version data, but can write according to capabilities-self endpoint + if (error.httpStatus === 403 && capabilities.get('canUpdate')) { + // versionModel is then a partial model from the metadata (if we have read there), or + // we need to create one on the client + versionModel = version || this.store.createRecord('secret-v2-version'); + versionModel.setProperties({ + failedServerRead: true, + }); + // if it was created on the client we need to trigger an event via ember-data + // so that it won't try to create the record on save + if (versionModel.isNew) { + versionModel.set('id', JSON.stringify(versionId)); + //TODO make this a util to better show what's happening + // this is because we want the ember-data model save to call update instead of create + // in the adapter so we have to force the frontend model to a "saved" state + versionModel.send('pushedData'); + } + } else { + throw error; + } + } + return versionModel; + }, + + handleSecretModelError(capabilities, secret, modelType, error) { + // can't read the path and don't have update capability, so re-throw + if (!capabilities.get('canUpdate') && modelType === 'secret') { + throw error; + } + // don't have access to the metadata for v2 or the secret for v1, + // so we make a stub model and mark it as `failedServerRead` + let secretModel = this.store.createRecord(modelType); + secretModel.setProperties({ + id: secret, + failedServerRead: true, + }); + return secretModel; + }, + + async model(params) { + let secret = this.secretParam(); + let backend = this.enginePathParam(); let modelType = this.modelType(backend, secret); if (!secret) { @@ -98,53 +190,31 @@ export default Route.extend(UnloadModelRoute, { if (modelType === 'pki-certificate') { secret = secret.replace('cert/', ''); } - return hash({ - secret: this.store - .queryRecord(modelType, { id: secret, backend }) - .then(secretModel => { - if (modelType === 'secret-v2') { - let targetVersion = parseInt(params.version || secretModel.currentVersion, 10); - let version = secretModel.versions.findBy('version', targetVersion); - // 404 if there's no version - if (!version) { - let error = new DS.AdapterError(); - set(error, 'httpStatus', 404); - throw error; - } - secretModel.set('engine', backendModel); - - return version.reload().then(() => { - secretModel.set('selectedVersion', version); - return secretModel; - }); - } - 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), - }); + let secretModel; + + let capabilities = this.capabilities(secret); + try { + secretModel = await this.store.queryRecord(modelType, { id: secret, backend }); + } catch (err) { + // we've failed the read request, but if it's a kv-type backend, we want to + // do additional checks of the capabilities + if (err.httpStatus === 403 && (modelType === 'secret-v2' || modelType === 'secret')) { + await capabilities; + secretModel = this.handleSecretModelError(capabilities, secret, modelType, err); + } else { + throw err; + } + } + await capabilities; + if (modelType === 'secret-v2') { + // after the the base model fetch, kv-v2 has a second associated + // version model that contains the secret data + secretModel = await this.fetchV2Models(capabilities, secretModel, params); + } + return { + secret: secretModel, + capabilities, + }; }, setupController(controller, model) { diff --git a/ui/app/serializers/secret-v2-version.js b/ui/app/serializers/secret-v2-version.js index 5e080d93d99a..4cf77f839e27 100644 --- a/ui/app/serializers/secret-v2-version.js +++ b/ui/app/serializers/secret-v2-version.js @@ -18,7 +18,13 @@ export default ApplicationSerializer.extend({ }, serialize(snapshot) { let secret = snapshot.belongsTo('secret'); - let version = secret.record.isStub ? snapshot.attr('version') : secret.attr('currentVersion'); + // if both models failed to read from the server, we need to write without CAS + if (secret.record.failedServerRead && snapshot.record.failedServerRead) { + return { + data: snapshot.attr('secretData'), + }; + } + let version = secret.record.failedServerRead ? snapshot.attr('version') : secret.attr('currentVersion'); version = version || 0; return { data: snapshot.attr('secretData'), diff --git a/ui/app/templates/components/empty-state.hbs b/ui/app/templates/components/empty-state.hbs index ebe81417b425..ba6bbbc12a4c 100644 --- a/ui/app/templates/components/empty-state.hbs +++ b/ui/app/templates/components/empty-state.hbs @@ -1,10 +1,10 @@ -
+

{{title}}

{{#if message}} -

+

{{message}}

{{/if}} diff --git a/ui/app/templates/components/secret-edit-display.hbs b/ui/app/templates/components/secret-edit-display.hbs index b3f39a1a1236..7aa60d3e2076 100644 --- a/ui/app/templates/components/secret-edit-display.hbs +++ b/ui/app/templates/components/secret-edit-display.hbs @@ -1,4 +1,4 @@ - {{#if (and (or @model.isNew @canEditV2Secret) @isV2 (not @model.isStub))}} + {{#if (and (or @model.isNew @canEditV2Secret) @isV2 (not @model.failedServerRead))}}
{{/if}} + {{#if @showWriteWithoutReadWarning}} + {{#if (and @isV2 @model.failedServerRead)}} + + {{else if @isV2}} + + {{else}} + + {{/if}} + {{/if}} + {{#if @showAdvancedMode}}