Create KV V2 Ember Engine (#22426)

This commit is contained in:
Angel Garbarino
2023-08-24 09:02:53 -06:00
committed by GitHub
parent da6815e5a4
commit e2cadfc9ff
167 changed files with 9607 additions and 205 deletions

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationAdapter from '../application';
export default class KvConfigAdapter extends ApplicationAdapter {
namespace = 'v1';
urlForFindRecord(id) {
return `${this.buildURL()}/${id}/config`;
}
}

136
ui/app/adapters/kv/data.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationAdapter from '../application';
import { kvDataPath, kvDeletePath, kvDestroyPath, kvUndeletePath } from 'vault/utils/kv-path';
import { assert } from '@ember/debug';
import ControlGroupError from 'vault/lib/control-group-error';
export default class KvDataAdapter extends ApplicationAdapter {
namespace = 'v1';
_url(fullPath) {
return `${this.buildURL()}/${fullPath}`;
}
createRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const url = this._url(kvDataPath(backend, path));
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then((res) => {
return {
data: {
id: kvDataPath(backend, path, res.data.version),
backend,
path,
...res.data,
},
};
});
}
fetchWrapInfo(query) {
const { backend, path, version, wrapTTL } = query;
const id = kvDataPath(backend, path, version);
return this.ajax(this._url(id), 'GET', { wrapTTL }).then((resp) => resp.wrap_info);
}
queryRecord(store, type, query) {
const { backend, path, version } = query;
// ID is the full path for the data (including version)
let id = kvDataPath(backend, path, version);
return this.ajax(this._url(id), 'GET')
.then((resp) => {
// if no version is queried, add version from response to ID
// otherwise duplicate ember data models will exist in store
// (one with an ID that includes the version and one without)
if (!version) {
id = kvDataPath(backend, path, resp.data.metadata.version);
}
return {
...resp,
data: {
id,
backend,
path,
...resp.data,
},
};
})
.catch((errorOrResponse) => {
const baseResponse = { id, backend, path, version };
const errorCode = errorOrResponse.httpStatus;
// if it's a legitimate error - throw it!
if (errorOrResponse instanceof ControlGroupError) {
throw errorOrResponse;
}
if (errorCode === 403) {
return {
data: {
...baseResponse,
fail_read_error_code: errorCode,
},
};
}
if (errorOrResponse.data) {
// in the case of a deleted/destroyed secret the API returns a 404 because { data: null }
// however, there could be a metadata block with important information like deletion_time
// handleResponse below checks 404 status codes for metadata and updates the code to 200 if it exists.
// we still end up in the good ol' catch() block, but instead of a 404 adapter error we've "caught"
// the metadata that sneakily tried to hide from us
return {
...errorOrResponse,
data: {
...baseResponse,
...errorOrResponse.data, // includes the { metadata } key we want
},
};
}
// If we get here, it's probably a 404 because it doesn't exist
throw errorOrResponse;
});
}
/* Five types of delete operations (the 5th operation is on the kv/metadata adapter) */
deleteRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const { deleteType, deleteVersions } = snapshot.adapterOptions;
if (!backend || !path) {
throw new Error('The request to delete or undelete is missing required attributes.');
}
switch (deleteType) {
case 'delete-latest-version':
return this.ajax(this._url(kvDataPath(backend, path)), 'DELETE');
case 'delete-version':
return this.ajax(this._url(kvDeletePath(backend, path)), 'POST', {
data: { versions: deleteVersions },
});
case 'destroy':
return this.ajax(this._url(kvDestroyPath(backend, path)), 'PUT', {
data: { versions: deleteVersions },
});
case 'undelete':
return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', {
data: { versions: deleteVersions },
});
default:
assert('deleteType must be one of delete-latest-version, delete-version, destroy, or undelete.');
}
}
handleResponse(status, headers, payload, requestData) {
// after deleting a secret version, data is null and the API returns a 404
// but there could be relevant metadata
if (status === 404 && payload.data?.metadata) {
return super.handleResponse(200, headers, payload, requestData);
}
return super.handleResponse(...arguments);
}
}

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationAdapter from '../application';
import { kvMetadataPath } from 'vault/utils/kv-path';
export default class KvMetadataAdapter extends ApplicationAdapter {
namespace = 'v1';
_url(fullPath) {
return `${this.buildURL()}/${fullPath}`;
}
createRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const id = kvMetadataPath(backend, path);
const url = this._url(id);
const data = this.serialize(snapshot);
return this.ajax(url, 'POST', { data }).then(() => {
return {
id,
data,
};
});
}
updateRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const id = kvMetadataPath(backend, path);
const url = this._url(id);
const data = this.serialize(snapshot);
return this.ajax(url, 'POST', { data }).then(() => {
return {
id,
data,
};
});
}
query(store, type, query) {
const { backend, pathToSecret } = query;
// example of pathToSecret: beep/boop/
return this.ajax(this._url(kvMetadataPath(backend, pathToSecret)), 'GET', {
data: { list: true },
}).then((resp) => {
resp.backend = backend;
resp.path = pathToSecret;
return resp;
});
}
queryRecord(store, type, query) {
const { backend, path } = query;
// ID is the full path for the metadata
const id = kvMetadataPath(backend, path);
return this.ajax(this._url(id), 'GET').then((resp) => {
return {
id,
...resp,
data: {
backend,
path,
...resp.data,
},
};
});
}
deleteRecord(store, type, snapshot) {
const { backend, path, fullSecretPath } = snapshot.record;
// fullSecretPath is used when deleting from the LIST view and is defined via the serializer
// path is used when deleting from the metadata details view.
return this.ajax(this._url(kvMetadataPath(backend, fullSecretPath || path)), 'DELETE');
}
}

View File

