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"
spellcheck="false"
@value={{@accessKey}}
data-test-aws-input="accessKey"
data-test-input="accessKey"
/>
</div>
</div>
@@ -56,7 +56,7 @@
name="secret"
class="input"
@value={{@secretKey}}
data-test-aws-input="secretKey"
data-test-input="secretKey"
/>
</div>
</div>
@@ -104,7 +104,7 @@
{{/if}}
<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>
</form>
</T.Panel>
@@ -134,7 +134,7 @@
@onChange={{fn this.handleTtlChange "leaseMax"}}
/>
<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>
</form>
</T.Panel>

View File

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

View File

@@ -4,56 +4,49 @@
*/
import { service } from '@ember/service';
import { computed, set } from '@ember/object';
import Component from '@ember/component';
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
const MODEL_TYPES = {
'ssh-sign': {
model: 'ssh-sign',
},
'ssh-creds': {
const CREDENTIAL_TYPES = {
ssh: {
model: 'ssh-otp-credential',
title: 'Generate SSH Credentials',
formFields: ['username', 'ip'],
displayFields: ['username', 'ip', 'key', 'keyType', 'port'],
},
'aws-creds': {
aws: {
model: 'aws-credential',
title: 'Generate AWS Credentials',
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({
controlGroup: service(),
store: service(),
router: service(),
// set on the component
backendType: null,
backendPath: null,
roleName: null,
action: null,
export default class GenerateCredentials extends Component {
@service controlGroup;
@service store;
@service router;
model: null,
loading: false,
emptyData: '{\n}',
@tracked model;
@tracked loading = false;
@tracked hasGenerated = false;
emptyData = '{\n}';
modelForType() {
const type = this.options;
if (type) {
return type.model;
}
// 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();
},
constructor() {
super(...arguments);
const modelType = this.modelForType();
this.model = this.generateNewModel(modelType);
}
willDestroy() {
// 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) {
this.model.unloadRecord();
}
this._super(...arguments);
},
super.willDestroy();
}
createOrReplaceModel() {
const modelType = this.modelForType();
const model = this.model;
const roleName = this.roleName;
const backendPath = this.backendPath;
modelForType() {
const type = this.options;
if (type) {
return type.model;
}
// 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) {
return;
}
if (model) {
model.unloadRecord();
}
const { roleName, backendPath, awsRoleType } = this.args;
const attrs = {
role: {
backend: backendPath,
@@ -82,44 +101,60 @@ export default Component.extend({
},
id: `${backendPath}-${roleName}`,
};
const newModel = this.store.createRecord(modelType, attrs);
this.set('model', newModel);
},
if (awsRoleType) {
// this is only set from route if backendType = aws
attrs.credentialType = awsRoleType;
}
return this.store.createRecord(modelType, attrs);
}
actions: {
create() {
const model = this.model;
this.set('loading', true);
this.model
.save()
.then(() => {
model.set('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.set('loading', false);
});
},
replaceModel() {
const modelType = this.modelForType();
if (!modelType) {
return;
}
if (this.model) {
this.model.unloadRecord();
}
this.model = this.generateNewModel(modelType);
}
codemirrorUpdated(attr, val, codemirror) {
codemirror.performLint();
const hasErrors = codemirror.state.lint.marked.length > 0;
@action
create(evt) {
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) {
set(this.model, attr, JSON.parse(val));
}
},
@action
codemirrorUpdated(attr, val, codemirror) {
codemirror.performLint();
const hasErrors = codemirror.state.lint.marked.length > 0;
newModel() {
this.createOrReplaceModel();
},
},
});
if (!hasErrors) {
this.model[attr] = JSON.parse(val);
}
}
@action
reset() {
this.hasGenerated = false;
this.replaceModel();
}
}

View File

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

View File

@@ -4,8 +4,8 @@
*/
import Model, { attr } from '@ember-data/model';
import { computed } from '@ember/object';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes';
const CREDENTIAL_TYPES = [
{
value: 'iam_user',
@@ -25,27 +25,28 @@ const CREDENTIAL_TYPES = [
},
];
const DISPLAY_FIELDS = ['accessKey', 'secretKey', 'securityToken', 'leaseId', 'renewable', 'leaseDuration'];
export default Model.extend({
helpText:
'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', {
@withExpandedAttributes()
export default class AwsCredential extends Model {
@attr('object', {
readOnly: true,
}),
})
role;
credentialType: attr('string', {
@attr('string', {
defaultValue: 'iam_user',
possibleValues: CREDENTIAL_TYPES,
readOnly: true,
}),
})
credentialType;
roleArn: attr('string', {
@attr('string', {
label: 'Role ARN',
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.',
}),
})
roleArn;
ttl: attr({
@attr({
editType: 'ttl',
defaultValue: '3600s',
setDefault: true,
@@ -53,29 +54,17 @@ export default Model.extend({
label: 'TTL',
helpText:
'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'),
renewable: attr('boolean'),
leaseDuration: attr('number'),
accessKey: attr('string'),
secretKey: attr('string'),
securityToken: attr('string'),
})
ttl;
attrs: computed('credentialType', 'accessKey', 'securityToken', function () {
const type = this.credentialType;
const fieldsForType = {
iam_user: ['credentialType'],
assumed_role: ['credentialType', 'ttl', 'roleArn'],
federation_token: ['credentialType', 'ttl'],
session_token: ['credentialType', 'ttl'],
};
if (this.accessKey || this.securityToken) {
return expandAttributeMeta(this, DISPLAY_FIELDS.slice(0));
}
return expandAttributeMeta(this, fieldsForType[type].slice(0));
}),
@attr('string') leaseId;
@attr('boolean') renewable;
@attr('number') leaseDuration;
@attr('string') accessKey;
@attr('string', { masked: true }) secretKey;
@attr('string', { masked: true }) securityToken;
toCreds: computed('accessKey', 'secretKey', 'securityToken', 'leaseId', function () {
get toCreds() {
const props = {
accessKey: this.accessKey,
secretKey: this.secretKey,
@@ -90,5 +79,5 @@ export default Model.extend({
return ret;
}, {});
return JSON.stringify(propsWithVals, null, 2);
}),
});
}
}

View File

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

View File

@@ -17,12 +17,15 @@ export default Route.extend({
store: service(),
beforeModel() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
if (backend != 'ssh') {
return;
const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend');
// redirect if the backend type does not support credentials
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 = '') {
@@ -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) {
const role = params.secret;
const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend');
const roleType = params.roleType;
let dbCred;
let dbCred, awsRole;
if (backendType === 'database') {
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({
backendPath,
backendType,
roleName: role,
roleType,
dbCred,
awsRoleType: awsRole?.credentialType,
});
},

View File

@@ -76,7 +76,7 @@
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<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")}}
<Hds::Button
@text="Cancel"

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
* 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 { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@@ -13,7 +13,63 @@ import { GENERAL } from '../helpers/general-selectors';
import authPage from 'vault/tests/pages/auth';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
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) {
setupApplicationTest(hooks);
setupMirage(hooks);
@@ -30,28 +86,23 @@ module('Acceptance | aws secret backend', function (hooks) {
test('aws backend', async function (assert) {
const path = `aws-${this.uid}`;
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 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.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('lease')).exists('renders the leases config tab');
await fillIn('[data-test-aws-input="accessKey"]', 'foo');
await fillIn('[data-test-aws-input="secretKey"]', 'bar');
await fillIn(GENERAL.inputByAttr('accessKey'), 'foo');
await fillIn(GENERAL.inputByAttr('secretKey'), 'bar');
await click('[data-test-aws-input="root-save"]');
await click(GENERAL.saveButton);
assert.true(
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('[data-test-aws-input="lease-save"]');
await click(GENERAL.saveButton);
assert.true(
this.flashSuccessSpy.calledTwice,
'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');
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
await click('[data-test-role-aws-create]');
await click(GENERAL.saveButton);
await waitUntil(() => currentURL() === `/vault/secrets/${path}/show/${roleName}`); // flaky without this
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/show/${roleName}`,
'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.dom(`[data-test-secret-link="${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]');
assert.dom(AWS_CREDS.secretLink(roleName)).exists();
//and delete
await click(`[data-test-secret-link="${roleName}"] [data-test-popup-menu-trigger]`);
await waitUntil(() => find(`[data-test-aws-role-delete="${roleName}"]`)); // flaky without
await click(`[data-test-aws-role-delete="${roleName}"]`);
await click(`${AWS_CREDS.secretLink(roleName)} [data-test-popup-menu-trigger]`);
await waitUntil(() => find(AWS_CREDS.delete(roleName))); // flaky without
await click(AWS_CREDS.delete(roleName));
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
*/
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 { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
import authPage from 'vault/tests/pages/auth';
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) {
setupApplicationTest(hooks);
@@ -26,6 +35,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
{
type: 'ca',
name: 'carole',
credsRoute: 'vault.cluster.secrets.backend.sign',
async fillInCreate() {
await click('[data-test-input="allowUserCertificates"]');
},
@@ -61,6 +71,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
{
type: 'otp',
name: 'otprole',
credsRoute: 'vault.cluster.secrets.backend.credentials',
async fillInCreate() {
await fillIn('[data-test-input="defaultUser"]', 'admin');
await click('[data-test-toggle-group="Options"]');
@@ -84,13 +95,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
},
];
test('ssh backend', async function (assert) {
// Popup menu causes flakiness
setRunOptions({
rules: {
'color-contrast': { enabled: false },
},
});
assert.expect(28);
assert.expect(30);
const sshPath = `ssh-${this.uid}`;
await enablePage.enable('ssh', sshPath);
@@ -100,27 +105,24 @@ module('Acceptance | ssh secret backend', function (hooks) {
await click('[data-test-secret-backend-configure]');
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
await click('[data-test-ssh-input="configure-submit"]');
assert.ok(
await waitUntil(() => findAll('[data-test-ssh-input="public-key"]').length),
'a public key is fetched'
);
await waitFor('[data-test-ssh-input="public-key"]');
assert.dom('[data-test-ssh-input="public-key"]').exists();
await click('[data-test-backend-view-link]');
assert.strictEqual(currentURL(), `/vault/secrets/${sshPath}/list`, `redirects to ssh index`);
for (const role of ROLES) {
// create a role
await click('[ data-test-secret-create]');
await click('[data-test-secret-create]');
assert.ok(
find('[data-test-secret-header]').textContent.includes('SSH Role'),
`${role.type}: renders the create page`
);
assert
.dom('[data-test-secret-header]')
.includesText('SSH Role', `${role.type}: renders the create page`);
await fillIn('[data-test-input="name"]', role.name);
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
await click('[data-test-backend-credentials]');
assert.strictEqual(currentRouteName(), role.credsRoute);
await role.fillInGenerate();
if (role.type === 'ca') {
await settled();
@@ -146,29 +148,23 @@ module('Acceptance | ssh secret backend', function (hooks) {
}
// generate creds
await click('[data-test-secret-generate]');
await click(GENERAL.saveButton);
await settled(); // eslint-disable-line
role.assertAfterGenerate(assert, sshPath);
// click the "Back" button
await click('[data-test-secret-generate-back]');
await click('[data-test-back-button]');
assert.ok(
findAll('[data-test-secret-generate-form]').length,
`${role.type}: back takes you back to the form`
);
assert.dom('[data-test-secret-generate-form]').exists(`${role.type}: back takes you back to the form`);
await click('[data-test-secret-generate-cancel]');
await click(GENERAL.cancelButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${sshPath}/list`,
`${role.type}: cancel takes you to ssh index`
);
assert.ok(
findAll(`[data-test-secret-link="${role.name}"]`).length,
`${role.type}: role shows in the list`
);
assert.dom(`[data-test-secret-link="${role.name}"]`).exists(`${role.type}: role shows in the list`);
//and delete
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"]'),
warningIsPresent: isPresent('[data-test-warning]'),
commonNameValue: value('[data-test-input="commonName"]'),
submit: clickable('[data-test-secret-generate]'),
back: clickable('[data-test-secret-generate-back]'),
submit: clickable('[data-test-save]'),
back: clickable('[data-test-back-button]'),
generateOTP: async function () {
await this.user('admin').ip('192.168.1.1').submit();
},