UI: glimmerize generate credentials component (#27405)

This commit is contained in:
Chelsea Shaw
2024-06-10 12:49:05 -05:00
committed by GitHub
parent b0864e3f54
commit 7e70e3fd52
17 changed files with 444 additions and 396 deletions

3
changelog/27405.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: AWS credentials form sets credential_type from backing role
```

View File

@@ -40,7 +40,7 @@
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
@value={{@accessKey}} @value={{@accessKey}}
data-test-aws-input="accessKey" data-test-input="accessKey"
/> />
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@
name="secret" name="secret"
class="input" class="input"
@value={{@secretKey}} @value={{@secretKey}}
data-test-aws-input="secretKey" data-test-input="secretKey"
/> />
</div> </div>
</div> </div>
@@ -104,7 +104,7 @@
{{/if}} {{/if}}
<div class="box is-bottomless is-fullwidth"> <div class="box is-bottomless is-fullwidth">
<Hds::Button @text="Save" data-test-aws-input="root-save" type="submit" /> <Hds::Button @text="Save" data-test-save type="submit" />
</div> </div>
</form> </form>
</T.Panel> </T.Panel>
@@ -134,7 +134,7 @@
@onChange={{fn this.handleTtlChange "leaseMax"}} @onChange={{fn this.handleTtlChange "leaseMax"}}
/> />
<div class="box is-bottomless is-fullwidth"> <div class="box is-bottomless is-fullwidth">
<Hds::Button @text="Save" data-test-aws-input="lease-save" type="submit" /> <Hds::Button @text="Save" data-test-save type="submit" />
</div> </div>
</form> </form>
</T.Panel> </T.Panel>

View File

@@ -7,13 +7,13 @@
<p.top> <p.top>
<Hds::Breadcrumb> <Hds::Breadcrumb>
<Hds::Breadcrumb::Item <Hds::Breadcrumb::Item
@text={{this.backendPath}} @text={{@backendPath}}
@route="vault.cluster.secrets.backend" @route="vault.cluster.secrets.backend"
@model={{this.backendPath}} @model={{@backendPath}}
data-test-link="role-list" data-test-link="role-list"
/> />
<Hds::Breadcrumb::Item @text="Credentials" @route="vault.cluster.secrets.backend" @model={{this.backendPath}} /> <Hds::Breadcrumb::Item @text="Credentials" @route="vault.cluster.secrets.backend" @model={{@backendPath}} />
<Hds::Breadcrumb::Item @text={{this.roleName}} @route="vault.cluster.secrets.backend.show" @model={{this.roleName}} /> <Hds::Breadcrumb::Item @text={{@roleName}} @route="vault.cluster.secrets.backend.show" @model={{@roleName}} />
<Hds::Breadcrumb::Item @text={{this.options.title}} @current={{true}} /> <Hds::Breadcrumb::Item @text={{this.options.title}} @current={{true}} />
</Hds::Breadcrumb> </Hds::Breadcrumb>
</p.top> </p.top>
@@ -24,7 +24,7 @@
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
{{#if this.model.hasGenerated}} {{#if this.hasGenerated}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless"> <div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<MessageError @model={{this.model}} /> <MessageError @model={{this.model}} />
{{#unless this.model.isError}} {{#unless this.model.isError}}
@@ -35,48 +35,42 @@
</A.Description> </A.Description>
</Hds::Alert> </Hds::Alert>
{{/unless}} {{/unless}}
{{#each this.model.attrs as |attr|}} {{#each this.displayFields as |key|}}
{{#if (eq attr.type "object")}} {{#let (get this.model.allByKey key) as |attr|}}
<InfoTableRow {{#if (eq attr.type "object")}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} <InfoTableRow
@value={{stringify (get this.model attr.name)}} @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
/> @value={{stringify (get this.model attr.name)}}
{{else}} />
{{#if {{else}}
(or {{#if attr.options.masked}}
(eq attr.name "key") {{#if (get this.model attr.name)}}
(eq attr.name "secretKey") <InfoTableRow
(eq attr.name "securityToken") @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
(eq attr.name "privateKey") @value={{get this.model attr.name}}
attr.options.masked >
) <MaskedInput
}} @value={{get this.model attr.name}}
{{#if (get this.model attr.name)}} @name={{attr.name}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{/if}}
{{else if (and (get this.model attr.name) (or (eq attr.name "issueDate") (eq attr.name "expiryDate")))}}
<InfoTableRow
data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{date-format (get this.model attr.name) "MMM dd, yyyy hh:mm:ss a"}}
/>
{{else}}
<InfoTableRow <InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}} @value={{get this.model attr.name}}
> />
<MaskedInput
@value={{get this.model attr.name}}
@name={{attr.name}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{/if}} {{/if}}
{{else if (and (get this.model attr.name) (or (eq attr.name "issueDate") (eq attr.name "expiryDate")))}}
<InfoTableRow
data-test-table-row
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{date-format (get this.model attr.name) "MMM dd, yyyy hh:mm:ss a"}}
/>
{{else}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
/>
{{/if}} {{/if}}
{{/if}} {{/let}}
{{/each}} {{/each}}
</div> </div>
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
@@ -106,34 +100,25 @@
@text="Back" @text="Back"
@color="secondary" @color="secondary"
@route="vault.cluster.secrets.backend.list-root" @route="vault.cluster.secrets.backend.list-root"
@model={{this.backendPath}} @model={{@backendPath}}
data-test-secret-generate-back={{true}} data-test-back-button
/> />
{{else}} {{else}}
<Hds::Button <Hds::Button @text="Back" @color="secondary" {{on "click" this.reset}} data-test-back-button />
@text="Back"
@color="secondary"
{{on "click" (action "newModel")}}
data-test-secret-generate-back="true"
/>
{{/if}} {{/if}}
</div> </div>
</div> </div>
{{else}} {{else}}
<form {{action "create" on="submit"}} data-test-secret-generate-form="true"> <form {{on "submit" this.create}} data-test-secret-generate-form>
<div class="box is-sideless no-padding-top is-fullwidth is-marginless"> <div class="box is-sideless no-padding-top is-fullwidth is-marginless">
<NamespaceReminder @mode="generate" @noun="credential" /> <NamespaceReminder @mode="generate" @noun="credential" />
<MessageError @model={{this.model}} /> <MessageError @model={{this.model}} />
{{#if this.model.helpText}} {{#if this.helpText}}
<p class="is-hint">{{this.model.helpText}}</p> <p class="is-hint">{{this.helpText}}</p>
{{/if}}
{{#if this.model.fieldGroups}}
<FormFieldGroupsLoop @model={{this.model}} @mode={{this.mode}} />
{{else}}
{{#each this.model.attrs as |attr|}}
<FormField data-test-field={{true}} @attr={{attr}} @model={{this.model}} />
{{/each}}
{{/if}} {{/if}}
{{#each this.formFields as |key|}}
<FormField data-test-field @attr={{get this.model.allByKey key}} @model={{this.model}} />
{{/each}}
</div> </div>
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<Hds::ButtonSet> <Hds::ButtonSet>
@@ -142,14 +127,14 @@
@icon={{if this.loading "loading"}} @icon={{if this.loading "loading"}}
type="submit" type="submit"
disabled={{this.loading}} disabled={{this.loading}}
data-test-secret-generate={{true}} data-test-save
/> />
<Hds::Button <Hds::Button
@text="Cancel" @text="Cancel"
@route="vault.cluster.secrets.backend.list-root" @route="vault.cluster.secrets.backend.list-root"
@color="secondary" @color="secondary"
@model={{this.backendPath}} @model={{@backendPath}}
data-test-secret-generate-cancel={{true}} data-test-cancel
/> />
</Hds::ButtonSet> </Hds::ButtonSet>
</div> </div>

View File

@@ -4,56 +4,49 @@
*/ */
import { service } from '@ember/service'; import { service } from '@ember/service';
import { computed, set } from '@ember/object'; import { action } from '@ember/object';
import Component from '@ember/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
const MODEL_TYPES = { const CREDENTIAL_TYPES = {
'ssh-sign': { ssh: {
model: 'ssh-sign',
},
'ssh-creds': {
model: 'ssh-otp-credential', model: 'ssh-otp-credential',
title: 'Generate SSH Credentials', title: 'Generate SSH Credentials',
formFields: ['username', 'ip'],
displayFields: ['username', 'ip', 'key', 'keyType', 'port'],
}, },
'aws-creds': { aws: {
model: 'aws-credential', model: 'aws-credential',
title: 'Generate AWS Credentials', title: 'Generate AWS Credentials',
backIsListLink: true, backIsListLink: true,
displayFields: ['accessKey', 'secretKey', 'securityToken', 'leaseId', 'renewable', 'leaseDuration'],
// aws form fields are dynamic
formFields: (model) => {
return {
iam_user: ['credentialType'],
assumed_role: ['credentialType', 'ttl', 'roleArn'],
federation_token: ['credentialType', 'ttl'],
session_token: ['credentialType', 'ttl'],
}[model.credentialType];
},
}, },
}; };
export default Component.extend({ export default class GenerateCredentials extends Component {
controlGroup: service(), @service controlGroup;
store: service(), @service store;
router: service(), @service router;
// set on the component
backendType: null,
backendPath: null,
roleName: null,
action: null,
model: null, @tracked model;
loading: false, @tracked loading = false;
emptyData: '{\n}', @tracked hasGenerated = false;
emptyData = '{\n}';
modelForType() { constructor() {
const type = this.options; super(...arguments);
if (type) { const modelType = this.modelForType();
return type.model; this.model = this.generateNewModel(modelType);
} }
// if we don't have a mode for that type then redirect them back to the backend list
this.router.transitionTo('vault.cluster.secrets.backend.list-root', this.backendPath);
},
options: computed('action', 'backendType', function () {
const action = this.action || 'creds';
return MODEL_TYPES[`${this.backendType}-${action}`];
}),
init() {
this._super(...arguments);
this.createOrReplaceModel();
},
willDestroy() { willDestroy() {
// components are torn down after store is unloaded and will cause an error if attempt to unload record // components are torn down after store is unloaded and will cause an error if attempt to unload record
@@ -61,20 +54,46 @@ export default Component.extend({
if (noTeardown && !this.model.isDestroyed && !this.model.isDestroying) { if (noTeardown && !this.model.isDestroyed && !this.model.isDestroying) {
this.model.unloadRecord(); this.model.unloadRecord();
} }
this._super(...arguments); super.willDestroy();
}, }
createOrReplaceModel() { modelForType() {
const modelType = this.modelForType(); const type = this.options;
const model = this.model; if (type) {
const roleName = this.roleName; return type.model;
const backendPath = this.backendPath; }
// if we don't have a mode for that type then redirect them back to the backend list
this.router.transitionTo('vault.cluster.secrets.backend.list-root', this.args.backendPath);
}
get helpText() {
if (this.options?.model === 'aws-credential') {
return 'For Vault roles of credential type iam_user, there are no inputs, just submit the form. Choose a type to change the input options.';
}
return '';
}
get options() {
return CREDENTIAL_TYPES[this.args.backendType];
}
get formFields() {
const typeOpts = this.options;
if (typeof typeOpts.formFields === 'function') {
return typeOpts.formFields(this.model);
}
return typeOpts.formFields;
}
get displayFields() {
return this.options.displayFields;
}
generateNewModel(modelType) {
if (!modelType) { if (!modelType) {
return; return;
} }
if (model) { const { roleName, backendPath, awsRoleType } = this.args;
model.unloadRecord();
}
const attrs = { const attrs = {
role: { role: {
backend: backendPath, backend: backendPath,
@@ -82,44 +101,60 @@ export default Component.extend({
}, },
id: `${backendPath}-${roleName}`, id: `${backendPath}-${roleName}`,
}; };
const newModel = this.store.createRecord(modelType, attrs); if (awsRoleType) {
this.set('model', newModel); // this is only set from route if backendType = aws
}, attrs.credentialType = awsRoleType;
}
return this.store.createRecord(modelType, attrs);
}
actions: { replaceModel() {
create() { const modelType = this.modelForType();
const model = this.model; if (!modelType) {
this.set('loading', true); return;
this.model }
.save() if (this.model) {
.then(() => { this.model.unloadRecord();
model.set('hasGenerated', true); }
}) this.model = this.generateNewModel(modelType);
.catch((error) => { }
// Handle control group AdapterError
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
const err = this.controlGroup.logFromError(error);
error.errors = [err.content];
}
throw error;
})
.finally(() => {
this.set('loading', false);
});
},
codemirrorUpdated(attr, val, codemirror) { @action
codemirror.performLint(); create(evt) {
const hasErrors = codemirror.state.lint.marked.length > 0; evt.preventDefault();
this.loading = true;
this.model
.save()
.then(() => {
this.hasGenerated = true;
})
.catch((error) => {
// Handle control group AdapterError
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
const err = this.controlGroup.logFromError(error);
error.errors = [err.content];
}
throw error;
})
.finally(() => {
this.loading = false;
});
}
if (!hasErrors) { @action
set(this.model, attr, JSON.parse(val)); codemirrorUpdated(attr, val, codemirror) {
} codemirror.performLint();
}, const hasErrors = codemirror.state.lint.marked.length > 0;
newModel() { if (!hasErrors) {
this.createOrReplaceModel(); this.model[attr] = JSON.parse(val);
}, }
}, }
});
@action
reset() {
this.hasGenerated = false;
this.replaceModel();
}
}

View File

@@ -6,11 +6,10 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
export default Controller.extend({ export default Controller.extend({
queryParams: ['action', 'roleType'], queryParams: ['roleType'],
action: '', // used for database credentials
roleType: '', roleType: '',
reset() { reset() {
this.set('action', '');
this.set('roleType', ''); this.set('roleType', '');
}, },
}); });

View File

@@ -4,8 +4,8 @@
*/ */
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
import { computed } from '@ember/object'; import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
const CREDENTIAL_TYPES = [ const CREDENTIAL_TYPES = [
{ {
value: 'iam_user', value: 'iam_user',
@@ -25,27 +25,28 @@ const CREDENTIAL_TYPES = [
}, },
]; ];
const DISPLAY_FIELDS = ['accessKey', 'secretKey', 'securityToken', 'leaseId', 'renewable', 'leaseDuration']; @withExpandedAttributes()
export default Model.extend({ export default class AwsCredential extends Model {
helpText: @attr('object', {
'For Vault roles of credential type iam_user, there are no inputs, just submit the form. Choose a type to change the input options.',
role: attr('object', {
readOnly: true, readOnly: true,
}), })
role;
credentialType: attr('string', { @attr('string', {
defaultValue: 'iam_user', defaultValue: 'iam_user',
possibleValues: CREDENTIAL_TYPES, possibleValues: CREDENTIAL_TYPES,
readOnly: true, readOnly: true,
}), })
credentialType;
roleArn: attr('string', { @attr('string', {
label: 'Role ARN', label: 'Role ARN',
helpText: helpText:
'The ARN of the role to assume if credential_type on the Vault role is assumed_role. Optional if the role has a single role ARN; required otherwise.', 'The ARN of the role to assume if credential_type on the Vault role is assumed_role. Optional if the role has a single role ARN; required otherwise.',
}), })
roleArn;
ttl: attr({ @attr({
editType: 'ttl', editType: 'ttl',
defaultValue: '3600s', defaultValue: '3600s',
setDefault: true, setDefault: true,
@@ -53,29 +54,17 @@ export default Model.extend({
label: 'TTL', label: 'TTL',
helpText: helpText:
'Specifies the TTL for the use of the STS token. Valid only when credential_type is assumed_role, federation_token, or session_token.', 'Specifies the TTL for the use of the STS token. Valid only when credential_type is assumed_role, federation_token, or session_token.',
}), })
leaseId: attr('string'), ttl;
renewable: attr('boolean'),
leaseDuration: attr('number'),
accessKey: attr('string'),
secretKey: attr('string'),
securityToken: attr('string'),
attrs: computed('credentialType', 'accessKey', 'securityToken', function () { @attr('string') leaseId;
const type = this.credentialType; @attr('boolean') renewable;
const fieldsForType = { @attr('number') leaseDuration;
iam_user: ['credentialType'], @attr('string') accessKey;
assumed_role: ['credentialType', 'ttl', 'roleArn'], @attr('string', { masked: true }) secretKey;
federation_token: ['credentialType', 'ttl'], @attr('string', { masked: true }) securityToken;
session_token: ['credentialType', 'ttl'],
};
if (this.accessKey || this.securityToken) {
return expandAttributeMeta(this, DISPLAY_FIELDS.slice(0));
}
return expandAttributeMeta(this, fieldsForType[type].slice(0));
}),
toCreds: computed('accessKey', 'secretKey', 'securityToken', 'leaseId', function () { get toCreds() {
const props = { const props = {
accessKey: this.accessKey, accessKey: this.accessKey,
secretKey: this.secretKey, secretKey: this.secretKey,
@@ -90,5 +79,5 @@ export default Model.extend({
return ret; return ret;
}, {}); }, {});
return JSON.stringify(propsWithVals, null, 2); return JSON.stringify(propsWithVals, null, 2);
}), }
}); }

View File

@@ -3,27 +3,25 @@
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
import { reads } from '@ember/object/computed';
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
import { computed } from '@ember/object'; import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
const CREATE_FIELDS = ['username', 'ip'];
const DISPLAY_FIELDS = ['username', 'ip', 'key', 'keyType', 'port']; @withExpandedAttributes()
export default Model.extend({ export default class SshOtpCredential extends Model {
role: attr('object', { @attr('object', {
readOnly: true, readOnly: true,
}), })
ip: attr('string', { role;
@attr('string', {
label: 'IP Address', label: 'IP Address',
}), })
username: attr('string'), ip;
key: attr('string'), @attr('string') username;
keyType: attr('string'), @attr('string', { masked: true }) key;
port: attr('number'), @attr('string') keyType;
attrs: computed('key', function () { @attr('number') port;
const keys = this.key ? DISPLAY_FIELDS.slice(0) : CREATE_FIELDS.slice(0);
return expandAttributeMeta(this, keys); get toCreds() {
}), return this.key;
toCreds: reads('key'), }
}); }

View File

@@ -17,12 +17,15 @@ export default Route.extend({
store: service(), store: service(),
beforeModel() { beforeModel() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend'); const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend');
if (backend != 'ssh') { // redirect if the backend type does not support credentials
return; if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendType)) {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backendPath);
}
// hydrate model if backend type is ssh
if (backendType === 'ssh') {
this.pathHelp.getNewModel('ssh-otp-credential', backendPath);
} }
const modelType = 'ssh-otp-credential';
return this.pathHelp.getNewModel(modelType, backend);
}, },
getDatabaseCredential(backend, secret, roleType = '') { getDatabaseCredential(backend, secret, roleType = '') {
@@ -51,23 +54,34 @@ export default Route.extend({
}); });
}, },
async getAwsRole(backend, id) {
try {
const role = await this.store.queryRecord('role-aws', { backend, id });
return role;
} catch (e) {
// swallow error, non-essential data
return;
}
},
async model(params) { async model(params) {
const role = params.secret; const role = params.secret;
const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend'); const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend');
const roleType = params.roleType; const roleType = params.roleType;
let dbCred; let dbCred, awsRole;
if (backendType === 'database') { if (backendType === 'database') {
dbCred = await this.getDatabaseCredential(backendPath, role, roleType); dbCred = await this.getDatabaseCredential(backendPath, role, roleType);
} else if (backendType === 'aws') {
awsRole = await this.getAwsRole(backendPath, role);
} }
if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendType)) {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backendPath);
}
return resolve({ return resolve({
backendPath, backendPath,
backendType, backendType,
roleName: role, roleName: role,
roleType, roleType,
dbCred, dbCred,
awsRoleType: awsRole?.credentialType,
}); });
}, },

View File

@@ -76,7 +76,7 @@
</div> </div>
<div class="field is-grouped-split box is-fullwidth is-bottomless"> <div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet> <Hds::ButtonSet>
<Hds::Button @text={{if (eq this.mode "create") "Create role" "Save"}} type="submit" data-test-role-aws-create /> <Hds::Button @text={{if (eq this.mode "create") "Create role" "Save"}} type="submit" data-test-save />
{{#if (eq this.mode "create")}} {{#if (eq this.mode "create")}}
<Hds::Button <Hds::Button
@text="Cancel" @text="Cancel"

View File

@@ -11,11 +11,10 @@
@model={{this.model.dbCred}} @model={{this.model.dbCred}}
/> />
{{else}} {{else}}
{{! TODO smells a little to have action off of query param requiring a conditional }}
<GenerateCredentials <GenerateCredentials
@backendPath={{this.model.backendPath}} @backendPath={{this.model.backendPath}}
@backendType={{this.model.backendType}} @backendType={{this.model.backendType}}
@roleName={{this.model.roleName}} @roleName={{this.model.roleName}}
@action={{if this.action this.action ""}} @awsRoleType={{this.model.awsRoleType}}
/> />
{{/if}} {{/if}}

View File

@@ -67,12 +67,7 @@
</div> </div>
{{/if}} {{/if}}
<div class="control"> <div class="control">
<Hds::Button <Hds::Button @text="Back" @color="secondary" {{on "click" (action "newModel")}} data-test-back-button />
@text="Back"
@color="secondary"
{{on "click" (action "newModel")}}
data-test-secret-generate-back={{true}}
/>
</div> </div>
</div> </div>
{{else}} {{else}}
@@ -113,14 +108,14 @@
@icon={{if this.loading "loading"}} @icon={{if this.loading "loading"}}
type="submit" type="submit"
disabled={{this.loading}} disabled={{this.loading}}
data-test-secret-generate data-test-save
/> />
<Hds::Button <Hds::Button
@text="Cancel" @text="Cancel"
@color="secondary" @color="secondary"
@route="vault.cluster.secrets.backend.list-root" @route="vault.cluster.secrets.backend.list-root"
@model={{this.backend.id}} @model={{this.backend.id}}
data-test-secret-generate-cancel data-test-cancel
/> />
</Hds::ButtonSet> </Hds::ButtonSet>
</div> </div>

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
import { click, fillIn, currentURL, find, settled, waitUntil } from '@ember/test-helpers'; import { click, fillIn, currentURL, find, settled, waitUntil, visit } from '@ember/test-helpers';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -13,7 +13,63 @@ import { GENERAL } from '../helpers/general-selectors';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend'; import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { deleteEngineCmd, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands';
import { overrideResponse } from 'vault/tests/helpers/stubs';
const AWS_CREDS = {
configTab: '[data-test-configuration-tab]',
configure: '[data-test-secret-backend-configure]',
awsForm: '[data-test-aws-root-creds-form]',
viewBackend: '[data-test-backend-view-link]',
createSecret: '[data-test-secret-create]',
secretHeader: '[data-test-secret-header]',
secretLink: (name) => (name ? `[data-test-secret-link="${name}"]` : '[data-test-secret-link]'),
crumb: (path) => `[data-test-secret-breadcrumb="${path}"] a`,
ttlToggle: '[data-test-ttl-toggle="TTL"]',
warning: '[data-test-warning]',
delete: (role) => `[data-test-aws-role-delete="${role}"]`,
backButton: '[data-test-back-button]',
generateLink: '[data-test-backend-credentials]',
};
const ROLE_TYPES = [
{
credentialType: 'iam_user',
async fillOutForm(assert) {
// nothing to fill out
assert.dom('[data-test-field]').exists({ count: 1 });
},
expectedPayload: {},
},
{
credentialType: 'assumed_role',
async fillOutForm(assert) {
await click(GENERAL.toggleInput('TTL'));
assert.dom(GENERAL.toggleInput('TTL')).isNotChecked();
await fillIn(GENERAL.inputByAttr('roleArn'), 'foobar');
},
expectedPayload: {
role_arn: 'foobar',
},
},
{
credentialType: 'federation_token',
async fillOutForm(assert) {
assert.dom(GENERAL.toggleInput('TTL')).isChecked();
await fillIn(GENERAL.ttl.input('TTL'), '3');
},
expectedPayload: {
ttl: '10800s',
},
},
{
credentialType: 'session_token',
async fillOutForm(assert) {
await click(GENERAL.toggleInput('TTL'));
assert.dom(GENERAL.toggleInput('TTL')).isNotChecked();
},
expectedPayload: null,
},
];
module('Acceptance | aws secret backend', function (hooks) { module('Acceptance | aws secret backend', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
setupMirage(hooks); setupMirage(hooks);
@@ -30,28 +86,23 @@ module('Acceptance | aws secret backend', function (hooks) {
test('aws backend', async function (assert) { test('aws backend', async function (assert) {
const path = `aws-${this.uid}`; const path = `aws-${this.uid}`;
const roleName = 'awsrole'; const roleName = 'awsrole';
this.server.post(`/${path}/creds/${roleName}`, (_, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { role_arn: 'foobar' }, 'does not send TTL when unchecked');
return {};
});
await enablePage.enable('aws', path); await enablePage.enable('aws', path);
await settled(); await settled();
await click('[data-test-configuration-tab]'); await click(AWS_CREDS.configTab);
await click('[data-test-secret-backend-configure]'); await click(AWS_CREDS.configure);
assert.strictEqual(currentURL(), `/vault/settings/secrets/configure/${path}`); assert.strictEqual(currentURL(), `/vault/settings/secrets/configure/${path}`);
assert.dom('[data-test-aws-root-creds-form]').exists(); assert.dom(AWS_CREDS.awsForm).exists();
assert.dom(GENERAL.tab('access-to-aws')).exists('renders the root creds tab'); assert.dom(GENERAL.tab('access-to-aws')).exists('renders the root creds tab');
assert.dom(GENERAL.tab('lease')).exists('renders the leases config tab'); assert.dom(GENERAL.tab('lease')).exists('renders the leases config tab');
await fillIn('[data-test-aws-input="accessKey"]', 'foo'); await fillIn(GENERAL.inputByAttr('accessKey'), 'foo');
await fillIn('[data-test-aws-input="secretKey"]', 'bar'); await fillIn(GENERAL.inputByAttr('secretKey'), 'bar');
await click('[data-test-aws-input="root-save"]'); await click(GENERAL.saveButton);
assert.true( assert.true(
this.flashSuccessSpy.calledWith('The backend configuration saved successfully!'), this.flashSuccessSpy.calledWith('The backend configuration saved successfully!'),
@@ -60,53 +111,133 @@ module('Acceptance | aws secret backend', function (hooks) {
await click(GENERAL.tab('lease')); await click(GENERAL.tab('lease'));
await click('[data-test-aws-input="lease-save"]'); await click(GENERAL.saveButton);
assert.true( assert.true(
this.flashSuccessSpy.calledTwice, this.flashSuccessSpy.calledTwice,
'a new success flash message is rendered upon saving lease' 'a new success flash message is rendered upon saving lease'
); );
await click('[data-test-backend-view-link]'); await click(AWS_CREDS.viewBackend);
assert.strictEqual(currentURL(), `/vault/secrets/${path}/list`, 'navigates to the roles list'); assert.strictEqual(currentURL(), `/vault/secrets/${path}/list`, 'navigates to the roles list');
await click('[data-test-secret-create]'); await click(AWS_CREDS.createSecret);
assert.dom('[data-test-secret-header]').hasText('Create an AWS Role', 'aws: renders the create page'); assert.dom(AWS_CREDS.secretHeader).hasText('Create an AWS Role', 'aws: renders the create page');
await fillIn('[data-test-input="name"]', roleName); await fillIn(GENERAL.inputByAttr('name'), roleName);
// save the role // save the role
await click('[data-test-role-aws-create]'); await click(GENERAL.saveButton);
await waitUntil(() => currentURL() === `/vault/secrets/${path}/show/${roleName}`); // flaky without this await waitUntil(() => currentURL() === `/vault/secrets/${path}/show/${roleName}`); // flaky without this
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${path}/show/${roleName}`, `/vault/secrets/${path}/show/${roleName}`,
'aws: navigates to the show page on creation' 'aws: navigates to the show page on creation'
); );
await click(`[data-test-secret-breadcrumb="${path}"] a`); await click(AWS_CREDS.crumb(path));
assert.strictEqual(currentURL(), `/vault/secrets/${path}/list`); assert.strictEqual(currentURL(), `/vault/secrets/${path}/list`);
assert.dom(`[data-test-secret-link="${roleName}"]`).exists(); assert.dom(AWS_CREDS.secretLink(roleName)).exists();
// check that generates credentials flow is correct
await click(`[data-test-secret-link="${roleName}"]`);
assert.dom('h1').hasText('Generate AWS Credentials');
assert.dom('[data-test-input="credentialType"]').hasValue('iam_user');
await fillIn('[data-test-input="credentialType"]', 'assumed_role');
await click('[data-test-ttl-toggle="TTL"]');
assert.dom('[data-test-ttl-toggle="TTL"]').isNotChecked();
await fillIn('[data-test-input="roleArn"]', 'foobar');
await click('[data-test-secret-generate]');
assert.dom('[data-test-warning]').exists('Shows access warning after generation');
await click('[data-test-secret-generate-back]');
//and delete //and delete
await click(`[data-test-secret-link="${roleName}"] [data-test-popup-menu-trigger]`); await click(`${AWS_CREDS.secretLink(roleName)} [data-test-popup-menu-trigger]`);
await waitUntil(() => find(`[data-test-aws-role-delete="${roleName}"]`)); // flaky without await waitUntil(() => find(AWS_CREDS.delete(roleName))); // flaky without
await click(`[data-test-aws-role-delete="${roleName}"]`); await click(AWS_CREDS.delete(roleName));
await click(GENERAL.confirmButton); await click(GENERAL.confirmButton);
assert.dom(`[data-test-secret-link="${roleName}"]`).doesNotExist('aws: role is no longer in the list'); assert.dom(AWS_CREDS.secretLink(roleName)).doesNotExist('aws: role is no longer in the list');
});
ROLE_TYPES.forEach((scenario) => {
test(`aws credentials - type ${scenario.credentialType}`, async function (assert) {
const path = `aws-cred-${this.uid}`;
const roleName = `awsrole-${scenario.credentialType}`;
this.server.post(`/${path}/creds/${roleName}`, (_, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, scenario.expectedPayload);
return {
data: {
access_key: 'AKIA...',
secret_key: 'xlCs...',
security_token: 'some-token',
arn: 'arn:aws:sts::123456789012:assumed-role/DeveloperRole/some-user-supplied-role-session-name',
},
};
});
this.server.get(`/${path}/creds/${roleName}`, () => {
return {
data: {
access_key: 'AKIA...',
secret_key: 'xlCs...',
security_token: 'some-token',
arn: 'arn:aws:sts::123456789012:assumed-role/DeveloperRole/some-user-supplied-role-session-name',
},
};
});
await runCmd(mountEngineCmd('aws', path));
await visit(`/vault/secrets/${path}/create`);
assert.dom('h1').hasText('Create an AWS Role');
await fillIn(GENERAL.inputByAttr('name'), roleName);
await fillIn(GENERAL.inputByAttr('credentialType'), scenario.credentialType);
await click(GENERAL.saveButton);
await waitUntil(() => currentURL() === `/vault/secrets/${path}/show/${roleName}`); // flaky without this
assert.strictEqual(currentURL(), `/vault/secrets/${path}/show/${roleName}`);
await click(AWS_CREDS.generateLink);
assert
.dom(GENERAL.inputByAttr('credentialType'))
.hasValue(scenario.credentialType, 'credentialType matches backing role');
// based on credentialType, fill out form
await scenario.fillOutForm(assert);
await click(GENERAL.saveButton);
assert.dom(AWS_CREDS.warning).exists('Shows access warning after generation');
assert.dom(GENERAL.infoRowValue('Access key')).exists();
assert.dom(GENERAL.infoRowValue('Secret key')).exists();
assert.dom(GENERAL.infoRowValue('Security token')).exists();
await visit('/vault/dashboard');
await runCmd(deleteEngineCmd(path));
});
});
test(`aws credentials without role read access`, async function (assert) {
const path = `aws-cred-${this.uid}`;
const roleName = `awsrole-noread`;
this.server.post(`/${path}/creds/${roleName}`, () => {
return {
data: {
access_key: 'AKIA...',
secret_key: 'xlCs...',
security_token: 'some-token',
arn: 'arn:aws:sts::123456789012:assumed-role/DeveloperRole/some-user-supplied-role-session-name',
},
};
});
this.server.get(`/${path}/roles/${roleName}`, () => overrideResponse(403));
await runCmd(mountEngineCmd('aws', path));
await runCmd(`write ${path}/roles/${roleName} credential_type=assumed_role`);
await visit(`/vault/secrets/${path}/list`);
assert.dom(AWS_CREDS.secretLink(roleName)).exists();
await click(AWS_CREDS.secretLink(roleName));
assert.strictEqual(currentURL(), `/vault/secrets/${path}/credentials/${roleName}`);
assert
.dom(GENERAL.inputByAttr('credentialType'))
.hasValue('iam_user', 'credentialType defaults to first in list due to no role read permissions');
await fillIn(GENERAL.inputByAttr('credentialType'), 'assumed_role');
await click(GENERAL.saveButton);
assert.dom(AWS_CREDS.warning).exists('Shows access warning after generation');
assert.dom(GENERAL.infoRowValue('Access key')).exists();
assert.dom(GENERAL.infoRowValue('Secret key')).exists();
assert.dom(GENERAL.infoRowValue('Security token')).exists();
await visit('/vault/dashboard');
await runCmd(deleteEngineCmd(path));
}); });
}); });