@@ -34,6 +34,7 @@ export default ApplicationAdapter.extend({
let mountModel, configModel;
try {
mountModel = await this.ajax(this.internalURL(query.path), 'GET');
// TODO kv engine cleanup - this logic can be removed when KV exists in separate ember engine
// if kv2 then add the config data to the mountModel
// version comes in as a string
if (mountModel?.data?.type === 'kv' && mountModel?.data?.options?.version === '2') {

View File

@@ -52,6 +52,14 @@ export default class App extends Application {
},
},
},
kv: {
dependencies: {
services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
},
},
},
pki: {
dependencies: {
services: [

View File

@@ -4,7 +4,7 @@
*/
import Component from '@ember/component';
import keys from 'vault/lib/keycodes';
import keys from 'core/utils/key-codes';
export default Component.extend({
onExecuteCommand() {},

View File

@@ -60,6 +60,7 @@ export default class GetCredentialsCard extends Component {
if (role) {
this.router.transitionTo('vault.cluster.secrets.backend.credentials', role);
}
// TODO kv engine cleanup. Should be able to remove this component and replace with overview card for the role usage. KV engine has switched to overview card.
if (secret) {
if (secret.endsWith('/')) {
this.router.transitionTo('vault.cluster.secrets.backend.list', secret);

View File

@@ -10,7 +10,7 @@ import { task, waitForEvent } from 'ember-concurrency';
import Component from '@ember/component';
import { set } from '@ember/object';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
import keys from 'vault/lib/keycodes';
import keys from 'core/utils/key-codes';
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';

View File

@@ -33,7 +33,7 @@
import Component from '@glimmer/component';
import ControlGroupError from 'vault/lib/control-group-error';
import Ember from 'ember';
import keys from 'vault/lib/keycodes';
import keys from 'core/utils/key-codes';
import { action, set } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

View File

@@ -18,8 +18,6 @@
* @showAdvancedMode={{showAdvancedMode}}
* @modelForData={{this.modelForData}}
* @canUpdateSecretData={{canUpdateSecretData}}
* @codemirrorString={{codemirrorString}}
* @wrappedData={{wrappedData}}
* @editActions={{hash
toggleAdvanced=(action "toggleAdvanced")
refresh=(action "refresh")
@@ -32,80 +30,45 @@
* @param {boolean} isV2 - KV type
* @param {boolean} isWriteWithoutRead - boolean describing permissions
* @param {boolean} secretDataIsAdvanced - used to determine if show JSON toggle
* @param {boolean} showAdvacnedMode - used for JSON toggle
* @param {boolean} showAdvancedMode - used for JSON toggle
* @param {object} modelForData - a modified version of the model with secret data
* @param {boolean} canUpdateSecretData - permissions that show the create new version button or not.
* @param {string} codemirrorString - used to copy the JSON
* @param {object} wrappedData - when copy the data it's the token of the secret returned.
* @param {object} editActions - actions passed from parent to child
*/
/* eslint ember/no-computed-properties-in-native-classes: 'warn' */
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { not } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
export default class SecretEditToolbar extends Component {
@service store;
@service flashMessages;
@tracked wrappedData = null;
@tracked isWrapping = false;
@not('wrappedData') showWrapButton;
@action
clearWrappedData() {
this.wrappedData = null;
}
@action
handleCopyError() {
this.flashMessages.danger('Could Not Copy Wrapped Data');
this.send('clearWrappedData');
}
@task
@waitFor
*wrapSecret() {
const { id } = this.args.modelForData;
const { backend } = this.args.model;
const wrapTTL = { wrapTTL: 1800 };
@action
handleCopySuccess() {
this.flashMessages.success('Copied Wrapped Data!');
this.send('clearWrappedData');
}
@action
handleWrapClick() {
this.isWrapping = true;
if (this.args.isV2) {
this.store
.adapterFor('secret-v2-version')
.queryRecord(this.args.modelForData.id, { wrapTTL: 1800 })
.then((resp) => {
this.wrappedData = resp.wrap_info.token;
this.flashMessages.success('Secret Successfully Wrapped!');
})
.catch(() => {
this.flashMessages.danger('Could Not Wrap Secret');
})
.finally(() => {
this.isWrapping = false;
});
} else {
this.store
.adapterFor('secret')
.queryRecord(null, null, {
backend: this.args.model.backend,
id: this.args.modelForData.id,
wrapTTL: 1800,
})
.then((resp) => {
this.wrappedData = resp.wrap_info.token;
this.flashMessages.success('Secret Successfully Wrapped!');
})
.catch(() => {
this.flashMessages.danger('Could Not Wrap Secret');
})
.finally(() => {
this.isWrapping = false;
});
try {
const resp = yield this.args.isV2
? this.store.adapterFor('secret-v2-version').queryRecord(id, wrapTTL)
: this.store.adapterFor('secret').queryRecord(null, null, { backend, id, ...wrapTTL });
this.wrappedData = resp.wrap_info.token;
this.flashMessages.success('Secret successfully wrapped!');
} catch (e) {
this.flashMessages.danger('Could not wrap secret.');
}
}
}

View File

@@ -11,7 +11,7 @@ import { task, waitForEvent } from 'ember-concurrency';
import { set } from '@ember/object';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
import keys from 'vault/lib/keycodes';
import keys from 'core/utils/key-codes';
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import keys from 'vault/lib/keycodes';
import keys from 'core/utils/key-codes';
import AdapterError from '@ember-data/adapter/error';
import { parse } from 'shell-quote';

View File

@@ -0,0 +1,31 @@
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { duration } from 'core/helpers/format-duration';
// This model is used only for display only - configuration happens via secret-engine model when an engine is mounted
@withFormFields(['casRequired', 'deleteVersionAfter', 'maxVersions'])
export default class KvConfigModel extends Model {
@attr backend;
@attr('number', { label: 'Maximum number of versions' }) maxVersions;
@attr('boolean', { label: 'Require check and set' }) casRequired;
@attr({ label: 'Automate secret deletion' }) deleteVersionAfter;
@lazyCapabilities(apiPath`${'backend'}/config`, 'backend') configPath;
get canRead() {
return this.configPath.get('canRead') !== false;
}
// used in template to render using this model instead of secret-engine (where these attrs also exist)
get displayFields() {
return ['casRequired', 'deleteVersionAfter', 'maxVersions'];
}
get displayDeleteTtl() {
if (this.deleteVersionAfter === '0s') return 'Never delete';
return duration([this.deleteVersionAfter]);
}
}

113
ui/app/models/kv/data.js Normal file
View File

@@ -0,0 +1,113 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';
import { withFormFields } from 'vault/decorators/model-form-fields';
/* sample response
{
"data": {
"data": {
"foo": "bar"
},
"metadata": {
"created_time": "2018-03-22T02:24:06.945319214Z",
"custom_metadata": {
"owner": "jdoe",
"mission_critical": "false"
},
"deletion_time": "",
"destroyed": false,
"version": 2
}
}
}
*/
const validations = {
path: [
{ type: 'presence', message: `Path can't be blank.` },
{ type: 'endsInSlash', message: `Path can't end in forward slash '/'.` },
{
type: 'containsWhiteSpace',
message:
"Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
level: 'warn',
},
],
secretData: [
{
validator: (model) =>
model.secretData !== undefined && typeof model.secretData !== 'object' ? false : true,
message: 'Vault expects data to be formatted as an JSON object.',
},
],
};
@withModelValidations(validations)
@withFormFields()
export default class KvSecretDataModel extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord.
@attr('string', { label: 'Path for this secret' }) path;
@attr('object') secretData; // { key: value } data of the secret version
// Params returned on the GET response.
@attr('string') createdTime;
@attr('object') customMetadata;
@attr('string') deletionTime;
@attr('boolean') destroyed;
@attr('number') version;
// Set in adapter if read failed
@attr('number') failReadErrorCode;
// if creating a new version this value is set in the edit route's
// model hook from metadata or secret version, pending permissions
// if the value is not a number, don't send options.cas on payload
@attr('number')
casVersion;
get state() {
if (this.destroyed) return 'destroyed';
if (this.deletionTime) return 'deleted';
if (this.createdTime) return 'created';
return '';
}
// Permissions
@lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath;
@lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath;
@lazyCapabilities(apiPath`${'backend'}/delete/${'path'}`, 'backend', 'path') deletePath;
@lazyCapabilities(apiPath`${'backend'}/destroy/${'path'}`, 'backend', 'path') destroyPath;
@lazyCapabilities(apiPath`${'backend'}/undelete/${'path'}`, 'backend', 'path') undeletePath;
get canDeleteLatestVersion() {
return this.dataPath.get('canDelete') !== false;
}
get canDeleteVersion() {
return this.deletePath.get('canUpdate') !== false;
}
get canUndelete() {
return this.undeletePath.get('canUpdate') !== false;
}
get canDestroyVersion() {
return this.destroyPath.get('canUpdate') !== false;
}
get canEditData() {
return this.dataPath.get('canUpdate') !== false;
}
get canReadData() {
return this.dataPath.get('canRead') !== false;
}
get canReadMetadata() {
return this.metadataPath.get('canRead') !== false;
}
get canUpdateMetadata() {
return this.metadataPath.get('canUpdate') !== false;
}
get canListMetadata() {
return this.metadataPath.get('canList') !== false;
}
}

View File

@@ -0,0 +1,106 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { keyIsFolder } from 'core/utils/key-utils';
const validations = {
maxVersions: [
{ type: 'number', message: 'Maximum versions must be a number.' },
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
],
};
const formFieldProps = ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter'];
@withModelValidations(validations)
@withFormFields(formFieldProps)
export default class KvSecretMetadataModel extends Model {
@attr('string') backend;
@attr('string') path;
@attr('string') fullSecretPath;
@attr('number', {
defaultValue: 0,
label: 'Maximum number of versions',
subText:
'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.',
})
maxVersions;
@attr('boolean', {
defaultValue: false,
label: 'Require Check and Set',
subText: `Writes will only be allowed if the key's current version matches the version specified in the cas parameter.`,
})
casRequired;
@attr('string', {
defaultValue: '0s',
editType: 'ttl',
label: 'Automate secret deletion',
helperTextDisabled: `A secret's version must be manually deleted.`,
helperTextEnabled: 'Delete all new versions of this secret after.',
})
deleteVersionAfter;
@attr('object', {
editType: 'kv',
subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.',
})
customMetadata;
// Additional Params only returned on the GET response.
@attr('string') createdTime;
@attr('number') currentVersion;
@attr('number') oldestVersion;
@attr('string') updatedTime;
@attr('object') versions;
// used for KV list and list-directory view
get pathIsDirectory() {
// ex: beep/
return keyIsFolder(this.path);
}
// turns version object into an array for version dropdown menu
get sortedVersions() {
const array = [];
for (const key in this.versions) {
array.push({ version: key, ...this.versions[key] });
}
// version keys are in order created with 1 being the oldest, we want newest first
return array.reverse();
}
// helps in long logic statements for state of a currentVersion
get currentSecret() {
const data = this.versions[this.currentVersion];
const state = data.destroyed ? 'destroyed' : data.deletion_time ? 'deleted' : 'created';
return {
state,
isDeactivated: state !== 'created',
};
}
// permissions needed for the list view where kv/data has not yet been called. Allows us to conditionally show action items in the LinkedBlock popups.
@lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath;
@lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath;
get canDeleteMetadata() {
return this.metadataPath.get('canDelete') !== false;
}
get canReadMetadata() {
return this.metadataPath.get('canRead') !== false;
}
get canUpdateMetadata() {
return this.metadataPath.get('canUpdate') !== false;
}
get canCreateVersionData() {
return this.dataPath.get('canUpdate') !== false;
}
}

View File

@@ -159,6 +159,7 @@ Router.map(function () {
this.route('backend', { path: '/:backend' }, function () {
this.mount('kmip');
this.mount('kubernetes');
this.mount('kv');
this.mount('pki');
this.route('index', { path: '/' });
this.route('configuration');

View File

@@ -10,6 +10,7 @@ export default Route.extend({
store: service(),
async model() {
const backend = this.modelFor('vault.cluster.secrets.backend');
// TODO kv engine cleanup - this can be removed when KV has fully moved to separate ember engine and list view config details menu is refactored
if (backend.isV2KV) {
const canRead = await this.store
.findRecord('capabilities', `${backend.id}/config`)

View File

@@ -320,7 +320,7 @@ export default Route.extend(UnloadModelRoute, {
if (!model.hasDirtyAttributes || model.isDeleted) {
return true;
}
// TODO: below is KV v2 logic, remove with engine work
// TODO kv engine cleanup: below is KV v2 logic, remove with engine work
const version = model.get('selectedVersion');
const changed = model.changedAttributes();
const changedKeys = Object.keys(changed);

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationSerializer from '../application';
export default class KvDataSerializer extends ApplicationSerializer {
serialize(snapshot) {
const { secretData, casVersion } = snapshot.record;
if (typeof casVersion === 'number') {
/* if this is a number it is set by one of the following:
A) user is creating initial version of a secret
-> 0 : default value set in route
B) user is creating a new version of a secret:
-> metadata.current_version : has metadata read permissions (data permissions are irrelevant)
-> secret.version : has data read permissions. without metadata read access a user is unable to navigate,
to older secret versions so we assume creation is from the latest version */
return { data: secretData, options: { cas: casVersion } };
}
// a non-number value means no read permission for both data and metadata
return { data: secretData };
}
normalizeKvData(payload) {
const { data, metadata } = payload.data;
return {
...payload,
data: {
...payload.data,
// Rename to secret_data so it doesn't get removed by normalizer
secret_data: data,
...metadata,
},
};
}
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (requestType === 'queryRecord') {
const transformed = this.normalizeKvData(payload);
return super.normalizeResponse(store, primaryModelClass, transformed, id, requestType);
}
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
}
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { assert } from '@ember/debug';
import ApplicationSerializer from '../application';
import { kvMetadataPath } from 'vault/utils/kv-path';
export default class KvMetadataSerializer extends ApplicationSerializer {
attrs = {
backend: { serialize: false },
path: { serialize: false },
oldestVersion: { serialize: false },
createdTime: { serialize: false },
updatedTime: { serialize: false },
currentVersion: { serialize: false },
versions: { serialize: false },
};
normalizeItems(payload) {
if (payload.data.keys) {
assert('payload.backend must be provided on kv/metadata list response', !!payload.backend);
return payload.data.keys.map((secret) => {
// If there is no payload.path then we're either on a "top level" secret or the first level directory of a nested secret.
// We set the path to the current secret or pathToSecret. e.g. my-secret or beep/boop/
// We add a param called full_secret_path to the model which we use to navigate to the nested secret. e.g. beep/boop/bop.
const fullSecretPath = payload.path ? payload.path + secret : secret;
return {
id: kvMetadataPath(payload.backend, fullSecretPath),
path: secret,
backend: payload.backend,
full_secret_path: fullSecretPath,
};
});
}
return super.normalizeItems(payload);
}
}

View File

@@ -132,6 +132,7 @@ export default class StoreService extends Store {
prevPage: clamp(currentPage - 1, 1, lastPage),
total: dataset.length || 0,
filteredTotal: data.length || 0,
pageSize: size,
};
return resp;

View File

@@ -2,6 +2,7 @@
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// TODO kv engine cleanup safe to remove this, the new component does not rely on this css
.visual-diff {
background-color: black;

View File

@@ -110,6 +110,16 @@ pre {
}
}
.is-danger {
.modal-card-head {
background: $red-010;
border: 1px solid $red-100;
}
.modal-card-title {
color: $red-dark;
}
}
.modal-confirm-section {
margin: $spacing-xl 0 $spacing-m;
}
@@ -118,7 +128,7 @@ pre {
background: $ui-gray-050;
border-top: $base-border;
}
// kv engine clean up - can remove this style after you remove secret-delete-menu
.modal-radio-button {
display: flex;
align-items: baseline;

View File

@@ -133,6 +133,7 @@ a.disabled.toolbar-link {
width: 0;
}
// TODO kv engine cleanup
.version-diff-toolbar {
display: flex;
align-items: baseline;

View File

@@ -29,6 +29,7 @@
@import './core/file';
@import './core/footer';
@import './core/inputs';
@import './core/json-diff-patch';
@import './core/label';
@import './core/level';
@import './core/link';

View File

@@ -92,6 +92,12 @@
color: $red-500;
}
&.is-warning-outlined {
background-color: $yellow-010;
border: 1px solid $yellow-700;
color: $yellow-700;
}
&.is-flat {
min-width: auto;
border: none;

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
// used in KV version diff view. https://github.com/benjamine/jsondiffpatch/tree/master
.jsondiffpatch-deleted .jsondiffpatch-property-name,
.jsondiffpatch-deleted pre,
.jsondiffpatch-modified .jsondiffpatch-left-value pre,
.jsondiffpatch-textdiff-deleted {
background: $red-500;
}
.jsondiffpatch-added .jsondiffpatch-property-name,
.jsondiffpatch-added .jsondiffpatch-value pre,
.jsondiffpatch-modified .jsondiffpatch-right-value pre,
.jsondiffpatch-textdiff-added {
background: $green-500;
}
.jsondiffpatch-property-name {
color: $ui-gray-300;
}

View File

@@ -18,6 +18,10 @@
background-color: $ui-gray-200;
}
.background-color-black {
background-color: black;
}
// borders
.has-border-top-light {
border-radius: 0;
@@ -40,6 +44,10 @@ select.has-error-border {
}
// text color
.text-grey-lightest {
color: $grey-lightest;
}
.has-text-grey-light {
color: $ui-gray-300 !important;
}

View File

@@ -118,6 +118,10 @@
grid-template-columns: repeat(3, 1fr);
}
.align-self-center {
align-self: center;
}
.is-medium-height {
height: 125px;
}

View File

@@ -34,6 +34,10 @@
position: relative;
}
.top-xxs {
top: $spacing-xxs;
}
// visibility
.is-invisible {
visibility: hidden;
@@ -44,6 +48,10 @@
width: 100%;
}
.is-three-fourths-width {
width: 75%;
}
.is-auto-width {
width: auto;
}

View File

@@ -104,3 +104,7 @@
color: inherit;
}
}
.opacity-060 {
opacity: 0.6;
}

View File

@@ -3,6 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{! TODO kv engine cleanup }}
<Toolbar>
<div class="version-diff-toolbar" data-test-version-diff-toolbar>
{{! Left side version }}

View File

@@ -33,6 +33,7 @@
@isMarginless={{true}}
/>
{{/if}}
{{! TODO kv engine cleanup }}
{{#if @modelForData.isFolder}}
<p class="help has-text-danger">
The secret path may not end in
@@ -172,7 +173,7 @@
<ul class={{if (and (eq @canReadSecretData false) this.isCreateNewVersionFromOldVersion) "bullet"}}>
{{#if (eq @canReadSecretData false)}}
<li data-test-warning-no-read-permissions>
You do not have read permissions. If a secret exists here creating a new secret will overwrite it.
You do not have read permissions. If a secret exists at this path creating a new secret will overwrite it.
</li>
{{/if}}
{{#if this.isCreateNewVersionFromOldVersion}}

View File

@@ -30,59 +30,15 @@
/>
{{/if}}
{{#if (and (eq @mode "show") @canUpdateSecretData)}}
{{! TODO kv engine cleanup - remove @isV2 logic }}
{{#unless (and @isV2 (or @isWriteWithoutRead @modelForData.destroyed @modelForData.deleted))}}
<BasicDropdown
@class="popup-menu"
@horizontalPosition="auto-right"
@verticalPosition="below"
@onClose={{action "clearWrappedData"}}
as |D|
>
<D.Trigger
data-test-popup-menu-trigger="true"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
Copy
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu">
<ul class="menu-list">
<li class="action">
<CopyButton
@class="link link-plain has-text-weight-semibold is-ghost"
@clipboardText={{stringify @modelForData.secretData}}
@success={{action (set-flash-message "JSON Copied!")}}
data-test-copy-button
>
Copy JSON
</CopyButton>
</li>
<li class="action">
{{#if this.showWrapButton}}
<button
class="link link-plain has-text-weight-semibold is-ghost {{if this.isWrapping 'is-loading'}}"
type="button"
{{on "click" this.handleWrapClick}}
data-test-wrap-button
disabled={{this.isWrapping}}
>
Wrap secret
</button>
{{else}}
<MaskedInput
@class="has-padding"
@displayOnly={{true}}
@allowCopy={{true}}
@value={{this.wrappedData}}
/>
{{/if}}
</li>
</ul>
</nav>
</D.Content>
</BasicDropdown>
<CopySecretDropdown
@clipboardText={{stringify @modelForData.secretData}}
@onWrap={{perform this.wrapSecret}}
@isWrapping={{this.wrapSecret.isRunning}}
@wrappedData={{this.wrappedData}}
@onClose={{this.clearWrappedData}}
/>
{{/unless}}
{{/if}}

View File

@@ -3,6 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{! TODO kv engine cleanup : this component can remove all logic related to V2 }}
{{#if (and @isV2 @modelForData.destroyed)}}
<EmptyState
@title="Version {{@modelForData.version}} of this secret has been permanently destroyed"

View File

@@ -3,6 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{! TODO kv engine cleanup : this component can be deleted in favor of <KvVersionDropdown/> }}
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
data-test-popup-menu-trigger="version"

View File

@@ -36,19 +36,16 @@
@placeholder="Filter leases"
/>
{{#if this.filterFocused}}
{{! template-lint-disable no-whitespace-for-layout }}
&nbsp; &nbsp;
{{! template-lint-enable no-whitespace-for-layout }}
{{#if this.filterMatchesKey}}
{{#unless this.filterIsFolder}}
<p class="help has-text-grey is-size-8">
<p class="help has-text-grey is-size-8 has-left-padding-xs">
<kbd>ENTER</kbd>
to go to see details
</p>
{{/unless}}
{{/if}}
{{#if this.firstPartialMatch}}
<p class="help has-text-grey is-size-8">
<p class="help has-text-grey is-size-8 has-left-padding-xs">
<kbd>TAB</kbd>
to complete
</p>

View File

@@ -3,6 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{! TODO kv engine cleanup }}
<PageHeader as |p|>
<p.top>
<KeyValueHeader

View File

@@ -99,6 +99,7 @@
<nav class="menu" aria-label="{{if backend.isSupportedBackend 'supported' 'unsupported'}} secrets engine menu">
<ul class="menu-list">
<li class="action">
{{! TODO kv engine cleanup: this link will not be accurate for kv config details }}
<LinkTo @route="vault.cluster.secrets.backend.configuration" @model={{backend.id}} data-test-engine-config>
View configuration
</LinkTo>

32
ui/app/utils/kv-path.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
/**
* This set of utils is for calculating the full path for a given KV V2 secret, which doubles as its ID.
* Additional methods for building URLs for other KV-V2 actions
*/
import { encodePath } from 'vault/utils/path-encoding-helpers';
function buildKvPath(backend: string, path: string, type: string, version?: number | string) {
const url = `${encodePath(backend)}/${type}/${encodePath(path)}`;
return version ? `${url}?version=${version}` : url;
}
export function kvDataPath(backend: string, path: string, version?: number | string) {
return buildKvPath(backend, path, 'data', version);
}
export function kvDeletePath(backend: string, path: string, version?: number | string) {
return buildKvPath(backend, path, 'delete', version);
}
export function kvMetadataPath(backend: string, path: string) {
return buildKvPath(backend, path, 'metadata');
}
export function kvDestroyPath(backend: string, path: string) {
return buildKvPath(backend, path, 'destroy');
}
export function kvUndeletePath(backend: string, path: string) {
return buildKvPath(backend, path, 'undelete');
}

View File

@@ -0,0 +1,38 @@
{{! @onWrap is recommend to be a concurrency task! see <Page::Secret::Details> in KV addon for example }}
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" @onClose={{@onClose}} as |D|>
<D.Trigger data-test-copy-menu-trigger class="toolbar-link {{if D.isOpen 'is-active'}}" @htmlTag="button">
Copy
<Chevron @direction={{if D.isOpen "up" "down"}} @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu">
<ul class="menu-list">
<li class="action">
<CopyButton
@class="link"
@clipboardText={{@clipboardText}}
@success={{fn (set-flash-message "JSON Copied!")}}
data-test-copy-button
>
Copy JSON
</CopyButton>
</li>
<li class="action">
{{#if @wrappedData}}
<MaskedInput @class="has-padding" @displayOnly={{true}} @allowCopy={{true}} @value={{@wrappedData}} />
{{else}}
<button
class="link button {{if @isWrapping 'is-loading'}}"
type="button"
{{on "click" @onWrap}}
disabled={{@isWrapping}}
data-test-wrap-button
>
Wrap secret
</button>
{{/if}}
</li>
</ul>
</nav>
</D.Content>
</BasicDropdown>

View File

@@ -7,7 +7,24 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class inputSelect extends Component {
/**
* @module InputSearch
* This component renders an input that fires a callback on "keyup" containing the input's value
*
* @example
* <InputSearch
* @initialValue="secret/path/"
* @onChange={{this.handleSearch}}
* @placeholder="search..."
* />
* @param {string} [id] - unique id for the input
* @param {string} [initialValue] - initial search value, i.e. a secret path prefix, that pre-fills the input field
* @param {string} [placeholder] - placeholder text for the input
* @param {string} [label] - label for the input
* @param {string} [subtext] - displays below the label
*/
export default class InputSearch extends Component {
/*
* @public
* @param Function

View File

@@ -21,7 +21,7 @@
<div class="columns is-variable" data-test-kv-row>
<div class="column is-one-quarter">
<Input
data-test-kv-key={{true}}
data-test-kv-key={{index}}
@value={{row.name}}
placeholder={{this.placeholders.key}}
{{on "change" (fn this.updateRow row index)}}
@@ -31,6 +31,13 @@
<div class="column">
{{#if (has-block)}}
{{yield row this.kvData}}
{{else if @isMasked}}
<MaskedInput
data-test-kv-value={{index}}
@name={{row.name}}
@onChange={{fn this.updateRow row index}}
@value={{row.value}}
/>
{{else}}
<Textarea
data-test-kv-value={{index}}
@@ -47,7 +54,7 @@
</div>
<div class="column is-narrow">
{{#if (eq this.kvData.length (inc index))}}
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{true}}>
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{index}}>
Add
</button>
{{else}}

View File

@@ -25,6 +25,7 @@ import KVObject from 'vault/lib/kv-object';
* ```
* @param {string} value - the value is captured from the model.
* @param {function} onChange - function that captures the value on change
* @param {boolean} [isMasked = false] - when true the <MaskedInput> renders instead of the default <textarea> to input the value portion of the key/value object
* @param {function} [onKeyUp] - function passed in that handles the dom keyup event. Used for validation on the kv custom metadata.
* @param {string} [label] - label displayed over key value inputs
* @param {string} [labelClass] - override default label class in FormFieldLabel component

View File

@@ -12,8 +12,7 @@ import Component from '@glimmer/component';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { keyIsFolder, parentKeyForKey } from 'core/utils/key-utils';
// TODO MOVE THESE TO THE ADDON
import keys from 'vault/lib/keycodes';
import keys from 'core/utils/key-codes';
/**
* @module NavigateInput

View File

@@ -5,15 +5,21 @@
<nav class="breadcrumb" aria-label="breadcrumbs" data-test-breadcrumbs>
<ul>
{{#each @breadcrumbs as |breadcrumb|}}
<li>
{{#each @breadcrumbs as |breadcrumb idx|}}
<li data-test-crumb="{{idx}}">
<span class="sep">/</span>
{{#if breadcrumb.linkExternal}}
<LinkToExternal @route={{breadcrumb.route}}>{{breadcrumb.label}}</LinkToExternal>
{{else if breadcrumb.route}}
<LinkTo @route={{breadcrumb.route}}>
{{breadcrumb.label}}
</LinkTo>
{{#if breadcrumb.model}}
<LinkTo @route={{breadcrumb.route}} @model={{breadcrumb.model}}>
{{breadcrumb.label}}
</LinkTo>
{{else}}
<LinkTo @route={{breadcrumb.route}}>
{{breadcrumb.label}}
</LinkTo>
{{/if}}
{{else}}
{{breadcrumb.label}}
{{/if}}

View File

@@ -21,7 +21,7 @@ export default class Breadcrumbs extends Component {
constructor() {
super(...arguments);
this.args.breadcrumbs.forEach((breadcrumb) => {
assert('breadcrumb has a label key', Object.keys(breadcrumb).includes('label'));
assert('breadcrumb must have a label key', Object.keys(breadcrumb).includes('label'));
});
}
}

View File

@@ -13,4 +13,5 @@ export default {
RIGHT: 39,
DOWN: 40,
T: 116,
BACKSPACE: 8,
};

View File

@@ -0,0 +1 @@
export { default } from 'core/components/copy-secret-dropdown';

View File

@@ -3,4 +3,4 @@
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/helpers/format-duration';
export { default, duration } from 'core/helpers/format-duration';

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/helpers/stringify';

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/helpers/to-label';

View File

@@ -0,0 +1,33 @@
{{#let (find-by "name" "path" @secret.allFields) as |attr|}}
{{#if @isEdit}}
<ReadonlyFormField @attr={{attr}} @value={{get @secret attr.name}} />
{{else}}
<FormField @attr={{attr}} @model={{@secret}} @modelValidations={{@modelValidations}} @onKeyUp={{@pathValidations}} />
{{/if}}
{{/let}}
<hr class="is-marginless has-background-gray-200" />
{{#if @showJson}}
<JsonEditor
@title="{{if @isEdit 'Version' 'Secret'}} data"
@value={{or (stringify @secret.secretData) this.emptyJson}}
@valueUpdated={{this.handleJson}}
/>
{{#if (or @modelValidations.secretData.errors this.lintingErrors)}}
<AlertInline @type={{if this.lintingErrors "warning" "danger"}} @paddingTop={{true}}>
{{#if @modelValidations.secretData.errors}}
{{@modelValidations.secretData.errors}}
{{else}}
JSON is unparsable. Fix linting errors to avoid data discrepancies.
{{/if}}
</AlertInline>
{{/if}}
{{else}}
<KvObjectEditor
class="has-top-margin-m"
@label="{{if @isEdit 'Version' 'Secret'}} data"
@value={{@secret.secretData}}
@onChange={{fn (mut @secret.secretData)}}
@isMasked={{true}}
/>
{{/if}}

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import KVObject from 'vault/lib/kv-object';
/**
* @module KvDataFields is used for rendering the fields associated with kv secret data, it hides/shows a json editor and renders validation errors for the json editor
*
* <KvDataFields
* @showJson={{true}}
* @secret={{@secret}}
* @isEdit={{true}}
* @modelValidations={{this.modelValidations}}
* @pathValidations={{this.pathValidations}}
* />
*
* @param {model} secret - Ember data model: 'kv/data', the new record saved by the form
* @param {boolean} showJson - boolean passed from parent to hide/show json editor
* @param {object} [modelValidations] - object of errors. If attr.name is in object and has error message display in AlertInline.
* @param {callback} [pathValidations] - callback function fired for the path input on key up
* @param {boolean} [isEdit=false] - if true, this is a new secret version rather than a new secret. Used to change text for some form labels
*/
export default class KvDataFields extends Component {
@tracked lintingErrors;
get emptyJson() {
// if secretData is null, this specially formats a blank object and renders a nice initial state for the json editor
return KVObject.create({ content: [{ name: '', value: '' }] }).toJSONString(true);
}
@action
handleJson(value, codemirror) {
codemirror.performLint();
this.lintingErrors = codemirror.state.lint.marked.length > 0;
if (!this.lintingErrors) {
this.args.secret.secretData = JSON.parse(value);
}
}
}

View File

@@ -0,0 +1,61 @@
<button type="button" class="toolbar-link" {{on "click" (fn (mut this.modalOpen) true)}} data-test-kv-delete={{@mode}}>
{{yield}}
</button>
{{#if this.modalOpen}}
<Modal
@title={{this.modalDisplay.title}}
@onClose={{fn (mut this.modalOpen) false}}
@isActive={{this.modalOpen}}
@type={{this.modalDisplay.type}}
@showCloseButton={{true}}
>
<section class="modal-card-body">
<p class="has-bottom-margin-s">
{{this.modalDisplay.intro}}
</p>
{{#if (eq @mode "delete")}}
<div class="is-flex-column">
{{#each this.deleteOptions as |option|}}
<ToolTip @verticalPosition="above" @horizontalPosition="left" as |T|>
<T.Trigger @tabindex="-1">
<div class="is-flex-align-baseline has-bottom-margin-m">
<RadioButton
id={{option.key}}
class="radio top-xxs"
@disabled={{option.disabled}}
@value={{option.key}}
@groupValue={{this.deleteType}}
@onChange={{fn (mut this.deleteType) option.key}}
/>
<label for={{option.key}} class="has-left-margin-s {{if option.disabled 'opacity-060'}}">
<p class="has-text-weight-semibold">{{option.label}}</p>
<p>{{option.description}}</p>
</label>
</div>
</T.Trigger>
{{#if option.disabled}}
<T.Content @defaultClass="tool-tip">
<div class="box">
{{option.tooltipMessage}}
</div>
</T.Content>
{{/if}}
</ToolTip>
{{/each}}
</div>
{{/if}}
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button {{if (eq this.modalDisplay.type 'danger') 'is-danger-outlined' 'is-warning-outlined'}}"
{{on "click" this.onDelete}}
>
Confirm
</button>
<button type="button" class="button is-secondary" {{on "click" (fn (mut this.modalOpen) false)}}>
Cancel
</button>
</footer>
</Modal>
{{/if}}

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { assert } from '@ember/debug';
/**
* @module KvDeleteModal displays a button for a delete type and launches a modal. Undelete is the only mode that does not launch the modal and is not handled in this component.
*
* <KvDeleteModal
* @mode="destroy"
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @onDelete={{this.handleDestruction}}
* />
*
* @param {string} mode - delete, delete-metadata, or destroy.
* @param {object} secret - The kv/data model.
* @param {object} [metadata] - The kv/metadata model. It is only required when mode is "delete" or "metadata-delete".
* @param {callback} onDelete - callback function fired to handle delete event.
*/
export default class KvDeleteModal extends Component {
@tracked deleteType = null; // Either delete-version or delete-current-version.
@tracked modalOpen = false;
get modalDisplay() {
switch (this.args.mode) {
// Does not match adapter key directly because a delete type must be selected.
case 'delete':
return {
title: 'Delete version?',
type: 'warning',
intro:
'There are two ways to delete a version of a secret. Both delete actions can be undeleted later. How would you like to proceed?',
};
case 'destroy':
return {
title: 'Destroy version?',
type: 'danger',
intro: `This action will permanently destroy Version ${this.args.secret.version} of the secret, and the secret data cannot be read or recovered later.`,
};
case 'delete-metadata':
return {
title: 'Delete metadata?',
type: 'danger',
intro:
'This will permanently delete the metadata and versions of the secret. All version history will be removed. This cannot be undone.',
};
default:
return assert('mode must be one of delete, destroy, or delete-metadata.');
}
}
get deleteOptions() {
const { secret, metadata } = this.args;
const isDeactivated = secret.canReadMetadata ? metadata?.currentSecret.isDeactivated : false;
return [
{
key: 'delete-version',
label: 'Delete this version',
description: `This deletes Version ${secret.version} of the secret.`,
disabled: !secret.canDeleteVersion,
tooltipMessage: 'You do not have permission to delete a specific version.',
},
{
key: 'delete-latest-version',
label: 'Delete latest version',
description: 'This deletes the most recent version of the secret.',
disabled: !secret.canDeleteLatestVersion || isDeactivated,
tooltipMessage: isDeactivated
? `The latest version of the secret is already ${metadata.currentSecret.state}.`
: 'You do not have permission to delete the latest version of this secret.',
},
];
}
@action
onDelete() {
const type = this.args.mode === 'delete' ? this.deleteType : this.args.mode;
this.args.onDelete(type);
this.modalOpen = false;
}
}

View File

@@ -0,0 +1,35 @@
<div class="navigate-filter">
<div class="field" data-test-nav-input>
<p class="control has-icons-left">
<Input
id="secret-filter"
class="filter input"
placeholder="Filter secrets"
@value={{@filterValue}}
@type="text"
data-test-component="kv-list-filter"
{{on "input" this.handleInput}}
{{on "keydown" this.handleKeyDown}}
{{on "focus" this.setFilterIsFocused}}
{{did-insert this.focusInput}}
autocomplete="off"
/>
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>
</div>
{{#if (and this.filterIsFocused this.isFilterMatch)}}
<p class="help has-text-grey is-size-8 has-left-padding-xs" data-test-help-enter>
<kbd>ENTER</kbd>
to go to see details.
<kbd>ESC</kbd>
to clear input.
</p>
{{else}}
<p class="help has-text-grey is-size-8 has-left-padding-xs" data-test-help-tab>
<kbd>TAB</kbd>
to autocomplete.
<kbd>ESC</kbd>
to clear input.
</p>
{{/if}}

View File

@@ -0,0 +1,166 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import keys from 'core/utils/key-codes';
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';
import escapeStringRegexp from 'escape-string-regexp';
import { tracked } from '@glimmer/tracking';
/**
* @module KvListFilter
* `KvListFilter` filters through the KV metadata LIST response. It allows users to search through the current list, navigate into directories, and use keyboard functions to: autocomplete, view a secret, create a new secret, or clear the input field.
* *
* <KvListFilter
* @secrets={{this.model.secrets}}
* @mountPoint={{this.model.mountPoint}}
* @filterValue="beep/my-"
* @pageFilter="my-"
* />
* @param {array} secrets - An array of secret models.
* @param {string} mountPoint - Where in the router files we're located. For this component it will always be vault.cluster.secrets.backend.kv
* @param {string} filterValue - A concatenation between the list-directory's dynamic path "path-to-secret" and the queryParam "pageFilter". For example, if we're inside the beep/ directory searching for any secret that starts with "my-" this value will equal "beep/my-".
* @param {string} pageFilter - The queryParam value, does not include pathToSecret ex: my-.
*/
export default class KvListFilterComponent extends Component {
@service router;
@tracked filterIsFocused = false;
navigate(pathToSecret, pageFilter) {
const route = pathToSecret ? `${this.args.mountPoint}.list-directory` : `${this.args.mountPoint}.list`;
const args = [route];
if (pathToSecret) {
args.push(pathToSecret);
}
args.push({
queryParams: {
pageFilter: pageFilter ? pageFilter : null,
},
});
this.router.transitionTo(...args);
}
/*
- partialMatch returns the secret that most closely matches the pageFilter queryParam.
- Searches pageFilter and not filterValue because if we're inside a directory we only care about the secrets listed there and not the directory.
- If pageFilter is empty this returns the first secret model in the list.
**/
get partialMatch() {
// If pageFilter is empty we replace it with an empty string because you cannot pass 'undefined' to RegEx.
const value = !this.args.pageFilter ? '' : this.args.pageFilter;
const reg = new RegExp('^' + escapeStringRegexp(value));
const match = this.args.secrets.filter((path) => reg.test(path.fullSecretPath))[0];
if (this.isFilterMatch || !match) return null;
return match.fullSecretPath;
}
/*
- isFilterMatch returns true if the filterValue matches a fullSecretPath.
**/
get isFilterMatch() {
return !!this.args.secrets?.findBy('fullSecretPath', this.args.filterValue);
}
/*
-handleInput is triggered after the value of the input has changed. It is not triggered when input looses focus.
**/
@action
handleInput(event) {
const input = event.target.value;
const isDirectory = keyIsFolder(input);
const parentDirectory = parentKeyForKey(input);
const secretWithinDirectory = keyWithoutParentKey(input);
if (isDirectory) {
this.navigate(input);
} else if (parentDirectory) {
this.navigate(parentDirectory, secretWithinDirectory);
} else {
this.navigate(null, input);
}
}
/*
-handleKeyDown handles: tab, enter, backspace and escape. Ignores everything else.
**/
@action
handleKeyDown(event) {
const input = event.target.value;
const parentDirectory = parentKeyForKey(input);
if (event.keyCode === keys.BACKSPACE) {
this.handleBackspace(input, parentDirectory);
}
if (event.keyCode === keys.TAB) {
event.preventDefault();
this.handleTab();
}
if (event.keyCode === keys.ENTER) {
event.preventDefault();
this.handleEnter(input);
}
if (event.keyCode === keys.ESC) {
this.handleEscape(parentDirectory);
}
// ignore all other key events
return;
}
// key-code specific methods
handleBackspace(input, parentDirectory) {
const isInputDirectory = keyIsFolder(input);
const inputWithoutParentKey = keyWithoutParentKey(input);
const pageFilter = isInputDirectory ? '' : inputWithoutParentKey.slice(0, -1);
this.navigate(parentDirectory, pageFilter);
}
handleTab() {
const isMatchDirectory = keyIsFolder(this.partialMatch);
const matchParentDirectory = parentKeyForKey(this.partialMatch);
const matchWithinDirectory = keyWithoutParentKey(this.partialMatch);
if (isMatchDirectory) {
// ex: beep/boop/
this.navigate(this.partialMatch);
} else if (!isMatchDirectory && matchParentDirectory) {
// ex: beep/boop/my-
this.navigate(matchParentDirectory, matchWithinDirectory);
} else {
// ex: my-
this.navigate(null, this.partialMatch);
}
}
handleEnter(input) {
if (this.isFilterMatch) {
// if secret exists send to details
this.router.transitionTo(`${this.args.mountPoint}.secret.details`, input);
} else {
// if secret does not exists send to create with the path prefilled with input value.
this.router.transitionTo(`${this.args.mountPoint}.create`, {
queryParams: { initialKey: input },
});
}
}
handleEscape(parentDirectory) {
// transition to the nearest parentDirectory. If no parentDirectory, then to the list route.
!parentDirectory ? this.navigate() : this.navigate(parentDirectory);
}
@action
setFilterIsFocused() {
// tracked property used to show or hide the help-text next to the input. Not involved in focus event itself.
this.filterIsFocused = true;
}
@action
focusInput() {
// set focus to the input when there is either a pageFilter queryParam value and/or list-directory's dynamic path-to-secret has a value.
if (this.args.filterValue) {
document.getElementById('secret-filter')?.focus();
}
}
}

View File

@@ -0,0 +1,10 @@
{{#each @metadata.formFields as |attr|}}
{{#if (eq attr.name "customMetadata")}}
<FormField @attr={{attr}} @model={{@metadata}} @modelValidations={{@modelValidations}} />
<label class="title has-top-padding-m is-4">
Additional options
</label>
{{else}}
<FormField @attr={{attr}} @model={{@metadata}} @modelValidations={{@modelValidations}} />
{{/if}}
{{/each}}

View File

@@ -0,0 +1,37 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
{{#if @mountName}}
<Icon @name="kv" @size="24" class="has-text-grey-light" />
{{@mountName}}
<span class="tag">Version 2</span>
{{else}}
{{@pageTitle}}
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if (has-block "tabLinks")}}
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="kv tabs">
<ul>
{{yield to="tabLinks"}}
</ul>
</nav>
</div>
{{/if}}
{{#if (or (has-block "toolbarFilters") (has-block "toolbarActions"))}}
<Toolbar>
<ToolbarFilters>
{{yield to="toolbarFilters"}}
</ToolbarFilters>
<ToolbarActions>
{{yield to="toolbarActions"}}
</ToolbarActions>
</Toolbar>
{{/if}}

View File

@@ -0,0 +1,14 @@
{{! renders a human readable date with a tooltip containing the API timestamp}}
<ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
<T.Trigger data-test-kv-version-tooltip-trigger tabindex="-1">
{{#if @text}}
{{@text}}
{{/if}}
{{date-format @timestamp "MMM dd, yyyy hh:mm a"}}
</T.Trigger>
<T.Content @defaultClass="tool-tip smaller-font">
<div class="box" data-test-hover-copy-tooltip-text>
{{@timestamp}}
</div>
</T.Content>
</ToolTip>

View File

@@ -0,0 +1,37 @@
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger data-test-version-dropdown class="toolbar-link {{if D.isOpen ' is-active'}}">
Version
{{or @displayVersion "current"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content">
<nav class="box menu">
<ul class="menu-list">
{{#each @metadata.sortedVersions as |versionData|}}
<li data-test-version={{versionData.version}} class="action">
<LinkTo @query={{hash version=versionData.version}} {{on "click" (fn @onClose D)}}>
Version
{{versionData.version}}
{{#if versionData.destroyed}}
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
{{else if versionData.deletion_time}}
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
{{/if}}
</LinkTo>
</li>
{{/each}}
{{! version diff }}
{{#if (gt @metadata.sortedVersions.length 1)}}
<hr />
<li>
<LinkTo @route="secret.metadata.diff" {{on "click" (fn @onClose D)}}>
Version Diff
</LinkTo>
</li>
{{/if}}
</ul>
</nav>
</D.Content>
</BasicDropdown>

View File

@@ -0,0 +1,32 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @mountName={{@mountConfig.id}}>
<:tabLinks>
<LinkTo @route="list" data-test-secrets-tab="Secrets">Secrets</LinkTo>
<LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo>
</:tabLinks>
</KvPageHeader>
{{! engine configuration }}
{{#if @engineConfig.canRead}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each @engineConfig.formFields as |attr|}}
<InfoTableRow
@alwaysRender={{true}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{if (eq attr.name "deleteVersionAfter") @engineConfig.displayDeleteTtl (get @engineConfig attr.name)}}
/>
{{/each}}
</div>
{{/if}}
{{! mount configuration }}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each @mountConfig.attrs as |attr|}}
{{#if (not (includes attr.name @engineConfig.displayFields))}}
<InfoTableRow
@formatTtl={{eq attr.options.editType "ttl"}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get @mountConfig (or attr.options.fieldValue attr.name)}}
/>
{{/if}}
{{/each}}
</div>

View File

@@ -0,0 +1,145 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @mountName={{@backend}}>
<:tabLinks>
<LinkTo @route={{this.router.currentRoute.localName}} data-test-secrets-tab="Secrets">Secrets</LinkTo>
<LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo>
</:tabLinks>
<:toolbarFilters>
{{#unless @noMetadataListPermissions}}
{{#if (or @secrets @filterValue)}}
<KvListFilter
@secrets={{@secrets}}
@mountPoint={{this.mountPoint}}
@filterValue={{@filterValue}}
@pageFilter={{@pageFilter}}
/>
{{/if}}
{{/unless}}
</:toolbarFilters>
<:toolbarActions>
<ToolbarLink data-test-toolbar-create-secret @route="create" @query={{hash initialKey=@filterValue}} @type="add">
Create secret
</ToolbarLink>
</:toolbarActions>
</KvPageHeader>
{{#if @noMetadataListPermissions}}
<div class="box is-fullwidth is-shadowless has-tall-padding">
<div class="selectable-card-container one-card">
<OverviewCard
@cardTitle="View secret"
@subText="Type the path of the secret you want to view. Include a trailing slash to navigate to the list view."
>
<form {{on "submit" this.transitionToSecretDetail}} class="has-top-margin-m is-flex">
<InputSearch
@id="search-input-kv-secret"
@initialValue={{@pathToSecret}}
@onChange={{this.handleSecretPathInput}}
@placeholder="secret/"
data-test-view-secret
/>
<button type="submit" class="button is-secondary" disabled={{not this.secretPath}} data-test-get-secret-detail>
{{this.buttonText}}
</button>
</form>
</OverviewCard>
</div>
</div>
{{else}}
{{#if @secrets}}
{{#each @secrets as |metadata|}}
<LinkedBlock
data-test-list-item={{metadata.path}}
class="list-item-row"
@params={{array (if metadata.pathIsDirectory "list-directory" "secret.details") metadata.fullSecretPath}}
@linkPrefix={{this.mountPoint}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Icon @name="user" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{metadata.path}}
</span>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
{{#if metadata.pathIsDirectory}}
<li>
<LinkTo @route="list-directory" @model={{metadata.fullSecretPath}}>
Content
</LinkTo>
</li>
{{else}}
<li>
<LinkTo @route="secret.details" @model={{metadata.fullSecretPath}}>
Details
</LinkTo>
</li>
{{#if metadata.canReadMetadata}}
<li>
<LinkTo @route="secret.metadata.versions" @model={{metadata.fullSecretPath}}>
View version history
</LinkTo>
</li>
{{/if}}
{{#if metadata.canCreateVersionData}}
<li>
<LinkTo @route="secret.details.edit" @model={{metadata.fullSecretPath}}>
Create new version
</LinkTo>
</li>
{{/if}}
{{#if metadata.canDeleteMetadata}}
<li>
<ConfirmAction
@buttonClasses="link is-destroy"
@onConfirmAction={{fn this.onDelete metadata}}
@confirmMessage="This will permanently delete this secret and all its versions."
@cancelButtonText="Cancel"
data-test-delete-metadata={{metadata.path}}
>
Permanently delete
</ConfirmAction>
</li>
{{/if}}
{{/if}}
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}
{{! Pagination }}
<Hds::Pagination::Numbered
@currentPage={{@secrets.meta.currentPage}}
@currentPageSize={{@secrets.meta.pageSize}}
@route={{this.router.currentRoute.localName}}
@showSizeSelector={{false}}
@totalItems={{@secrets.meta.total}}
@queryFunction={{this.paginationQueryParams}}
data-test-pagination
/>
{{else}}
{{#if @filterValue}}
<EmptyState @title="There are no secrets matching &quot;{{@filterValue}}&quot;." />
{{else}}
<EmptyState
data-test-secret-list-empty-state
@title="No secrets yet"
@message="When created, secrets will be listed here. Create a secret to get started."
>
<LinkTo class="has-top-margin-xs" @route="create">
Create secret
</LinkTo>
</EmptyState>
{{/if}}
{{/if}}
{{/if}}

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { getOwner } from '@ember/application';
import { ancestorKeysForKey } from 'core/utils/key-utils';
import errorMessage from 'vault/utils/error-message';
import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs';
/**
* @module List
* ListPage component is a component to show a list of kv/metadata secrets.
*
* @param {array} secrets - An array of models generated form kv/metadata query.
* @param {string} backend - The name of the kv secret engine.
* @param {string} pathToSecret - The directory name that the secret belongs to ex: beep/boop/
* @param {string} pageFilter - The input on the kv-list-filter. Does not include a directory name.
* @param {string} filterValue - The concatenation of the pathToSecret and pageFilter ex: beep/boop/my-
* @param {boolean} noMetadataListPermissions - true if the return to query metadata LIST is 403, indicating the user does not have permissions to that endpoint.
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
*/
export default class KvListPageComponent extends Component {
@service flashMessages;
@service router;
@service store;
@tracked secretPath;
get mountPoint() {
// mountPoint tells transition where to start. In this case, mountPoint will always be vault.cluster.secrets.backend.kv.
return getOwner(this).mountPoint;
}
get buttonText() {
return pathIsDirectory(this.secretPath) ? 'View list' : 'View secret';
}
// callback from HDS pagination to set the queryParams currentPage
get paginationQueryParams() {
return (page) => {
return {
currentPage: page,
};
};
}
@action
async onDelete(model) {
try {
// The model passed in is a kv/metadata model
await model.destroyRecord();
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
const message = `Successfully deleted the metadata and all version data of the secret ${model.fullSecretPath}.`;
this.flashMessages.success(message);
// if you've deleted a secret from within a directory, transition to its parent directory.
if (this.router.currentRoute.localName === 'list-directory') {
const ancestors = ancestorKeysForKey(model.fullSecretPath);
const nearest = ancestors.pop();
this.router.transitionTo(`${this.mountPoint}.list-directory`, nearest);
} else {
// Transition to refresh the model
this.router.transitionTo(`${this.mountPoint}.list`);
}
} catch (error) {
const message = errorMessage(error, 'Error deleting secret. Please try again or contact support.');
this.flashMessages.danger(message);
}
}
@action
handleSecretPathInput(value) {
this.secretPath = value;
}
@action
transitionToSecretDetail(evt) {
evt.preventDefault();
pathIsDirectory(this.secretPath)
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', this.secretPath)
: this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', this.secretPath);
}
}

View File

@@ -0,0 +1,93 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
{{#if @secret.canReadMetadata}}
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
{{/if}}
</:tabLinks>
<:toolbarFilters>
{{#unless this.emptyState}}
<Toggle @name="json" @status="success" @size="small" @checked={{this.showJsonView}} @onChange={{this.toggleJsonView}}>
<span class="has-text-grey">JSON</span>
</Toggle>
{{/unless}}
</:toolbarFilters>
<:toolbarActions>
{{#if this.showUndelete}}
<button type="button" class="toolbar-link" {{on "click" this.undelete}}>
Undelete
</button>
{{/if}}
{{#if this.showDelete}}
<KvDeleteModal @mode="delete" @secret={{@secret}} @metadata={{@metadata}} @onDelete={{this.handleDestruction}}>
Delete
</KvDeleteModal>
{{/if}}
{{#if this.showDestroy}}
<KvDeleteModal @mode="destroy" @secret={{@secret}} @onDelete={{this.handleDestruction}}>
Destroy
</KvDeleteModal>
{{/if}}
<div class="toolbar-separator"></div>
{{#if (and @secret.canReadData (eq @secret.state "created"))}}
<CopySecretDropdown
@clipboardText={{stringify @secret.secretData}}
@onWrap={{perform this.wrapSecret}}
@isWrapping={{this.wrapSecret.isRunning}}
@wrappedData={{this.wrappedData}}
@onClose={{this.clearWrappedData}}
/>
{{/if}}
{{#if @secret.canReadMetadata}}
<KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} />
{{/if}}
{{#if @secret.canEditData}}
<ToolbarLink data-test-create-new-version @route="secret.details.edit" @type="add">Create new version</ToolbarLink>
{{/if}}
</:toolbarActions>
</KvPageHeader>
{{#if (or @secret.deletionTime (not this.emptyState))}}
<div class="info-table-row-header">
<div class="info-table-row thead {{if this.showJsonView 'is-shadowless'}} ">
{{#unless this.hideHeaders}}
<div class="th column is-one-quarter">
Key
</div>
<div class="th column">
Value
</div>
{{/unless}}
<div class="th column justify-right">
{{#if (or @secret.deletionTime @secret.createdTime)}}
<KvTooltipTimestamp
@text="Version {{if @secret.version @secret.version}} {{@secret.state}}"
@timestamp={{or @secret.deletionTime @secret.createdTime}}
/>
{{/if}}
</div>
</div>
</div>
{{/if}}
{{#if this.emptyState}}
<EmptyState @title={{this.emptyState.title}} @message={{this.emptyState.message}}>
{{#if this.emptyState.link}}
<DocLink @path={{this.emptyState.link}}>Learn more</DocLink>
{{/if}}
</EmptyState>
{{else}}
{{#if this.showJsonView}}
<JsonEditor @title="Version data" @value={{stringify @secret.secretData}} @readOnly={{true}} />
{{else}}
{{#each-in @secret.secretData as |key value|}}
<InfoTableRow @label={{key}} @value={{value}} @alwaysRender={{true}}>
<MaskedInput @name={{key}} @value={{value}} @displayOnly={{true}} @allowCopy={{true}} @allowDownload={{true}} />
</InfoTableRow>
{{else}}
<InfoTableRow @label="" @value="" @alwaysRender={{true}} />
{{/each-in}}
{{/if}}
{{/if}}

View File

@@ -0,0 +1,200 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { next } from '@ember/runloop';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
/**
* @module KvSecretDetails renders the key/value data of a KV secret.
* It also renders a dropdown to display different versions of the secret.
* <Page::Secret::Details
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @breadcrumbs={{this.breadcrumbs}}
/>
*
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
* @param {model} secret - Ember data model: 'kv/data'
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
*/
export default class KvSecretDetails extends Component {
@service flashMessages;
@service router;
@service store;
@tracked showJsonView = false;
@tracked wrappedData = null;
@action
toggleJsonView() {
this.showJsonView = !this.showJsonView;
}
@action
closeVersionMenu(dropdown) {
// strange issue where closing dropdown triggers full transition (which redirects to auth screen in production)
// closing dropdown in next tick of run loop fixes it
next(() => dropdown.actions.close());
}
@action
clearWrappedData() {
this.wrappedData = null;
}
@task
@waitFor
*wrapSecret() {
const { backend, path } = this.args.secret;
const adapter = this.store.adapterFor('kv/data');
try {
const { token } = yield adapter.fetchWrapInfo({ backend, path, wrapTTL: 1800 });
if (!token) throw 'No token';
this.wrappedData = token;
this.flashMessages.success('Secret successfully wrapped!');
} catch (error) {
this.flashMessages.danger('Could not wrap secret.');
}
}
@action
async undelete() {
const { secret } = this.args;
try {
await secret.destroyRecord({
adapterOptions: { deleteType: 'undelete', deleteVersions: secret.version },
});
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.flashMessages.success(`Successfully undeleted ${secret.path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret', {
queryParams: { version: secret.version },
});
} catch (err) {
this.flashMessages.danger(
`There was a problem undeleting ${secret.path}. Error: ${err.errors.join(' ')}.`
);
}
}
@action
async handleDestruction(type) {
const { secret } = this.args;
try {
await secret.destroyRecord({ adapterOptions: { deleteType: type, deleteVersions: secret.version } });
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.flashMessages.success(`Successfully ${secret.state} Version ${secret.version} of ${secret.path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret', {
queryParams: { version: secret.version },
});
} catch (err) {
const verb = type.includes('delete') ? 'deleting' : 'destroying';
this.flashMessages.danger(
`There was a problem ${verb} Version ${secret.version} of ${secret.path}. Error: ${err.errors.join(
' '
)}.`
);
}
}
get version() {
return this.args.secret.version || this.router.currentRoute.queryParams.version;
}
get hideHeaders() {
return this.showJsonView || this.emptyState;
}
get versionState() {
const { secret, metadata } = this.args;
if (secret.failReadErrorCode !== 403) {
return secret.state;
}
// If the user can't read secret data, get the current version
// state from metadata versions
if (metadata?.sortedVersions) {
const version = this.version;
const meta = version
? metadata.sortedVersions.find((v) => v.version == version)
: metadata.sortedVersions[0];
if (meta?.destroyed) {
return 'destroyed';
}
if (meta?.deletion_time) {
return 'deleted';
}
if (meta?.created_time) {
return 'created';
}
}
return '';
}
get showUndelete() {
const { secret } = this.args;
if (secret.canUndelete) {
return this.versionState === 'deleted';
}
return false;
}
get showDelete() {
const { secret } = this.args;
if (secret.canDeleteVersion || secret.canDeleteLatestVersion) {
return this.versionState === 'created';
}
return false;
}
get showDestroy() {
const { secret } = this.args;
if (secret.canDestroyVersion) {
return this.versionState !== 'destroyed';
}
return false;
}
get emptyState() {
if (!this.args.secret.canReadData) {
return {
title: 'You do not have permission to read this secret',
message:
'Your policies may permit you to write a new version of this secret, but do not allow you to read its current contents.',
};
}
// only destructure if we can read secret data
const { version, destroyed, deletionTime } = this.args.secret;
if (destroyed) {
return {
title: `Version ${version} of this secret has been permanently destroyed`,
message: `A version that has been permanently deleted cannot be restored. ${
this.args.secret.canReadMetadata
? ' You can view other versions of this secret in the Version History tab above.'
: ''
}`,
link: '/vault/docs/secrets/kv/kv-v2',
};
}
if (deletionTime) {
return {
title: `Version ${version} of this secret has been deleted`,
message: `This version has been deleted but can be undeleted. ${
this.args.secret.canReadMetadata
? 'View other versions of this secret by clicking the Version History tab above.'
: ''
}`,
link: '/vault/docs/secrets/kv/kv-v2',
};
}
return false;
}
}

View File

@@ -0,0 +1,72 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Create New Version">
<:toolbarFilters>
<Toggle @name="json" @status="success" @size="small" @checked={{this.showJsonView}} @onChange={{this.toggleJsonView}}>
<span class="has-text-grey">JSON</span>
</Toggle>
</:toolbarFilters>
</KvPageHeader>
{{#if this.showOldVersionAlert}}
<Hds::Alert data-test-secret-version-alert @type="inline" @color="warning" class="has-top-bottom-margin" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You are creating a new version based on data from Version
{{@previousVersion}}. The current version for
<code>{{@secret.path}}</code>
is Version
{{@currentVersion}}.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if @noReadAccess}}
<Hds::Alert data-test-secret-no-read-alert @type="inline" @color="warning" class="has-top-bottom-margin" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You do not have read permissions for this secret data. Saving will overwrite the existing secret.
</A.Description>
</Hds::Alert>
{{/if}}
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @model={{@secret}} @errorMessage={{this.errorMessage}} />
<KvDataFields
@showJson={{this.showJsonView}}
@secret={{@secret}}
@modelValidations={{this.modelValidations}}
@isEdit={{true}}
/>
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-kv-save
>
Save
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-kv-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
@mimicRefresh={{true}}
/>
{{/if}}
</div>
</form>

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { inject as service } from '@ember/service';
/**
* @module KvSecretEdit is used for creating a new version of a secret
*
* <Page::Secret::Edit
* @secret={{this.model.newVersion}}
* @previousVersion={{this.model.secret.version}}
* @currentVersion={{this.model.metadata.currentVersion}}
* @breadcrumbs={{this.breadcrumbs}
* />
*
* @param {model} secret - Ember data model: 'kv/data', the new record for the new secret version saved by the form
* @param {number} previousVersion - previous secret version number
* @param {number} currentVersion - current secret version, comes from the metadata endpoint
* @param {array} breadcrumbs - breadcrumb objects to render in page header
*/
export default class KvSecretEdit extends Component {
@service flashMessages;
@service router;
@tracked showJsonView = false;
@tracked errorMessage;
@tracked modelValidations;
@tracked invalidFormAlert;
get showOldVersionAlert() {
const { currentVersion, previousVersion } = this.args;
// isNew check prevents alert from flashing after save but before route transitions
if (!currentVersion || !previousVersion || !this.args.secret.isNew) return false;
if (currentVersion !== previousVersion) return true;
return false;
}
@action
toggleJsonView() {
this.showJsonView = !this.showJsonView;
}
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage } = this.args.secret.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
const { secret } = this.args;
yield this.args.secret.save();
this.flashMessages.success(`Successfully created new version of ${secret.path}.`);
// transition to parent secret route to re-query latest version
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret');
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorMessage = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
@action
onCancel() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details');
}
}

View File

@@ -0,0 +1,72 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
{{#if @secret.canReadMetadata}}
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
{{/if}}
</:tabLinks>
<:toolbarActions>
{{#if @metadata.canDeleteMetadata}}
<KvDeleteModal @mode="delete-metadata" @metadata={{@metadata}} @onDelete={{this.onDelete}}>
Delete metadata
</KvDeleteModal>
{{/if}}
{{#if @metadata.canUpdateMetadata}}
<ToolbarLink @route="secret.metadata.edit" data-test-edit-metadata>Edit metadata</ToolbarLink>
{{/if}}
</:toolbarActions>
</KvPageHeader>
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
Custom metadata
</h2>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section>
{{#if (or @metadata.canReadMetadata @secret.canReadData)}}
{{#each-in (or @metadata.customMetadata @secret.customMetadata) as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else}}
<EmptyState
@title="No custom metadata"
@bottomBorder={{true}}
@message="This data is version-agnostic and is usually used to describe the secret being stored."
>
{{#if @metadata.canUpdateMetadata}}
<LinkTo @route="secret.metadata.edit" @model={{@path}} data-test-add-custom-metadata>
Add metadata
</LinkTo>
{{/if}}
</EmptyState>
{{/each-in}}
{{else}}
<EmptyState
@title="You do not have access to read custom metadata"
@message="In order to read custom metadata you either need read access to the secret data and/or read access to metadata."
/>
{{/if}}
</div>
<section data-test-kv-metadata-section>
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
Secret metadata
</h2>
{{#if @metadata.canReadMetadata}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section>
<InfoTableRow @alwaysRender={{true}} @label="Last updated">
<KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} />
</InfoTableRow>
<InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{@metadata.maxVersions}} />
<InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{@metadata.casRequired}} />
<InfoTableRow
@alwaysRender={{true}}
@label="Delete version after"
@value={{if (eq @metadata.deleteVersionAfter "0s") "Never delete" (format-duration @metadata.deleteVersionAfter)}}
/>
</div>
{{else}}
<EmptyState
@title="You do not have access to secret metadata"
@message="In order to edit secret metadata, the UI requires read permissions; otherwise, data may be deleted. Edits can still be made via the API and CLI."
/>
{{/if}}
</section>

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
/**
* @module KvSecretMetadataDetails renders the details view for kv/metadata.
* It also renders a button to delete metadata.
* <Page::Secret::Metadata::Details
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @breadcrumbs={{this.breadcrumbs}}
/>
*
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
* @param {model} [secret] - Ember data model: 'kv/data'. Param not required for delete-metadata.
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
*/
export default class KvSecretMetadataDetails extends Component {
@service flashMessages;
@service router;
@service store;
@action
async onDelete() {
// The only delete option from this view is delete on metadata.
const { metadata } = this.args;
try {
await metadata.destroyRecord();
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.flashMessages.success(
`Successfully deleted the metadata and all version data for the secret ${metadata.path}.`
);
this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
} catch (err) {
this.flashMessages.danger(`There was an issue deleting ${metadata.path} metadata.`);
}
}
}

View File

@@ -0,0 +1,57 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Edit Secret Metadata" />
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m has-bottom-margin-m">
The options below are all version-agnostic; they apply to all versions of this secret.
</p>
{{#if @metadata.canUpdateMetadata}}
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="update" @noun="KV secret metadata" />
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
<KvMetadataFields @metadata={{@metadata}} @modelValidations={{this.modelValidations}} />
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="has-top-padding-s">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-kv-save
>
Update
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-kv-cancel
>
Cancel
</button>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline
data-test-invalid-form-alert
@type="danger"
@paddingTop={{true}}
@message={{this.invalidFormAlert}}
@mimicRefresh={{true}}
/>
</div>
{{/if}}
</div>
</div>
</form>
{{else}}
<EmptyState
@title="You do not have permissions to edit metadata"
@message="Ask your administrator if you think you should have access."
>
<LinkTo @route="secret.metadata.index" @model={{@metadata.path}}>
View Metadata
</LinkTo>
<DocLink @path="/vault/api-docs/secret/kv/kv-v2#create-update-metadata">More here</DocLink>
</EmptyState>
{{/if}}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import { action } from '@ember/object';
/**
* @module KvSecretMetadataEdit
* This component renders the view for editing a kv secret's metadata.
* While secret data and metadata are created on the same view, they are edited on different views/routes.
*
* @param {array} metadata - The kv/metadata model. It is version agnostic.
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
* @callback onCancel - Callback triggered when cancel button is clicked that transitions to the metadata details route.
* @callback onSave - Callback triggered on save success that transitions to the metadata details route.
*/
export default class KvSecretMetadataEditComponent extends Component {
@service flashMessages;
@tracked errorBanner = '';
@tracked invalidFormAlert = '';
@tracked modelValidations = null;
@action
cancel() {
this.args.metadata.rollbackAttributes();
this.args.onCancel();
}
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage } = this.args.metadata.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
const { path } = this.args.metadata;
yield this.args.metadata.save();
this.flashMessages.success(`Successfully updated ${path}'s metadata.`);
this.args.onSave();
}
} catch (error) {
this.errorBanner = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
}

View File

@@ -0,0 +1,91 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:toolbarFilters>
{{! left side version selector }}
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger data-test-version-dropdown-left class="toolbar-link {{if D.isOpen ' is-active'}}">
Version
{{this.leftSideVersion}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content">
<nav class="box menu" aria-label="version-left">
<ul class="menu-list">
{{#each @metadata.sortedVersions as |versionData|}}
<li>
<button
type="button"
class="link {{if (loose-equal versionData.version this.leftSideVersion) 'is-active'}}"
{{on "click" (fn this.selectVersion versionData.version D.actions "left")}}
disabled={{(or versionData.destroyed versionData.deletion_time)}}
>
Version
{{versionData.version}}
{{#if versionData.destroyed}}
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
{{else if versionData.deletion_time}}
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
{{else if (loose-equal @metadata.currentVersion versionData.version)}}
<Icon @name="check-circle-fill" class="has-text-success is-pulled-right" />
{{/if}}
</button>
</li>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
{{! right side version selector }}
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger data-test-version-dropdown-right class="toolbar-link {{if D.isOpen ' is-active'}}">
Version
{{this.rightSideVersion}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content">
<nav class="box menu" aria-label="version-right">
<ul class="menu-list">
{{#each @metadata.sortedVersions as |versionData|}}
<li>
<button
type="button"
class="link {{if (loose-equal versionData.version this.rightSideVersion) 'is-active'}}"
{{on "click" (fn this.selectVersion versionData.version D.actions "right")}}
disabled={{(or versionData.destroyed versionData.deletion_time)}}
data-test-version-button={{versionData.version}}
>
Version
{{versionData.version}}
{{#if versionData.destroyed}}
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
{{else if versionData.deletion_time}}
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
{{else if (loose-equal @metadata.currentVersion versionData.version)}}
<Icon @name="check-circle-fill" class="has-text-success is-pulled-right" />
{{/if}}
</button>
</li>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
{{! status icon }}
{{#if this.statesMatch}}
<div class="has-left-padding-s">
<Icon @name="check-circle-fill" class="has-text-success" />
<span>States match</span>
</div>
{{/if}}
</:toolbarFilters>
</KvPageHeader>
{{! Show an empty state if the current version of the secret is deleted or destroyed. This would only happen on init. }}
{{#if (and (loose-equal this.leftSideVersion @metadata.currentVersion) @metadata.currentSecret.isDeactivated)}}
<EmptyState
@title="Version {{@metadata.currentVersion}} of {{@path}} has been {{@metadata.currentSecret.state}}"
@message="Please select another version of the secret to compare."
/>
{{else}}
<div class="form-section visual-diff text-grey-lightest background-color-black">
<pre data-test-visual-diff>{{sanitized-html this.visualDiff}}</pre>
</div>
{{/if}}

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { kvDataPath } from 'vault/utils/kv-path';
/**
* @module KvVersionDiff
* This component produces a JSON diff view between 2 secret versions. It uses the library jsondiffpatch.
*
* @param {string} backend - Backend from the kv/data model.
* @param {string} path - Backend from the kv/data model.
* @param {array} metadata - The kv/metadata model. It is version agnostic.
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component.
*/
export default class KvVersionDiffComponent extends Component {
@service store;
@tracked leftSideVersion;
@tracked rightSideVersion;
@tracked visualDiff;
@tracked statesMatch = false;
constructor() {
super(...arguments);
// tracked properties set here because they use args.
this.leftSideVersion = this.args.metadata.currentVersion;
this.rightSideVersion = this.defaultRightSideVersion;
this.createVisualDiff();
}
get defaultRightSideVersion() {
// unless the version is destroyed or deleted we return the version prior to the current version.
const versionData = this.args.metadata.sortedVersions.find(
(version) =>
version.destroyed === false && version.deletion_time === '' && version.version != this.leftSideVersion
);
return versionData ? versionData.version : this.leftSideVersion - 1;
}
async fetchSecretData(version) {
const { backend, path } = this.args;
// check the store first, avoiding an extra network request if possible.
const storeData = await this.store.peekRecord('kv/data', kvDataPath(backend, path, version));
const data = storeData ? storeData : await this.store.queryRecord('kv/data', { backend, path, version });
return data?.secretData;
}
async createVisualDiff() {
/* eslint-disable no-undef */
const leftSideData = await this.fetchSecretData(Number(this.leftSideVersion));
const rightSideData = await this.fetchSecretData(Number(this.rightSideVersion));
const diffpatcher = jsondiffpatch.create({});
const delta = diffpatcher.diff(rightSideData, leftSideData);
this.statesMatch = !delta;
this.visualDiff = delta
? jsondiffpatch.formatters.html.format(delta, rightSideData)
: JSON.stringify(leftSideData, undefined, 2);
}
@action selectVersion(version, actions, side) {
if (side === 'right') {
this.rightSideVersion = version;
}
if (side === 'left') {
this.leftSideVersion = version;
}
// close dropdown menu.
if (actions) actions.close();
this.createVisualDiff();
}
}

View File

@@ -0,0 +1,97 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
</:tabLinks>
</KvPageHeader>
<Toolbar />
{{#if @metadata.canReadMetadata}}
<div class="sub-text has-text-weight-semibold is-flex-end has-short-padding">
<KvTooltipTimestamp @text="Secret last updated" @timestamp={{@metadata.updatedTime}} />
</div>
{{#each @metadata.sortedVersions as |versionData|}}
<LinkedBlock
class="list-item-row"
@params={{array "vault.cluster.secrets.backend.kv.secret.details" @metadata.path}}
@queryParams={{hash version=versionData.version}}
data-test-version-linked-block={{versionData.version}}
>
<div class="level is-mobile">
<div class="is-grid is-grid-3-columns is-three-fourths-width">
{{! version number and icon }}
<div class="align-self-center">
<Icon @name="history" class="has-text-grey" data-test-version />
<span class="has-text-weight-semibold has-text-black">
Version
{{versionData.version}}
</span>
</div>
{{! icons }}
<div class="align-self-center" data-test-icon-holder={{versionData.version}}>
{{#if versionData.destroyed}}
<div>
<span class="has-text-danger is-size-8 is-block">
<Icon @name="x-square-fill" />Destroyed
</span>
</div>
{{else if versionData.deletion_time}}
<div>
<span class="has-text-grey is-size-8 is-block">
<Icon @name="x-square-fill" />
<KvTooltipTimestamp @text="Deleted" @timestamp={{versionData.deletion_time}} />
</span>
</div>
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
<div>
<span class="has-text-success is-size-8 is-block">
<Icon @name="check-circle-fill" />Current
</span>
</div>
{{/if}}
</div>
{{! version created date }}
<div class="is-size-8 has-text-weight-semibold has-text-grey align-self-center">
<KvTooltipTimestamp @text="Created" @timestamp={{versionData.created_time}} />
</div>
</div>
<div class="level-right">
<div class="level-item">
<PopupMenu @name="version-{{versionData.version}}">
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo @route="secret.details" @query={{hash version=versionData.version}}>
View version
{{versionData.version}}
</LinkTo>
</li>
{{#if @metadata.canCreateVersionData}}
<li>
<LinkTo
@route="secret.details.edit"
@query={{hash version=versionData.version}}
data-test-create-new-version-from={{versionData.version}}
@disabled={{or versionData.destroyed versionData.deletion_time}}
>
Create new version from
{{versionData.version}}
</LinkTo>
</li>
{{/if}}
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}
{{else}}
<EmptyState
@title="You do not have permission to read metadata"
@message="Ask your administrator if you think you should have access."
/>
{{/if}}

View File

@@ -0,0 +1,65 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Create Secret">
<:toolbarFilters>
<Toggle @name="json" @status="success" @size="small" @checked={{this.showJsonView}} @onChange={{this.toggleJsonView}}>
<span class="has-text-grey">JSON</span>
</Toggle>
</:toolbarFilters>
</KvPageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @errorMessage={{this.errorMessage}} />
<KvDataFields
@showJson={{this.showJsonView}}
@secret={{@secret}}
@modelValidations={{this.modelValidations}}
@pathValidations={{this.pathValidations}}
/>
<ToggleButton
@isOpen={{this.showMetadata}}
@openLabel="Hide secret metadata"
@closedLabel="Show secret metadata"
@onClick={{fn (mut this.showMetadata)}}
class="is-block"
data-test-metadata-toggle
/>
{{#if this.showMetadata}}
<div class="box has-container" data-test-metadata-section>
<KvMetadataFields @metadata={{@metadata}} @modelValidations={{this.modelValidations}} />
</div>
{{/if}}
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-kv-save
>
Save
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-kv-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
@mimicRefresh={{true}}
/>
{{/if}}
</div>
</form>

View File

@@ -0,0 +1,127 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { inject as service } from '@ember/service';
import { pathIsFromDirectory } from 'kv/utils/kv-breadcrumbs';
import errorMessage from 'vault/utils/error-message';
/**
* @module KvSecretCreate is used for creating the initial version of a secret
*
* <Page::Secrets::Create
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @breadcrumbs={{this.breadcrumbs}}
* />
*
* @param {model} secret - Ember data model: 'kv/data', the new record saved by the form
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {array} breadcrumbs - breadcrumb objects to render in page header
*/
export default class KvSecretCreate extends Component {
@service flashMessages;
@service router;
@service store;
@tracked showJsonView = false;
@tracked errorMessage; // only renders for kv/data API errors
@tracked modelValidations;
@tracked invalidFormAlert;
@action
toggleJsonView() {
this.showJsonView = !this.showJsonView;
}
@action
pathValidations() {
// check path attribute warnings on key up
const { state } = this.args.secret.validate();
if (state?.path?.warnings) {
// only set model validations if warnings exist
this.modelValidations = state;
}
}
@task
*save(event) {
event.preventDefault();
this.resetErrors();
const { isValid, state } = this.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = isValid ? '' : 'There is an error with this form.';
const { secret, metadata } = this.args;
if (isValid) {
try {
// try saving secret data first
yield secret.save();
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.flashMessages.success(`Successfully saved secret data for: ${secret.path}.`);
} catch (error) {
this.errorMessage = errorMessage(error);
}
// users must have permission to create secret data to create metadata in the UI
// only attempt to save metadata if secret data saves successfully and metadata is edited
if (secret.createdTime && this.hasChanged(metadata)) {
try {
metadata.path = secret.path;
yield metadata.save();
this.flashMessages.success(`Successfully saved metadata.`);
} catch (error) {
this.flashMessages.danger(`Secret data was saved but metadata was not: ${errorMessage(error)}`, {
sticky: true,
});
}
}
// prevent transition if there are errors with secret data
if (this.errorMessage) {
this.invalidFormAlert = 'There was an error submitting this form.';
} else {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', secret.path);
}
}
}
@action
onCancel() {
const { path } = this.args.secret;
pathIsFromDirectory(path)
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', path)
: this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
}
// HELPERS
validate() {
const dataValidations = this.args.secret.validate();
const metadataValidations = this.args.metadata.validate();
const state = { ...dataValidations.state, ...metadataValidations.state };
const failed = !dataValidations.isValid || !metadataValidations.isValid;
return { state, isValid: !failed };
}
hasChanged(model) {
const fieldName = model.formFields.map((attr) => attr.name);
const changedAttrs = Object.keys(model.changedAttributes());
// exclusively check if form field attributes have changed ('backend' and 'path' are passed to createRecord)
return changedAttrs.any((attr) => fieldName.includes(attr));
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = null;
this.modelValidations = null;
this.invalidFormAlert = null;
}
}

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Controller from '@ember/controller';
export default class KvCreateController extends Controller {
queryParams = ['initialKey'];
}

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Controller from '@ember/controller';
export default class KvListController extends Controller {
queryParams = ['pageFilter', 'currentPage'];
}

View File

@@ -0,0 +1,6 @@
import Controller from '@ember/controller';
export default class KvSecretDetailsController extends Controller {
queryParams = ['version'];
version = null;
}

24
ui/lib/kv/addon/engine.js Normal file
View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Engine from '@ember/engine';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';
import config from './config/environment';
const { modulePrefix } = config;
export default class KvEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'],
externalRoutes: ['secrets'],
};
}
loadInitializers(KvEngine, modulePrefix);

25
ui/lib/kv/addon/routes.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import buildRoutes from 'ember-engines/routes';
export default buildRoutes(function () {
// There are two list routes because Ember won't let a route param (e.g. *path_to_secret) be blank.
// :path_to_secret is used when we're listing a secret directory. Example { path: '/:beep%2Fboop%2F/directory' });
this.route('list');
this.route('list-directory', { path: '/:path_to_secret/directory' });
this.route('create');
this.route('secret', { path: '/:name' }, function () {
this.route('details', function () {
this.route('edit'); // route to create new version of a secret
});
this.route('metadata', function () {
this.route('edit');
this.route('versions');
this.route('diff');
});
});
this.route('configuration');
});

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class KvConfigurationRoute extends Route {
@service store;
model() {
const backend = this.modelFor('application');
return hash({
mountConfig: backend,
engineConfig: this.store.findRecord('kv/config', backend.id).catch(() => {
// return an empty record so we have access to model capabilities
return this.store.createRecord('kv/config', { backend: backend.id });
}),
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.mountConfig.id, route: 'list' },
{ label: 'configuration' },
];
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
@withConfirmLeave('model.secret', ['model.metadata'])
export default class KvSecretsCreateRoute extends Route {
@service store;
@service secretMountPath;
model(params) {
const backend = this.secretMountPath.currentPath;
const { initialKey: path } = params;
return hash({
backend,
path,
// see serializer for logic behind setting casVersion
secret: this.store.createRecord('kv/data', { backend, path, casVersion: 0 }),
metadata: this.store.createRecord('kv/metadata', { backend, path }),
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const crumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path),
{ label: 'create' },
];
controller.breadcrumbs = crumbs;
}
}

View File

@@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class KvErrorRoute extends Route {
@service secretMountPath;
setupController(controller) {
super.setupController(...arguments);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'list' },
];
controller.mountName = this.secretMountPath.currentPath;
}
}

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { normalizePath } from 'vault/utils/path-encoding-helpers';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretsListRoute extends Route {
@service store;
@service router;
@service secretMountPath;
queryParams = {
pageFilter: {
refreshModel: true,
},
currentPage: {
refreshModel: true,
},
};
async fetchMetadata(backend, pathToSecret, params) {
return await this.store
.lazyPaginatedQuery('kv/metadata', {
backend,
responsePath: 'data.keys',
page: Number(params.currentPage) || 1,
size: Number(params.currentPageSize),
pageFilter: params.pageFilter,
pathToSecret,
})
.catch((err) => {
if (err.httpStatus === 403) {
return 403;
}
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
model(params) {
const { pageFilter, path_to_secret } = params;
const pathToSecret = path_to_secret ? normalizePath(path_to_secret) : '';
const backend = this.secretMountPath.currentPath;
const filterValue = pathToSecret ? (pageFilter ? pathToSecret + pageFilter : pathToSecret) : pageFilter;
return hash({
secrets: this.fetchMetadata(backend, pathToSecret, params),
backend,
pathToSecret,
filterValue,
pageFilter,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
if (resolvedModel.secrets === 403) {
resolvedModel.noMetadataListPermissions = true;
}
let breadcrumbsArray = [{ label: 'secrets', route: 'secrets', linkExternal: true }];
// if on top level don't link the engine breadcrumb label, but if within a directory, do link back to top level.
if (this.routeName === 'list') {
breadcrumbsArray.push({ label: resolvedModel.backend });
} else {
breadcrumbsArray = [
...breadcrumbsArray,
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.pathToSecret, true),
];
}
controller.set('breadcrumbs', breadcrumbsArray);
}
resetController(controller, isExiting) {
if (isExiting) {
controller.set('pageFilter', null);
controller.set('currentPage', null);
}
}
}

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from './list-directory';

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class KvSecretRoute extends Route {
@service secretMountPath;
@service store;
fetchSecretData(backend, path) {
// This will always return a record unless 404 not found (show error) or control group
return this.store.queryRecord('kv/data', { backend, path });
}
fetchSecretMetadata(backend, path) {
// catch error and do nothing because kv/data model handles metadata capabilities
return this.store.queryRecord('kv/metadata', { backend, path }).catch(() => {});
}
model() {
const backend = this.secretMountPath.currentPath;
const { name: path } = this.paramsFor('secret');
return hash({
path,
backend,
secret: this.fetchSecretData(backend, path),
metadata: this.fetchSecretMetadata(backend, path),
});
}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class KvSecretDetailsRoute extends Route {
@service store;
queryParams = {
version: {
refreshModel: true,
},
};
model(params) {
const parentModel = this.modelFor('secret');
// Only fetch versioned data if selected version does not match parent (current) version
// and parentModel.secret has failReadErrorCode since permissions aren't version specific
if (
params.version &&
parentModel.secret.version !== params.version &&
!parentModel.secret.failReadErrorCode
) {
// query params have changed by selecting a different version from the dropdown
// fire off new request for that version's secret data
const { backend, path } = parentModel;
return hash({
...parentModel,
secret: this.store.queryRecord('kv/data', { backend, path, version: params.version }),
});
}
return parentModel;
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const { version } = this.paramsFor(this.routeName);
controller.set('version', resolvedModel.secret.version || version);
}
resetController(controller, isExiting) {
if (isExiting) {
controller.set('version', null);
}
}
}

View File

@@ -0,0 +1,39 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
@withConfirmLeave('model.newVersion')
export default class KvSecretDetailsEditRoute extends Route {
@service store;
model() {
const parentModel = this.modelFor('secret.details');
const { backend, path, secret, metadata } = parentModel;
return hash({
secret,
metadata,
backend,
path,
newVersion: this.store.createRecord('kv/data', {
backend,
path,
secretData: secret?.secretData,
// see serializer for logic behind setting casVersion
casVersion: metadata?.currentVersion || secret?.version,
}),
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path),
{ label: 'edit' },
];
}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretDetailsIndexRoute extends Route {
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path, true),
];
controller.breadcrumbs = breadcrumbsArray;
}
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class SecretIndex extends Route {
@service router;
redirect() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details');
}
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretMetadataDiffRoute extends Route {
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path),
{ label: 'version diff' },
];
controller.set('breadcrumbs', breadcrumbsArray);
}
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretMetadataEditRoute extends Route {
// model passed from 'secret' route, if we need to access or intercept
// it can retrieved via `this.modelFor('secret'), which includes the metadata model.
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path),
{ label: 'metadata', route: 'secret.metadata' },
{ label: 'edit' },
];
controller.set('breadcrumbs', breadcrumbsArray);
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretMetadataIndexRoute extends Route {
// model passed from parent secret route, if we need to access or intercept
// it can retrieved via `this.modelFor('secret'), which includes the metadata model.
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path),
{ label: 'metadata' },
];
controller.set('breadcrumbs', breadcrumbsArray);
}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class KvSecretMetadataVersionsRoute extends Route {
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path),
{ label: 'version history' },
];
controller.set('breadcrumbs', breadcrumbsArray);
}
}

Some files were not shown because too many files have changed in this diff Show More