KV search box when no list access to metadata (#12626)

* get credentials card test and setup

* call getcrednetials card and remove path test error

* configuration

* metadata search box

* changelog

* checking if it is noReadAccess

* try removing test

* blah

* a test

* blah

* stuff

* attempting a clean up to solve issue

* Another attempt

* test1

* test2

* test3

* test4

* test5

* test6

* test7

* finally?

* clean up
This commit is contained in:
Angel Garbarino 2021-09-29 14:35:00 -06:00 committed by GitHub
parent 33cca7586a
commit 92223b600e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 410 additions and 323 deletions

3
changelog/12626.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Add KV secret search box when no metadata list access.
```

View File

@ -5,11 +5,16 @@
* *
* @example * @example
* ```js * ```js
* <GetCredentialsCard @title="Get Credentials" @searchLabel="Role to use" @models={{array 'database/roles'}} /> * <GetCredentialsCard @title="Get Credentials" @searchLabel="Role to use" @models={{array 'database/roles'}} @type="role" @backend={{model.backend}}/>
* ``` * ```
* @param title=null {String} - The title displays the card title * @param title=null {String} - The title displays the card title
* @param searchLabel=null {String} - The text above the searchSelect component * @param searchLabel=null {String} - The text above the searchSelect component
* @param models=null {Array} - An array of model types to fetch from the API. Passed through to SearchSelect component * @param models=null {Array} - An array of model types to fetch from the API. Passed through to SearchSelect component
* @param type=null {String} - Determines where the transitionTo goes. If role to backend.credentials, if secret backend.show
* @param shouldUseFallback=[false] {Boolean} - If true the input is used instead of search select.
* @param subText=[null] {String} - Text below title
* @param placeHolder=[null] {String} - Only works if shouldUseFallback is true. Displays the helper text inside the input.
* @param backend=null {String} - Name of the backend to look up on search.
*/ */
import Component from '@glimmer/component'; import Component from '@glimmer/component';
@ -20,25 +25,36 @@ export default class GetCredentialsCard extends Component {
@service router; @service router;
@service store; @service store;
@tracked role = ''; @tracked role = '';
@tracked secret = '';
@action @action
async transitionToCredential() { async transitionToCredential() {
const role = this.role; const role = this.role;
const secret = this.secret;
if (role) { if (role) {
this.router.transitionTo('vault.cluster.secrets.backend.credentials', role); this.router.transitionTo('vault.cluster.secrets.backend.credentials', role);
} }
if (secret) {
this.router.transitionTo('vault.cluster.secrets.backend.show', secret);
}
} }
get buttonDisabled() { get buttonDisabled() {
return !this.role; return !this.role && !this.secret;
} }
@action @action
handleRoleInput(value) { handleRoleInput(value) {
// if it comes in from the fallback component then the value is a string otherwise it's an array if (this.args.type === 'role') {
let role = value; // if it comes in from the fallback component then the value is a string otherwise it's an array
if (Array.isArray(value)) { // which currently only happens if type is role.
role = value[0]; if (Array.isArray(value)) {
this.role = value[0];
} else {
this.role = value;
}
}
if (this.args.type === 'secret') {
this.secret = value;
} }
this.role = role;
} }
} }

View File

@ -11,7 +11,7 @@
* @modelForData={{@modelForData}} * @modelForData={{@modelForData}}
* @isV2=true * @isV2=true
* @secretData={{@secretData}} * @secretData={{@secretData}}
* @canCreateSecretMetadata=true * @canCreateSecretMetadata=false
* /> * />
* ``` * ```
* @param {string} mode - create, edit, show determines what view to display * @param {string} mode - create, edit, show determines what view to display
@ -20,7 +20,7 @@
* @param {object} modelForData - a class that helps track secret data, defined in secret-edit * @param {object} modelForData - a class that helps track secret data, defined in secret-edit
* @param {boolean} isV2 - whether or not KV1 or KV2 * @param {boolean} isV2 - whether or not KV1 or KV2
* @param {object} secretData - class that is created in secret-edit * @param {object} secretData - class that is created in secret-edit
* @param {boolean} canCreateSecretMetadata - based on permissions to the /metadata/ endpoint. If user has secret create access. * @param {boolean} canUpdateSecretMetadata - based on permissions to the /metadata/ endpoint. If user has secret update. create is not enough for metadata.
*/ */
import Component from '@glimmer/component'; import Component from '@glimmer/component';

View File

@ -109,7 +109,8 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
'mode' 'mode'
), ),
canDeleteSecretMetadata: alias('checkMetadataCapabilities.canDelete'), canDeleteSecretMetadata: alias('checkMetadataCapabilities.canDelete'),
canCreateSecretMetadata: alias('checkMetadataCapabilities.canCreate'), canUpdateSecretMetadata: alias('checkMetadataCapabilities.canUpdate'),
canReadSecretMetadata: alias('checkMetadataCapabilities.canRead'),
requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'), requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'),

View File

@ -4,19 +4,31 @@ import Route from '@ember/routing/route';
export default Route.extend({ export default Route.extend({
wizard: service(), wizard: service(),
store: service(), store: service(),
model() { async model() {
let backend = this.modelFor('vault.cluster.secrets.backend'); let backend = this.modelFor('vault.cluster.secrets.backend');
if (this.wizard.featureState === 'list') { if (this.wizard.featureState === 'list') {
this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE', backend.get('type')); this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE', backend.get('type'));
} }
if (backend.isV2KV) { if (backend.isV2KV) {
// design wants specific default to show that can't be set in the model let canRead = await this.store
backend.set('casRequired', backend.casRequired ? backend.casRequired : 'False'); .findRecord('capabilities', `${backend.id}/config`)
backend.set( .then(response => response.canRead);
'deleteVersionAfter', // only set these config params if they can read the config endpoint.
backend.deleteVersionAfter !== '0s' ? backend.deleteVersionAfter : 'Never delete' if (canRead) {
); // design wants specific default to show that can't be set in the model
backend.set('maxVersions', backend.maxVersions ? backend.maxVersions : 'Not set'); backend.set('casRequired', backend.casRequired ? backend.casRequired : 'False');
backend.set(
'deleteVersionAfter',
backend.deleteVersionAfter !== '0s' ? backend.deleteVersionAfter : 'Never delete'
);
backend.set('maxVersions', backend.maxVersions ? backend.maxVersions : 'Not set');
} else {
// remove the default values from the model if they don't have read access otherwise it will display the defaults even if they've been set (because they error on returning config data)
// normally would catch the config error in the secret-v2 adapter, but I need the functions to proceed, not stop. So we remove the values here.
backend.set('casRequired', null);
backend.set('deleteVersionAfter', null);
backend.set('maxVersions', null);
}
} }
return backend; return backend;
}, },

View File

@ -10,6 +10,7 @@ const SUPPORTED_BACKENDS = supportedSecretBackends();
export default Route.extend({ export default Route.extend({
templateName: 'vault/cluster/secrets/backend/list', templateName: 'vault/cluster/secrets/backend/list',
pathHelp: service('path-help'), pathHelp: service('path-help'),
noMetadataPermissions: false,
queryParams: { queryParams: {
page: { page: {
refreshModel: true, refreshModel: true,
@ -111,6 +112,9 @@ export default Route.extend({
// if we're at the root we don't want to throw // if we're at the root we don't want to throw
if (backendModel && err.httpStatus === 404 && secret === '') { if (backendModel && err.httpStatus === 404 && secret === '') {
return []; return [];
} else if (backendModel.engineType === 'kv' && backendModel.isV2KV) {
this.set('noMetadataPermissions', true);
return [];
} else { } else {
// else we're throwing and dealing with this in the error action // else we're throwing and dealing with this in the error action
throw err; throw err;
@ -149,6 +153,7 @@ export default Route.extend({
let backend = this.enginePathParam(); let backend = this.enginePathParam();
let backendModel = this.store.peekRecord('secret-engine', backend); let backendModel = this.store.peekRecord('secret-engine', backend);
let has404 = this.has404; let has404 = this.has404;
let noMetadataPermissions = this.noMetadataPermissions;
// only clear store cache if this is a new model // only clear store cache if this is a new model
if (secret !== controller.get('baseKey.id')) { if (secret !== controller.get('baseKey.id')) {
this.store.clearAllDatasets(); this.store.clearAllDatasets();
@ -157,6 +162,7 @@ export default Route.extend({
controller.setProperties({ controller.setProperties({
model, model,
has404, has404,
noMetadataPermissions,
backend, backend,
backendModel, backendModel,
baseKey: { id: secret }, baseKey: { id: secret },

View File

@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
export default class MetadataShow extends Route { export default class MetadataShow extends Route {
@service store; @service store;
noReadAccess = false;
beforeModel() { beforeModel() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend'); const { backend } = this.paramsFor('vault.cluster.secrets.backend');
@ -11,14 +12,23 @@ export default class MetadataShow extends Route {
model(params) { model(params) {
let { secret } = params; let { secret } = params;
return this.store.queryRecord('secret-v2', { return this.store
backend: this.backend, .queryRecord('secret-v2', {
id: secret, backend: this.backend,
}); id: secret,
})
.catch(error => {
// there was an error likely in read metadata.
// still load the page and handle what you show by filtering for this property
if (error.httpStatus === 403) {
this.noReadAccess = true;
}
});
} }
setupController(controller, model) { setupController(controller, model) {
controller.set('backend', this.backend); // for backendCrumb controller.set('backend', this.backend); // for backendCrumb
controller.set('model', model); controller.set('model', model);
controller.set('noReadAccess', this.noReadAccess);
} }
} }

View File

@ -1,18 +1,21 @@
<form class="selectable-card is-rounded no-flex"> <form class="selectable-card is-rounded no-flex data-test-get-credentials-card">
<div class="is-flex-between is-fullwidth card-details" > <div class="is-flex-between is-fullwidth card-details" >
<h3 class="title is-5">{{@title}}</h3> <h3 class="title is-5">{{@title}}</h3>
</div> </div>
<div class="has-top-bottom-margin"> <div class="has-top-bottom-margin">
<p class="is-label search-label">{{@searchLabel}}</p> <p class="is-label search-label">{{@searchLabel}}</p>
</div> </div>
<p class="sub-text">{{@subText}}</p>
<SearchSelect <SearchSelect
@id={{id}} @id={{id}}
@models={{@models}} @models={{@models}}
@selectLimit='1' @selectLimit='1'
@backend={{@backend}} @backend={{@backend}}
@fallbackComponent='input-search' @fallbackComponent='input-search'
@shouldUseFallback={{@shouldUseFallback}}
@onChange={{action 'handleRoleInput' }} @onChange={{action 'handleRoleInput' }}
@inputValue={{get model valuePath}} @inputValue={{get model valuePath}}
@placeHolder={{@placeHolder}}
data-test-search-roles data-test-search-roles
/> />
<input <input

View File

@ -5,6 +5,7 @@
@type="text" @type="text"
@value={{this.searchInput}} @value={{this.searchInput}}
{{on 'keyup' this.inputChanged}} {{on 'keyup' this.inputChanged}}
@placeholder={{@placeHolder}}
/> />
</div> </div>
</div> </div>

View File

@ -97,7 +97,8 @@
{{/each}} {{/each}}
</div> </div>
{{/if}} {{/if}}
{{#if (and @isV2 @canCreateSecretMetadata) }} {{!-- must have UPDATE permissions to add secret metadata. Create only will not work --}}
{{#if (and @isV2 @canUpdateSecretMetadata)}}
<ToggleButton <ToggleButton
@class="is-block" @class="is-block"
@toggleAttr={{"showMetadata"}} @toggleAttr={{"showMetadata"}}
@ -113,7 +114,7 @@
@updateValidationErrorCount={{action "updateValidationErrorCount"}} @updateValidationErrorCount={{action "updateValidationErrorCount"}}
/> />
{{/if}} {{/if}}
{{/if}} {{/if}}
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control"> <div class="control">
<button <button
@ -135,6 +136,7 @@
{{/if}} {{/if}}
{{#if (eq @mode "edit")}} {{#if (eq @mode "edit")}}
{{!-- no metadata option because metadata is version agnostic --}}
<form onsubmit={{action "createOrUpdateKey" "edit"}}> <form onsubmit={{action "createOrUpdateKey" "edit"}}>
<div class="box is-sideless is-fullwidth is-marginless padding-top"> <div class="box is-sideless is-fullwidth is-marginless padding-top">
{{#if @model.canReadSecretData}} {{#if @model.canReadSecretData}}

View File

@ -26,7 +26,8 @@
Secret Secret
</LinkTo> </LinkTo>
</LinkTo> </LinkTo>
{{#if model.canReadMetadata}} {{!-- must have read access to /metadata see tab or update to update metadata--}}
{{#if (or this.canReadSecretMetadata this.canUpdateSecretMetadata)}}
<LinkTo @route="vault.cluster.secrets.backend.metadata" @model={{key.id}} @tagName="li" @activeClass="is-active" data-test-secret-metadata-tab> <LinkTo @route="vault.cluster.secrets.backend.metadata" @model={{key.id}} @tagName="li" @activeClass="is-active" data-test-secret-metadata-tab>
<LinkTo @route="vault.cluster.secrets.backend.metadata"> <LinkTo @route="vault.cluster.secrets.backend.metadata">
Metadata Metadata
@ -65,7 +66,7 @@
@isV2={{isV2}} @isV2={{isV2}}
@secretData={{secretData}} @secretData={{secretData}}
@buttonDisabled={{buttonDisabled}} @buttonDisabled={{buttonDisabled}}
@canCreateSecretMetadata={{canCreateSecretMetadata}} @canUpdateSecretMetadata={{canUpdateSecretMetadata}}
/> />
{{else if (eq mode "show")}} {{else if (eq mode "show")}}
<SecretFormShow <SecretFormShow

View File

@ -5,107 +5,122 @@
@backendCrumb={{backendCrumb}} @backendCrumb={{backendCrumb}}
@filter={{filter}} @filter={{filter}}
/> />
{{#if this.noMetadataPermissions}}
{{#with (options-for-backend backendType tab) as |options|}} <div class="box is-fullwidth is-shadowless has-tall-padding">
{{#if (or model.meta.total (not isConfigurableTab))}} <div class="selectable-card-container one-card">
<Toolbar> <GetCredentialsCard
{{#if model.meta.total}} @shouldUseFallback={{true}}
<ToolbarFilters> @title="View secret"
<NavigateInput @searchLabel="Secret path"
@enterpriseProduct="vault" @subText="Type the path of the secret you want to read"
@filterFocusDidChange={{action "setFilterFocus"}} @placeHolder="secret/"
@filterDidChange={{action "setFilter"}} @backend="kv"
@filter={{this.filter}} @type="secret"
@filterMatchesKey={{filterMatchesKey}} />
@firstPartialMatch={{firstPartialMatch}} </div>
@baseKey={{get baseKey "id"}} </div>
@shouldNavigateTree={{options.navigateTree}} {{else}}
@placeholder={{options.searchPlaceholder}} {{#with (options-for-backend backendType tab) as |options|}}
@mode={{if (eq tab 'certs') 'secrets-cert' 'secrets'}} {{#if (or model.meta.total (not isConfigurableTab))}}
@data-test-nav-input={{true}} <Toolbar>
/> {{#if model.meta.total}}
{{#if filterFocused}} <ToolbarFilters>
{{#if filterMatchesKey}} <NavigateInput
{{#unless filterIsFolder}} @enterpriseProduct="vault"
@filterFocusDidChange={{action "setFilterFocus"}}
@filterDidChange={{action "setFilter"}}
@filter={{this.filter}}
@filterMatchesKey={{filterMatchesKey}}
@firstPartialMatch={{firstPartialMatch}}
@baseKey={{get baseKey "id"}}
@shouldNavigateTree={{options.navigateTree}}
@placeholder={{options.searchPlaceholder}}
@mode={{if (eq tab 'certs') 'secrets-cert' 'secrets'}}
@data-test-nav-input={{true}}
/>
{{#if filterFocused}}
{{#if filterMatchesKey}}
{{#unless filterIsFolder}}
<p class="input-hint">
<kbd>Enter</kbd> to view {{filter}}
</p>
{{/unless}}
{{/if}}
{{#if firstPartialMatch}}
<p class="input-hint"> <p class="input-hint">
<kbd>Enter</kbd> to view {{filter}} <kbd>Tab</kbd> to autocomplete
</p> </p>
{{/unless}} {{/if}}
{{/if}} {{/if}}
{{#if firstPartialMatch}} </ToolbarFilters>
<p class="input-hint">
<kbd>Tab</kbd> to autocomplete
</p>
{{/if}}
{{/if}}
</ToolbarFilters>
{{/if}}
<ToolbarActions>
<ToolbarSecretLink
@secret=''
@mode="create"
@type="add"
@queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}}
@data-test-secret-create=true
>
{{options.create}}
</ToolbarSecretLink>
</ToolbarActions>
</Toolbar>
{{/if}}
{{#if model.meta.total}}
{{#each model as |item|}}
{{!-- Because of the component helper cannot use glimmer nested SecretList::Item --}}
{{#let (component options.listItemPartial) as |Component|}}
<Component
@item={{item}}
@backendModel={{backendModel}}
@backendType={{backendType}}
@delete={{action "delete" item "secret"}}
@itemPath={{concat options.modelPrefix item.id}}
@itemType={{options.item}}
@modelType={{@modelType}}
@options={{options}}
@toggleZeroAddress={{action "toggleZeroAddress" item backendModel}}
/>
{{/let}}
{{else}}
<div class="box is-sideless">
{{#if filterFocused}}
There are no {{pluralize options.item}} matching <code>{{filter}}</code>, press <kbd>ENTER</kbd> to add one.
{{else}}
There are no {{pluralize options.item}} matching <code>{{filter}}</code>.
{{/if}} {{/if}}
</div>
{{/each}} <ToolbarActions>
{{#if (gt model.meta.lastPage 1) }} <ToolbarSecretLink
<ListPagination @page={{model.meta.currentPage}} @lastPage={{model.meta.lastPage}} @link={{concat "vault.cluster.secrets.backend.list" (unless baseKey.id "-root")}} @model={{compact (array backend (if baseKey.id baseKey.id))}} /> @secret=''
@mode="create"
@type="add"
@queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}}
@data-test-secret-create=true
>
{{options.create}}
</ToolbarSecretLink>
</ToolbarActions>
</Toolbar>
{{/if}} {{/if}}
{{else}}
{{#if (eq baseKey.id '')}} {{#if model.meta.total}}
<EmptyState {{#each model as |item|}}
@title="No {{pluralize options.item}} in this backend" {{!-- Because of the component helper cannot use glimmer nested SecretList::Item --}}
@message="Secrets in this backend will be listed here. Add a secret to get started." {{#let (component options.listItemPartial) as |Component|}}
> <Component
<SecretLink @mode="create" @secret="" @queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}} @class="link"> @item={{item}}
{{options.create}} @backendModel={{backendModel}}
</SecretLink> @backendType={{backendType}}
</EmptyState> @delete={{action "delete" item "secret"}}
@itemPath={{concat options.modelPrefix item.id}}
@itemType={{options.item}}
@modelType={{@modelType}}
@options={{options}}
@toggleZeroAddress={{action "toggleZeroAddress" item backendModel}}
/>
{{/let}}
{{else}} {{else}}
{{#if filterIsFolder}} <div class="box is-sideless">
{{#if filterFocused}}
There are no {{pluralize options.item}} matching <code>{{filter}}</code>, press <kbd>ENTER</kbd> to add one.
{{else}}
There are no {{pluralize options.item}} matching <code>{{filter}}</code>.
{{/if}}
</div>
{{/each}}
{{#if (gt model.meta.lastPage 1) }}
<ListPagination @page={{model.meta.currentPage}} @lastPage={{model.meta.lastPage}} @link={{concat "vault.cluster.secrets.backend.list" (unless baseKey.id "-root")}} @model={{compact (array backend (if baseKey.id baseKey.id))}} />
{{/if}}
{{else}}
{{#if (eq baseKey.id '')}}
<EmptyState <EmptyState
@title={{if (eq filter baseKey.id) @title="No {{pluralize options.item}} in this backend"
(concat @message="Secrets in this backend will be listed here. Add a secret to get started."
"No " (pluralize options.item) " under &quot;" this.filter "&quot;" >
) <SecretLink @mode="create" @secret="" @queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}} @class="link">
(concat {{options.create}}
"No folders matching &quot;" this.filter "&quot;" </SecretLink>
) </EmptyState>
}} {{else}}
/> {{#if filterIsFolder}}
<EmptyState
@title={{if (eq filter baseKey.id)
(concat
"No " (pluralize options.item) " under &quot;" this.filter "&quot;"
)
(concat
"No folders matching &quot;" this.filter "&quot;"
)
}}
/>
{{/if}}
{{/if}} {{/if}}
{{/if}} {{/if}}
{{/if}} {{/with}}
{{/with}} {{/if}}

View File

@ -33,6 +33,7 @@
</div> </div>
<Toolbar> <Toolbar>
{{!-- You must have update on metadata, create is not enough. --}}
{{#if this.model.canUpdateMetadata}} {{#if this.model.canUpdateMetadata}}
<ToolbarActions> <ToolbarActions>
<ToolbarLink @params={{array 'vault.cluster.secrets.backend.edit-metadata' this.model.id }}> <ToolbarLink @params={{array 'vault.cluster.secrets.backend.edit-metadata' this.model.id }}>
@ -50,28 +51,38 @@
<div class="box is-fullwidth is-sideless is-paddingless is-marginless"> <div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each-in this.model.customMetadata as | key value|}} {{#each-in this.model.customMetadata as | key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} /> <InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else if this.noReadAccess}}
<EmptyState
@title="You do not have access to read secret metadata"
@bottomBorder={{true}}
@message="In order to edit secret metadata access, the UI requires read permissions; otherwise, data may be deleted. Edits can still be made via the API and CLI.">
</EmptyState>
{{else}} {{else}}
<EmptyState <EmptyState
@title="No custom metadata" @title="No custom metadata"
@bottomBorder={{true}} @bottomBorder={{true}}
@message="This data is version-agnostic and is usually used to describe the secret being stored."> @message="This data is version-agnostic and is usually used to describe the secret being stored.">
<LinkTo {{#if this.model.canUpdateMetadata}}
@route="vault.cluster.secrets.backend.edit-metadata" <LinkTo
@model={{this.model.id}} @route="vault.cluster.secrets.backend.edit-metadata"
data-test-add-custom-metadata @model={{this.model.id}}
> data-test-add-custom-metadata
Add metadata >
</LinkTo> Add metadata
</LinkTo>
{{/if}}
</EmptyState> </EmptyState>
{{/each-in}} {{/each-in}}
</div> </div>
<div class="form-section"> {{#unless this.noReadAccess}}
<label class="title has-padding-top is-5"> <div class="form-section">
Secret Metadata <label class="title has-padding-top is-5">
</label> Secret Metadata
</div> </label>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless"> </div>
<InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{this.model.maxVersions}} /> <div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{this.model.casRequired}} /> <InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{this.model.maxVersions}} />
<InfoTableRow @alwaysRender={{true}} @label="Delete version after" @value={{if (eq this.model.deleteVersionAfter "0s") "Never delete" this.model.deleteVersionAfter}} /> <InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{this.model.casRequired}} />
</div> <InfoTableRow @alwaysRender={{true}} @label="Delete version after" @value={{if (eq this.model.deleteVersionAfter "0s") "Never delete" this.model.deleteVersionAfter}} />
</div>
{{/unless}}

View File

@ -32,6 +32,7 @@
@actionText="Configure new" @actionText="Configure new"
@actionTo="vault.cluster.secrets.backend.create-root" @actionTo="vault.cluster.secrets.backend.create-root"
@queryParam={{'connection'}} @queryParam={{'connection'}}
@type="role"
/> />
{{/if}} {{/if}}
{{#if (or model.roleCapabilities.canList model.staticRoleCapabilities.canList) }} {{#if (or model.roleCapabilities.canList model.staticRoleCapabilities.canList) }}

View File

@ -5,6 +5,7 @@
onChange=(action "onChange") onChange=(action "onChange")
inputValue=inputValue inputValue=inputValue
helpText=helpText helpText=helpText
placeHolder=placeHolder
}} }}
{{else}} {{else}}
<label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label> <label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label>

View File

@ -395,34 +395,113 @@ module('Acceptance | secrets/secret/create', function(hooks) {
); );
}); });
test('version 2 with restricted policy still allows creation', async function(assert) { test('paths are properly encoded', async function(assert) {
let backend = 'kv-v2'; let backend = 'kv';
const V2_POLICY = ` let paths = [
path "kv-v2/metadata/*" { '(',
capabilities = ["list"] ')',
} '"',
path "kv-v2/data/secret" { //"'",
capabilities = ["create", "read", "update"] '!',
} '#',
`; '$',
await consoleComponent.runCommands([ '&',
`write sys/mounts/${backend} type=kv options=version=2`, '*',
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`, '+',
// delete any kv previously written here so that tests can be re-run '@',
'delete kv-v2/metadata/secret', '{',
'write -field=client_token auth/token/create policies=kv-v2-degrade', '|',
]); '}',
'~',
let userToken = consoleComponent.lastLogOutput; '[',
await logout.visit(); '\\',
await authPage.login(userToken); ']',
'^',
await writeSecret(backend, 'secret', 'foo', 'bar'); '_',
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); ].map(char => `${char}some`);
assert.ok(showPage.editIsPresent, 'shows the edit button'); assert.expect(paths.length * 2);
let secretName = '2';
let commands = paths.map(path => `write '${backend}/${path}/${secretName}' 3=4`);
await consoleComponent.runCommands(['write sys/mounts/kv type=kv', ...commands]);
for (let path of paths) {
await listPage.visit({ backend, id: path });
assert.ok(listPage.secrets.filterBy('text', '2')[0], `${path}: secret is displayed properly`);
await listPage.secrets.filterBy('text', '2')[0].click();
assert.equal(
currentRouteName(),
'vault.cluster.secrets.backend.show',
`${path}: show page renders correctly`
);
}
}); });
test('version 2 with restricted policy still allows edit but does not show metadata tab', async function(assert) { test('create secret with space shows version data', async function(assert) {
let enginePath = `kv-${new Date().getTime()}`;
let secretPath = 'space space';
// mount version 2
await mountSecrets.visit();
await mountSecrets.selectType('kv');
await mountSecrets
.next()
.path(enginePath)
.submit();
await settled();
await listPage.create();
await editPage.createSecret(secretPath, 'foo', 'bar');
await settled();
await click('[data-test-popup-menu-trigger="version"]');
await settled();
await click('[data-test-version-history]');
await settled();
assert.dom('[data-test-list-item-content]').exists('renders the version and not an error state');
// click on version
await click('[data-test-popup-menu-trigger="true"]');
await click('[data-test-version]');
await settled();
// perform encode function that should be done by the encodePath
let encodedSecretPath = secretPath.replace(/ /g, '%20');
assert.equal(currentURL(), `/vault/secrets/${enginePath}/show/${encodedSecretPath}?version=1`);
});
// the web cli does not handle a quote as part of a path, so we test it here via the UI
test('creating a secret with a single or double quote works properly', async function(assert) {
await consoleComponent.runCommands('write sys/mounts/kv type=kv');
let paths = ["'some", '"some'];
for (let path of paths) {
await listPage.visitRoot({ backend: 'kv' });
await listPage.create();
await editPage.createSecret(`${path}/2`, 'foo', 'bar');
await listPage.visit({ backend: 'kv', id: path });
assert.ok(listPage.secrets.filterBy('text', '2')[0], `${path}: secret is displayed properly`);
await listPage.secrets.filterBy('text', '2')[0].click();
assert.equal(
currentRouteName(),
'vault.cluster.secrets.backend.show',
`${path}: show page renders correctly`
);
}
});
test('filter clears on nav', async function(assert) {
await consoleComponent.runCommands([
'vault write sys/mounts/test type=kv',
'refresh',
'vault write test/filter/foo keys=a keys=b',
'vault write test/filter/foo1 keys=a keys=b',
'vault write test/filter/foo2 keys=a keys=b',
]);
await listPage.visit({ backend: 'test', id: 'filter' });
assert.equal(listPage.secrets.length, 3, 'renders three secrets');
await listPage.filterInput('filter/foo1');
assert.equal(listPage.secrets.length, 1, 'renders only one secret');
await listPage.secrets.objectAt(0).click();
await showPage.breadcrumbs.filterBy('text', 'filter')[0].click();
assert.equal(listPage.secrets.length, 3, 'renders three secrets');
assert.equal(listPage.filterInputValue, 'filter/', 'pageFilter has been reset');
});
// All policy test below this line
test('version 2 with restricted policy still allows creation and does not show metadata tab', async function(assert) {
let backend = 'kv-v2'; let backend = 'kv-v2';
const V2_POLICY = ` const V2_POLICY = `
path "kv-v2/metadata/*" { path "kv-v2/metadata/*" {
@ -441,20 +520,72 @@ module('Acceptance | secrets/secret/create', function(hooks) {
]); ]);
let userToken = consoleComponent.lastLogOutput; let userToken = consoleComponent.lastLogOutput;
// check secret edit
await writeSecret(backend, 'secret', 'foo', 'bar');
await logout.visit(); await logout.visit();
await authPage.login(userToken); await authPage.login(userToken);
await editPage.visitEdit({ backend, id: 'secret' }); await writeSecret(backend, 'secret', 'foo', 'bar');
await editPage.editSecret('bar', 'baz');
await settled();
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
assert.ok(showPage.editIsPresent, 'shows the edit button'); assert.ok(showPage.editIsPresent, 'shows the edit button');
//check for metadata tab //check for metadata tab which should not show because you don't have read capabilities
assert.dom('[data-test-secret-metadata-tab]').doesNotExist('does not show metadata tab'); assert.dom('[data-test-secret-metadata-tab]').doesNotExist('does not show metadata tab');
}); });
test('version 2: with metadata no read or list access but access to the data endpoint', async function(assert) {
let backend = 'no-metadata-read';
let V2_POLICY_NO_LIST = `
path "${backend}/metadata/*" {
capabilities = ["create","update"]
}
path "${backend}/data/*" {
capabilities = ["create", "read", "update"]
}
`;
await consoleComponent.runCommands([
// delete any kv previously written here so that tests can be re-run
`delete ${backend}/metadata/secret-path`,
// delete any previous mount with same name
`delete sys/mounts/${backend}`,
`write sys/mounts/${backend} type=kv options=version=2`,
`write sys/policies/acl/metadata-no-list policy=${btoa(V2_POLICY_NO_LIST)}`,
'write -field=client_token auth/token/create policies=metadata-no-list',
]);
await settled();
let userToken2 = consoleComponent.lastLogOutput;
await settled();
await listPage.visitRoot({ backend });
await settled();
await listPage.create();
await settled();
await editPage.createSecretWithMetadata('secret-path', 'secret-key', 'secret-value', 101);
await settled();
await logout.visit();
await settled();
await authPage.login(userToken2);
await settled();
// test if metadata tab there and error and no edit. and you cant see metadata that was setup.
await click(`[data-test-auth-backend-link=${backend}]`);
await settled();
// this fails in IE11 on browserstack so going directly to URL
// let card = document.querySelector('[data-test-search-roles]').childNodes[1];
// await typeIn(card.querySelector('input'), 'secret-path');
// await settled();
await visit('/vault/secrets/no-metadata-read/show/secret-path');
// await click('[data-test-get-credentials]');
await settled();
await assert
.dom('[data-test-value-div="secret-key"]')
.exists('secret view page and info table row with secret-key value');
// check metadata
await click('[data-test-secret-metadata-tab]');
await settled();
assert
.dom('[data-test-empty-state-message]')
.hasText(
'In order to edit secret metadata access, the UI requires read permissions; otherwise, data may be deleted. Edits can still be made via the API and CLI.'
);
});
// KV delete operations testing
test('version 2 with policy with destroy capabilities shows modal', async function(assert) { test('version 2 with policy with destroy capabilities shows modal', async function(assert) {
let backend = 'kv-v2'; let backend = 'kv-v2';
const V2_POLICY = ` const V2_POLICY = `
@ -582,111 +713,7 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await writeSecret(backend, 'secret', 'foo', 'bar'); await writeSecret(backend, 'secret', 'foo', 'bar');
assert.dom('[data-test-secret-v2-delete="true"]').exists('drop down delete shows'); assert.dom('[data-test-secret-v2-delete="true"]').exists('drop down delete shows');
}); });
// end of KV delete operation testing
test('paths are properly encoded', async function(assert) {
let backend = 'kv';
let paths = [
'(',
')',
'"',
//"'",
'!',
'#',
'$',
'&',
'*',
'+',
'@',
'{',
'|',
'}',
'~',
'[',
'\\',
']',
'^',
'_',
].map(char => `${char}some`);
assert.expect(paths.length * 2);
let secretName = '2';
let commands = paths.map(path => `write '${backend}/${path}/${secretName}' 3=4`);
await consoleComponent.runCommands(['write sys/mounts/kv type=kv', ...commands]);
for (let path of paths) {
await listPage.visit({ backend, id: path });
assert.ok(listPage.secrets.filterBy('text', '2')[0], `${path}: secret is displayed properly`);
await listPage.secrets.filterBy('text', '2')[0].click();
assert.equal(
currentRouteName(),
'vault.cluster.secrets.backend.show',
`${path}: show page renders correctly`
);
}
});
test('create secret with space shows version data', async function(assert) {
let enginePath = `kv-${new Date().getTime()}`;
let secretPath = 'space space';
// mount version 2
await mountSecrets.visit();
await mountSecrets.selectType('kv');
await mountSecrets
.next()
.path(enginePath)
.submit();
await settled();
await listPage.create();
await editPage.createSecret(secretPath, 'foo', 'bar');
await settled();
await click('[data-test-popup-menu-trigger="version"]');
await settled();
await click('[data-test-version-history]');
await settled();
assert.dom('[data-test-list-item-content]').exists('renders the version and not an error state');
// click on version
await click('[data-test-popup-menu-trigger="true"]');
await click('[data-test-version]');
await settled();
// perform encode function that should be done by the encodePath
let encodedSecretPath = secretPath.replace(/ /g, '%20');
assert.equal(currentURL(), `/vault/secrets/${enginePath}/show/${encodedSecretPath}?version=1`);
});
// the web cli does not handle a quote as part of a path, so we test it here via the UI
test('creating a secret with a single or double quote works properly', async function(assert) {
await consoleComponent.runCommands('write sys/mounts/kv type=kv');
let paths = ["'some", '"some'];
for (let path of paths) {
await listPage.visitRoot({ backend: 'kv' });
await listPage.create();
await editPage.createSecret(`${path}/2`, 'foo', 'bar');
await listPage.visit({ backend: 'kv', id: path });
assert.ok(listPage.secrets.filterBy('text', '2')[0], `${path}: secret is displayed properly`);
await listPage.secrets.filterBy('text', '2')[0].click();
assert.equal(
currentRouteName(),
'vault.cluster.secrets.backend.show',
`${path}: show page renders correctly`
);
}
});
test('filter clears on nav', async function(assert) {
await consoleComponent.runCommands([
'vault write sys/mounts/test type=kv',
'refresh',
'vault write test/filter/foo keys=a keys=b',
'vault write test/filter/foo1 keys=a keys=b',
'vault write test/filter/foo2 keys=a keys=b',
]);
await listPage.visit({ backend: 'test', id: 'filter' });
assert.equal(listPage.secrets.length, 3, 'renders three secrets');
await listPage.filterInput('filter/foo1');
assert.equal(listPage.secrets.length, 1, 'renders only one secret');
await listPage.secrets.objectAt(0).click();
await showPage.breadcrumbs.filterBy('text', 'filter')[0].click();
assert.equal(listPage.secrets.length, 3, 'renders three secrets');
assert.equal(listPage.filterInputValue, 'filter/', 'pageFilter has been reset');
});
let setupNoRead = async function(backend, canReadMeta = false) { let setupNoRead = async function(backend, canReadMeta = false) {
const V2_WRITE_ONLY_POLICY = ` const V2_WRITE_ONLY_POLICY = `

View File

@ -147,49 +147,4 @@ module('Acceptance | settings/mount-secret-backend', function(hooks) {
await settled(); await settled();
assert.dom('[data-test-row-value="Maximum number of versions"]').hasText('Not set'); assert.dom('[data-test-row-value="Maximum number of versions"]').hasText('Not set');
}); });
test('version 2 with no create to sys/mounts endpoint does not allows mounting of secret engine', async function(assert) {
let backend = `kv-noMount-${new Date().getTime()}`;
const V2_POLICY = `
path "${backend}/*" {
capabilities = ["update","list","create","read","sudo","delete"]
}
path "sys/mounts/*"
{
capabilities = ["read", "delete", "list", "sudo"]
}
# List existing secrets engines.
path "sys/mounts"
{
capabilities = ["read"]
}
`;
await consoleComponent.runCommands([
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`,
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
let userToken = consoleComponent.lastLogOutput;
await logout.visit();
await authPage.login(userToken);
// create the engine
await mountSecrets.visit();
await mountSecrets.selectType('kv');
await mountSecrets
.next()
.path(backend)
.setMaxVersion(101)
.submit();
await settled();
assert.ok(
find('[data-test-flash-message]').textContent.trim(),
`You do not have access to the sys/mounts endpoint. The secret engine was not mounted.`
);
assert.equal(currentRouteName(), 'vault.cluster.settings.mount-secret-backend');
await page.secretList();
await settled();
assert.dom(`[data-test-secret-backend-row=${backend}]`).doesNotExist();
});
}); });

View File

@ -2,7 +2,7 @@ import { module, test } from 'qunit';
import { run } from '@ember/runloop'; import { run } from '@ember/runloop';
import { setupRenderingTest } from 'ember-qunit'; import { setupRenderingTest } from 'ember-qunit';
import Service from '@ember/service'; import Service from '@ember/service';
import { render } from '@ember/test-helpers'; import { render, typeIn } from '@ember/test-helpers';
import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers'; import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
@ -45,9 +45,23 @@ module('Integration | Component | get-credentials-card', function(hooks) {
test('it shows button that can be clicked to credentials route when an item is selected', async function(assert) { test('it shows button that can be clicked to credentials route when an item is selected', async function(assert) {
const models = ['database/role']; const models = ['database/role'];
this.set('models', models); this.set('models', models);
await render(hbs`<GetCredentialsCard @title={{title}} @searchLabel={{searchLabel}} @models={{models}}/>`); await render(
hbs`<GetCredentialsCard @title={{title}} @searchLabel={{searchLabel}} @models={{models}} @type="role"/>`
);
await clickTrigger(); await clickTrigger();
await selectChoose('', 'my-role'); await selectChoose('', 'my-role');
assert.dom('[data-test-get-credentials]').isEnabled(); assert.dom('[data-test-get-credentials]').isEnabled();
}); });
test('it shows input field that can be clicked to a secret when role is secret', async function(assert) {
await render(
hbs`<GetCredentialsCard @title={{title}} @shouldUseFallback={{true}} @placeHolder="secret/" @backend="kv" @type="secret"/>`
);
let card = document.querySelector('[data-test-search-roles]').childNodes[1];
let placeholder = card.querySelector('input').placeholder;
assert.equal(placeholder, 'secret/');
await typeIn(card.querySelector('input'), 'test');
assert.dom('[data-test-get-credentials]').isEnabled();
});
}); });

View File

@ -29,6 +29,14 @@ export default create({
.secretValue(value) .secretValue(value)
.save(); .save();
}, },
createSecretWithMetadata: async function(path, key, value, maxVersion) {
return this.path(path)
.secretKey(key)
.secretValue(value)
.toggleMetadata()
.maxVersion(maxVersion)
.save();
},
editSecret: async function(key, value) { editSecret: async function(key, value) {
return this.secretKey(key) return this.secretKey(key)
.secretValue(value) .secretValue(value)