View File

@@ -3,14 +3,23 @@
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
import { click, fillIn, findAll, currentURL, find, settled, waitUntil } from '@ember/test-helpers'; import {
click,
fillIn,
currentURL,
find,
settled,
waitUntil,
currentRouteName,
waitFor,
} from '@ember/test-helpers';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend'; import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import { setRunOptions } from 'ember-a11y-testing/test-support'; import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Acceptance | ssh secret backend', function (hooks) { module('Acceptance | ssh secret backend', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
@@ -26,6 +35,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
{ {
type: 'ca', type: 'ca',
name: 'carole', name: 'carole',
credsRoute: 'vault.cluster.secrets.backend.sign',
async fillInCreate() { async fillInCreate() {
await click('[data-test-input="allowUserCertificates"]'); await click('[data-test-input="allowUserCertificates"]');
}, },
@@ -61,6 +71,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
{ {
type: 'otp', type: 'otp',
name: 'otprole', name: 'otprole',
credsRoute: 'vault.cluster.secrets.backend.credentials',
async fillInCreate() { async fillInCreate() {
await fillIn('[data-test-input="defaultUser"]', 'admin'); await fillIn('[data-test-input="defaultUser"]', 'admin');
await click('[data-test-toggle-group="Options"]'); await click('[data-test-toggle-group="Options"]');
@@ -84,13 +95,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
}, },
]; ];
test('ssh backend', async function (assert) { test('ssh backend', async function (assert) {
// Popup menu causes flakiness assert.expect(30);
setRunOptions({
rules: {
'color-contrast': { enabled: false },
},
});
assert.expect(28);
const sshPath = `ssh-${this.uid}`; const sshPath = `ssh-${this.uid}`;
await enablePage.enable('ssh', sshPath); await enablePage.enable('ssh', sshPath);
@@ -100,27 +105,24 @@ module('Acceptance | ssh secret backend', function (hooks) {
await click('[data-test-secret-backend-configure]'); await click('[data-test-secret-backend-configure]');
assert.strictEqual(currentURL(), `/vault/settings/secrets/configure/${sshPath}`); assert.strictEqual(currentURL(), `/vault/settings/secrets/configure/${sshPath}`);
assert.ok(findAll('[data-test-ssh-configure-form]').length, 'renders the empty configuration form'); assert.dom('[data-test-ssh-configure-form]').exists('renders the empty configuration form');
// default has generate CA checked so we just submit the form // default has generate CA checked so we just submit the form
await click('[data-test-ssh-input="configure-submit"]'); await click('[data-test-ssh-input="configure-submit"]');
assert.ok( await waitFor('[data-test-ssh-input="public-key"]');
await waitUntil(() => findAll('[data-test-ssh-input="public-key"]').length), assert.dom('[data-test-ssh-input="public-key"]').exists();
'a public key is fetched'
);
await click('[data-test-backend-view-link]'); await click('[data-test-backend-view-link]');
assert.strictEqual(currentURL(), `/vault/secrets/${sshPath}/list`, `redirects to ssh index`); assert.strictEqual(currentURL(), `/vault/secrets/${sshPath}/list`, `redirects to ssh index`);
for (const role of ROLES) { for (const role of ROLES) {
// create a role // create a role
await click('[ data-test-secret-create]'); await click('[data-test-secret-create]');
assert.ok( assert
find('[data-test-secret-header]').textContent.includes('SSH Role'), .dom('[data-test-secret-header]')
`${role.type}: renders the create page` .includesText('SSH Role', `${role.type}: renders the create page`);
);
await fillIn('[data-test-input="name"]', role.name); await fillIn('[data-test-input="name"]', role.name);
await fillIn('[data-test-input="keyType"]', role.type); await fillIn('[data-test-input="keyType"]', role.type);
@@ -138,7 +140,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
// sign a key with this role // sign a key with this role
await click('[data-test-backend-credentials]'); await click('[data-test-backend-credentials]');
assert.strictEqual(currentRouteName(), role.credsRoute);
await role.fillInGenerate(); await role.fillInGenerate();
if (role.type === 'ca') { if (role.type === 'ca') {
await settled(); await settled();
@@ -146,29 +148,23 @@ module('Acceptance | ssh secret backend', function (hooks) {
} }
// generate creds // generate creds
await click('[data-test-secret-generate]'); await click(GENERAL.saveButton);
await settled(); // eslint-disable-line await settled(); // eslint-disable-line
role.assertAfterGenerate(assert, sshPath); role.assertAfterGenerate(assert, sshPath);
// click the "Back" button // click the "Back" button
await click('[data-test-secret-generate-back]'); await click('[data-test-back-button]');
assert.ok( assert.dom('[data-test-secret-generate-form]').exists(`${role.type}: back takes you back to the form`);
findAll('[data-test-secret-generate-form]').length,
`${role.type}: back takes you back to the form`
);
await click('[data-test-secret-generate-cancel]'); await click(GENERAL.cancelButton);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${sshPath}/list`, `/vault/secrets/${sshPath}/list`,
`${role.type}: cancel takes you to ssh index` `${role.type}: cancel takes you to ssh index`
); );
assert.ok( assert.dom(`[data-test-secret-link="${role.name}"]`).exists(`${role.type}: role shows in the list`);
findAll(`[data-test-secret-link="${role.name}"]`).length,
`${role.type}: role shows in the list`
);
//and delete //and delete
await click(`[data-test-secret-link="${role.name}"] [data-test-popup-menu-trigger]`); await click(`[data-test-secret-link="${role.name}"] [data-test-popup-menu-trigger]`);

View File

@@ -1,35 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Base } from '../create';
import { settled } from '@ember/test-helpers';
import { clickable, visitable, create, fillable } from 'ember-cli-page-object';
export default create({
...Base,
visitEdit: visitable('/vault/secrets/:backend/edit/:id'),
visitEditRoot: visitable('/vault/secrets/:backend/edit'),
toggleDomain: clickable('[data-test-toggle-group="Domain Handling"]'),
toggleOptions: clickable('[data-test-toggle-group="Options"]'),
name: fillable('[data-test-input="name"]'),
allowAnyName: clickable('[data-test-input="allowAnyName"]'),
allowedDomains: fillable('[data-test-input="allowedDomains"] .input'),
save: clickable('[data-test-role-create]'),
async createRole(name, allowedDomains) {
await this.toggleDomain();
await settled();
await this.toggleOptions();
await settled();
await this.name(name);
await settled();
await this.allowAnyName();
await settled();
await this.allowedDomains(allowedDomains);
await settled();
await this.save();
await settled();
},
});

View File

@@ -1,36 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Base } from '../credentials';
import { clickable, text, value, create, fillable, isPresent } from 'ember-cli-page-object';
export default create({
...Base,
title: text('[data-test-title]'),
commonName: fillable('[data-test-input="commonName"]'),
commonNameValue: value('[data-test-input="commonName"]'),
csr: fillable('[data-test-input="csr"]'),
submit: clickable('[data-test-secret-generate]'),
back: clickable('[data-test-secret-generate-back]'),
certificate: text('[data-test-row-value="Certificate"]'),
toggleOptions: clickable('[data-test-toggle-group]'),
enableTtl: clickable('[data-test-toggle-input]'),
hasCert: isPresent('[data-test-row-value="Certificate"]'),
fillInTime: fillable('[data-test-ttl-value]'),
fillInField: fillable('[data-test-select="ttl-unit"]'),
issueCert: async function (commonName) {
await this.commonName(commonName).toggleOptions().enableTtl().fillInField('h').fillInTime('30').submit();
},
sign: async function (commonName, csr) {
return this.csr(csr)
.commonName(commonName)
.toggleOptions()
.enableTtl()
.fillInField('h')
.fillInTime('30')
.submit();
},
});

View File

@@ -1,25 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Base } from '../show';
import { settled } from '@ember/test-helpers';
import { create, clickable, collection, text, isPresent } from 'ember-cli-page-object';
export default create({
...Base,
rows: collection('data-test-row-label'),
certificate: text('[data-test-row-value="Certificate"]'),
hasCert: isPresent('[data-test-row-value="Certificate"]'),
edit: clickable('[data-test-edit-link]'),
generateCert: clickable('[data-test-credentials-link]'),
deleteBtn: clickable('[data-test-role-delete] button'),
confirmBtn: clickable('[data-test-confirm-button]'),
async deleteRole() {
await this.deleteBtn();
await settled();
await this.confirmBtn();
await settled();
},
});

View File

@@ -14,8 +14,8 @@ export default create({
ip: fillable('[data-test-input="ip"]'), ip: fillable('[data-test-input="ip"]'),
warningIsPresent: isPresent('[data-test-warning]'), warningIsPresent: isPresent('[data-test-warning]'),
commonNameValue: value('[data-test-input="commonName"]'), commonNameValue: value('[data-test-input="commonName"]'),
submit: clickable('[data-test-secret-generate]'), submit: clickable('[data-test-save]'),
back: clickable('[data-test-secret-generate-back]'), back: clickable('[data-test-back-button]'),
generateOTP: async function () { generateOTP: async function () {
await this.user('admin').ip('192.168.1.1').submit(); await this.user('admin').ip('192.168.1.1').submit();
}, },