Dynamic OpenAPI UI (#6209)

This commit is contained in:
madalynrose
2019-02-14 13:52:34 -05:00
committed by GitHub
parent d8e9adc9d3
commit 368d431b93
55 changed files with 812 additions and 562 deletions

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -1,2 +1,3 @@
import AuthConfig from './_base'; import AuthConfig from './_base';
export default AuthConfig.extend(); export default AuthConfig.extend();

View File

@@ -7,4 +7,8 @@ export default Adapter.extend({
} }
return `/v1/${role.backend}/sign/${role.name}`; return `/v1/${role.backend}/sign/${role.name}`;
}, },
pathForType() {
return 'sign';
},
}); });

View File

@@ -13,7 +13,6 @@ export default Adapter.extend({
} }
return url; return url;
}, },
optionsForQuery(id) { optionsForQuery(id) {
let data = {}; let data = {};
if (!id) { if (!id) {

View File

@@ -7,16 +7,20 @@ export default ApplicationAdapter.extend({
return path ? url + '/' + path : url; return path ? url + '/' + path : url;
}, },
internalURL(path) {
let url = `/${this.urlPrefix()}/internal/ui/mounts`;
if (path) {
url = `${url}/${path}`;
}
return url;
},
pathForType() { pathForType() {
return 'mounts'; return 'mounts';
}, },
query(store, type, query) { query(store, type, query) {
let url = `/${this.urlPrefix()}/internal/ui/mounts`; return this.ajax(this.internalURL(query.path), 'GET');
if (query.path) {
url = `${url}/${query.path}`;
}
return this.ajax(url, 'GET');
}, },
createRecord(store, type, snapshot) { createRecord(store, type, snapshot) {

View File

@@ -34,6 +34,10 @@ export default ApplicationAdapter.extend({
return url; return url;
}, },
pathForType() {
return 'mounts';
},
optionsForQuery(id, action, wrapTTL) { optionsForQuery(id, action, wrapTTL) {
let data = {}; let data = {};
if (action === 'query') { if (action === 'query') {

View File

@@ -8,10 +8,11 @@ const AuthConfigBase = Component.extend({
model: null, model: null,
flashMessages: service(), flashMessages: service(),
router: service(),
wizard: service(),
saveModel: task(function*() { saveModel: task(function*() {
try { try {
yield this.get('model').save(); yield this.model.save();
} catch (err) { } catch (err) {
// AdapterErrors are handled by the error-message component // AdapterErrors are handled by the error-message component
// in the form // in the form
@@ -20,7 +21,11 @@ const AuthConfigBase = Component.extend({
} }
return; return;
} }
this.get('flashMessages').success('The configuration was saved successfully.'); if (this.wizard.currentMachine === 'authentication' && this.wizard.featureState === 'config') {
this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE');
}
this.router.transitionTo('vault.cluster.access.methods').followRedirects();
this.flashMessages.success('The configuration was saved successfully.');
}), }),
}); });

View File

@@ -1,14 +1,16 @@
import AuthConfigComponent from './config'; import AuthConfigComponent from './config';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import DS from 'ember-data'; import DS from 'ember-data';
export default AuthConfigComponent.extend({ export default AuthConfigComponent.extend({
router: service(),
wizard: service(),
saveModel: task(function*() { saveModel: task(function*() {
const model = this.get('model'); let data = this.model.config.serialize();
let data = model.get('config').serialize(); data.description = this.model.description;
data.description = model.get('description');
try { try {
yield model.tune(data); yield this.model.tune(data);
} catch (err) { } catch (err) {
// AdapterErrors are handled by the error-message component // AdapterErrors are handled by the error-message component
// in the form // in the form
@@ -17,6 +19,10 @@ export default AuthConfigComponent.extend({
} }
return; return;
} }
this.get('flashMessages').success('The configuration options were saved successfully.'); if (this.wizard.currentMachine === 'authentication' && this.wizard.featureState === 'config') {
this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE');
}
this.router.transitionTo('vault.cluster.access.methods').followRedirects();
this.flashMessages.success('The configuration was saved successfully.');
}), }),
}); });

View File

@@ -21,7 +21,6 @@ export default Component.extend({
* *
*/ */
onMountSuccess() {}, onMountSuccess() {},
onConfigError() {},
/* /*
* @param String * @param String
* @public * @public
@@ -41,18 +40,18 @@ export default Component.extend({
*/ */
mountModel: null, mountModel: null,
showConfig: false, showEnable: false,
init() { init() {
this._super(...arguments); this._super(...arguments);
const type = this.get('mountType'); const type = this.mountType;
const modelType = type === 'secret' ? 'secret-engine' : 'auth-method'; const modelType = type === 'secret' ? 'secret-engine' : 'auth-method';
const model = this.get('store').createRecord(modelType); const model = this.store.createRecord(modelType);
this.set('mountModel', model); this.set('mountModel', model);
}, },
mountTypes: computed('mountType', function() { mountTypes: computed('mountType', function() {
return this.get('mountType') === 'secret' ? ENGINES : METHODS; return this.mountType === 'secret' ? ENGINES : METHODS;
}), }),
willDestroy() { willDestroy() {
@@ -60,44 +59,10 @@ export default Component.extend({
this.get('mountModel').rollbackAttributes(); this.get('mountModel').rollbackAttributes();
}, },
getConfigModelType(methodType) {
let mountType = this.get('mountType');
// will be something like secret-aws
// or auth-azure
let key = `${mountType}-${methodType}`;
let noConfig = ['auth-approle', 'auth-alicloud'];
if (mountType === 'secret' || noConfig.includes(key)) {
return;
}
if (methodType === 'aws') {
return 'auth-config/aws/client';
}
return `auth-config/${methodType}`;
},
changeConfigModel(methodType) {
let mount = this.get('mountModel');
if (this.get('mountType') === 'secret') {
return;
}
let configRef = mount.hasMany('authConfigs').value();
let currentConfig = configRef && configRef.get('firstObject');
if (currentConfig) {
// rollbackAttributes here will remove the the config model from the store
// because `isNew` will be true
currentConfig.rollbackAttributes();
currentConfig.unloadRecord();
}
let configType = this.getConfigModelType(methodType);
if (!configType) return;
let config = this.get('store').createRecord(configType);
config.set('backend', mount);
},
checkPathChange(type) { checkPathChange(type) {
let mount = this.get('mountModel'); let mount = this.mountModel;
let currentPath = mount.get('path'); let currentPath = mount.path;
let list = this.get('mountTypes'); let list = this.mountTypes;
// if the current path matches a type (meaning the user hasn't altered it), // if the current path matches a type (meaning the user hasn't altered it),
// change it here to match the new type // change it here to match the new type
let isUnchanged = list.findBy('type', currentPath); let isUnchanged = list.findBy('type', currentPath);
@@ -107,7 +72,7 @@ export default Component.extend({
}, },
mountBackend: task(function*() { mountBackend: task(function*() {
const mountModel = this.get('mountModel'); const mountModel = this.mountModel;
const { type, path } = mountModel.getProperties('type', 'path'); const { type, path } = mountModel.getProperties('type', 'path');
try { try {
yield mountModel.save(); yield mountModel.save();
@@ -116,74 +81,27 @@ export default Component.extend({
return; return;
} }
let mountType = this.get('mountType'); let mountType = this.mountType;
mountType = mountType === 'secret' ? `${mountType}s engine` : `${mountType} method`; mountType = mountType === 'secret' ? `${mountType}s engine` : `${mountType} method`;
this.get('flashMessages').success(`Successfully mounted the ${type} ${mountType} at ${path}.`); this.flashMessages.success(`Successfully mounted the ${type} ${mountType} at ${path}.`);
if (this.get('mountType') === 'secret') { yield this.onMountSuccess(type, path);
yield this.get('onMountSuccess')(type, path); return;
return;
}
yield this.get('saveConfig').perform(mountModel);
}).drop(),
advanceWizard() {
this.get('wizard').transitionFeatureMachine(
this.get('wizard.featureState'),
'CONTINUE',
this.get('mountModel').get('type')
);
},
saveConfig: task(function*(mountModel) {
const configRef = mountModel.hasMany('authConfigs').value();
const { type, path } = mountModel.getProperties('type', 'path');
if (!configRef) {
this.advanceWizard();
yield this.get('onMountSuccess')(type, path);
return;
}
const config = configRef.get('firstObject');
try {
if (config && Object.keys(config.changedAttributes()).length) {
yield config.save();
this.advanceWizard();
this.get('flashMessages').success(
`The config for ${type} ${this.get('mountType')} method at ${path} was saved successfully.`
);
}
yield this.get('onMountSuccess')(type, path);
} catch (err) {
this.get('flashMessages').danger(
`There was an error saving the configuration for ${type} ${this.get(
'mountType'
)} method at ${path}. ${err.errors.join(' ')}`
);
yield this.get('onConfigError')(mountModel.id);
}
}).drop(), }).drop(),
actions: { actions: {
onTypeChange(path, value) { onTypeChange(path, value) {
if (path === 'type') { if (path === 'type') {
this.get('wizard').set('componentState', value); this.wizard.set('componentState', value);
this.changeConfigModel(value);
this.checkPathChange(value); this.checkPathChange(value);
} }
}, },
toggleShowConfig(value) { toggleShowEnable(value) {
this.set('showConfig', value); this.set('showEnable', value);
if (value === true && this.get('wizard.featureState') === 'idle') { if (value === true && this.wizard.featureState === 'idle') {
this.get('wizard').transitionFeatureMachine( this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE', this.mountModel.type);
this.get('wizard.featureState'),
'CONTINUE',
this.get('mountModel').get('type')
);
} else { } else {
this.get('wizard').transitionFeatureMachine( this.wizard.transitionFeatureMachine(this.wizard.featureState, 'RESET', this.mountModel.type);
this.get('wizard.featureState'),
'RESET',
this.get('mountModel').get('type')
);
} }
}, },
}, },

View File

@@ -4,14 +4,10 @@ import Controller from '@ember/controller';
export default Controller.extend({ export default Controller.extend({
wizard: service(), wizard: service(),
actions: { actions: {
onMountSuccess: function(type) { onMountSuccess: function(type, path) {
let transition = this.transitionToRoute('vault.cluster.access.methods'); this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE', type);
return transition.followRedirects().then(() => { let transition = this.transitionToRoute('vault.cluster.settings.auth.configure', path);
this.get('wizard').transitionFeatureMachine(this.get('wizard.featureState'), 'CONTINUE', type); return transition.followRedirects();
});
},
onConfigError: function(modelId) {
return this.transitionToRoute('vault.cluster.settings.auth.configure', modelId);
}, },
}, },
}); });

View File

@@ -22,16 +22,16 @@ export default {
{ type: 'render', level: 'step', component: 'wizard/auth-enable' }, { type: 'render', level: 'step', component: 'wizard/auth-enable' },
], ],
on: { on: {
CONTINUE: 'list', CONTINUE: 'config',
}, },
}, },
list: { config: {
onEntry: [ onEntry: [
{ type: 'render', level: 'step', component: 'wizard/auth-list' },
{ type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' },
{ type: 'render', level: 'step', component: 'wizard/auth-config' },
], ],
on: { on: {
DETAILS: 'details', CONTINUE: 'details',
}, },
}, },
details: { details: {

View File

@@ -2,5 +2,8 @@ import DS from 'ember-data';
const { belongsTo } = DS; const { belongsTo } = DS;
export default DS.Model.extend({ export default DS.Model.extend({
backend: belongsTo('auth-method', { readOnly: true, async: false }), backend: belongsTo('auth-method', { inverse: 'authConfigs', readOnly: true, async: false }),
getHelpUrl: function(backend) {
return `/v1/auth/${backend}/config?help=1`;
},
}); });

View File

@@ -1,12 +1,13 @@
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
useOpenAPI: true,
tenantId: attr('string', { tenantId: attr('string', {
label: 'Tenant ID', label: 'Tenant ID',
helpText: 'The tenant ID for the Azure Active Directory organization', helpText: 'The tenant ID for the Azure Active Directory organization',
@@ -26,12 +27,16 @@ export default AuthConfig.extend({
googleCertsEndpoint: attr('string'), googleCertsEndpoint: attr('string'),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ default: ['tenantId', 'resource'] }, { default: ['tenantId', 'resource'] },
{ {
'Azure Options': ['clientId', 'clientSecret'], 'Azure Options': ['clientId', 'clientSecret'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -1,12 +1,14 @@
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
useOpenAPI: true,
// We have to leave this here because the backend doesn't support the file type yet.
credentials: attr('string', { credentials: attr('string', {
editType: 'file', editType: 'file',
}), }),
@@ -14,12 +16,15 @@ export default AuthConfig.extend({
googleCertsEndpoint: attr('string'), googleCertsEndpoint: attr('string'),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ default: ['credentials'] }, { default: ['credentials'] },
{ {
'Google Cloud Options': ['googleCertsEndpoint'], 'Google Cloud Options': ['googleCertsEndpoint'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -1,24 +1,28 @@
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
useOpenAPI: true,
organization: attr('string'), organization: attr('string'),
baseUrl: attr('string', { baseUrl: attr('string', {
label: 'Base URL', label: 'Base URL',
}), }),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ default: ['organization'] }, { default: ['organization'] },
{ {
'GitHub Options': ['baseUrl'], 'GitHub Options': ['baseUrl'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),

View File

@@ -2,36 +2,34 @@ import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
useOpenAPI: true,
kubernetesHost: attr('string', { kubernetesHost: attr('string', {
label: 'Kubernetes Host',
helpText: helpText:
'Host must be a host string, a host:port pair, or a URL to the base of the Kubernetes API server', 'Host must be a host string, a host:port pair, or a URL to the base of the Kubernetes API server',
}), }),
kubernetesCaCert: attr('string', { kubernetesCaCert: attr('string', {
label: 'Kubernetes CA Certificate',
editType: 'file', editType: 'file',
helpText: 'PEM encoded CA cert for use by the TLS client used to talk with the Kubernetes API', helpText: 'PEM encoded CA cert for use by the TLS client used to talk with the Kubernetes API',
}), }),
tokenReviewerJwt: attr('string', { tokenReviewerJwt: attr('string', {
label: 'Token Reviewer JWT',
helpText: helpText:
'A service account JWT used to access the TokenReview API to validate other JWTs during login. If not set the JWT used for login will be used to access the API', 'A service account JWT used to access the TokenReview API to validate other JWTs during login. If not set the JWT used for login will be used to access the API',
}), }),
pemKeys: attr({ pemKeys: attr({
label: 'Service account verification keys',
editType: 'stringArray', editType: 'stringArray',
}), }),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ {
default: ['kubernetesHost', 'kubernetesCaCert'], default: ['kubernetesHost', 'kubernetesCaCert'],
}, },
@@ -39,6 +37,10 @@ export default AuthConfig.extend({
'Kubernetes Options': ['tokenReviewerJwt', 'pemKeys'], 'Kubernetes Options': ['tokenReviewerJwt', 'pemKeys'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -3,97 +3,50 @@ import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
url: attr('string', { useOpenAPI: true,
label: 'URL',
}),
starttls: attr('boolean', {
defaultValue: false,
label: 'Issue StartTLS command after establishing an unencrypted connection',
}),
tlsMinVersion: attr('string', {
label: 'Minimum TLS Version',
defaultValue: 'tls12',
possibleValues: ['tls10', 'tls11', 'tls12'],
}),
tlsMaxVersion: attr('string', {
label: 'Maximum TLS Version',
defaultValue: 'tls12',
possibleValues: ['tls10', 'tls11', 'tls12'],
}),
insecureTls: attr('boolean', {
defaultValue: false,
label: 'Skip LDAP server SSL certificate verification',
}),
certificate: attr('string', {
label: 'CA certificate to verify LDAP server certificate',
editType: 'file',
}),
binddn: attr('string', { binddn: attr('string', {
label: 'Name of Object to bind (binddn)',
helpText: 'Used when performing user search. Example: cn=vault,ou=Users,dc=example,dc=com', helpText: 'Used when performing user search. Example: cn=vault,ou=Users,dc=example,dc=com',
}), }),
bindpass: attr('string', { bindpass: attr('string', {
label: 'Password',
helpText: 'Used along with binddn when performing user search', helpText: 'Used along with binddn when performing user search',
sensitive: true, sensitive: true,
}), }),
userdn: attr('string', { userdn: attr('string', {
label: 'User DN',
helpText: 'Base DN under which to perform user search. Example: ou=Users,dc=example,dc=com', helpText: 'Base DN under which to perform user search. Example: ou=Users,dc=example,dc=com',
}), }),
userattr: attr('string', { userattr: attr('string', {
label: 'User Attribute',
defaultValue: 'cn',
helpText: helpText:
'Attribute on user attribute object matching the username passed when authenticating. Examples: sAMAccountName, cn, uid', 'Attribute on user attribute object matching the username passed when authenticating. Examples: sAMAccountName, cn, uid',
}), }),
discoverdn: attr('boolean', {
defaultValue: false,
label: 'Use anonymous bind to discover the bind DN of a user',
}),
denyNullBind: attr('boolean', {
defaultValue: true,
label: 'Prevent users from bypassing authentication when providing an empty password',
}),
upndomain: attr('string', { upndomain: attr('string', {
label: 'User Principal (UPN) Domain',
helpText: helpText:
'The userPrincipalDomain used to construct the UPN string for the authenticating user. The constructed UPN will appear as [username]@UPNDomain. Example: example.com, which will cause vault to bind as username@example.com.', 'The userPrincipalDomain used to construct the UPN string for the authenticating user. The constructed UPN will appear as [username]@UPNDomain. Example: example.com, which will cause vault to bind as username@example.com.',
}), }),
groupfilter: attr('string', { groupfilter: attr('string', {
label: 'Group Filter',
helpText: helpText:
'Go template used when constructing the group membership query. The template can access the following context variables: [UserDN, Username]. The default is (|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}})), which is compatible with several common directory schemas. To support nested group resolution for Active Directory, instead use the following query: (&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))', 'Go template used when constructing the group membership query. The template can access the following context variables: [UserDN, Username]. The default is (|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}})), which is compatible with several common directory schemas. To support nested group resolution for Active Directory, instead use the following query: (&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))',
}), }),
groupdn: attr('string', { groupdn: attr('string', {
label: 'Group DN',
helpText: helpText:
'LDAP search base for group membership search. This can be the root containing either groups or users. Example: ou=Groups,dc=example,dc=com', 'LDAP search base for group membership search. This can be the root containing either groups or users. Example: ou=Groups,dc=example,dc=com',
}), }),
groupattr: attr('string', { groupattr: attr('string', {
label: 'Group Attribute',
defaultValue: 'cn',
helpText: helpText:
'LDAP attribute to follow on objects returned by groupfilter in order to enumerate user group membership. Examples: for groupfilter queries returning group objects, use: cn. For queries returning user objects, use: memberOf. The default is cn.', 'LDAP attribute to follow on objects returned by groupfilter in order to enumerate user group membership. Examples: for groupfilter queries returning group objects, use: cn. For queries returning user objects, use: memberOf. The default is cn.',
}), }),
useTokenGroups: attr('boolean', { useTokenGroups: attr('boolean', {
defaultValue: false,
label: 'Use Token Groups',
helpText: helpText:
'Use the Active Directory tokenGroups constructed attribute to find the group memberships. This returns all security groups for the user, including nested groups. In an Active Directory environment with a large number of groups this method offers increased performance. Selecting this will cause Group DN, Attribute, and Filter to be ignored.', 'Use the Active Directory tokenGroups constructed attribute to find the group memberships. This returns all security groups for the user, including nested groups. In an Active Directory environment with a large number of groups this method offers increased performance. Selecting this will cause Group DN, Attribute, and Filter to be ignored.',
}), }),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ {
default: ['url'], default: ['url'],
}, },
@@ -117,6 +70,9 @@ export default AuthConfig.extend({
'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'useTokenGroups'], 'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'useTokenGroups'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -2,32 +2,31 @@ import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
useOpenAPI: true,
orgName: attr('string', { orgName: attr('string', {
label: 'Organization Name',
helpText: 'Name of the organization to be used in the Okta API', helpText: 'Name of the organization to be used in the Okta API',
}), }),
apiToken: attr('string', { apiToken: attr('string', {
label: 'API Token',
helpText: helpText:
'Okta API token. This is required to query Okta for user group membership. If this is not supplied only locally configured groups will be enabled.', 'Okta API token. This is required to query Okta for user group membership. If this is not supplied only locally configured groups will be enabled.',
}), }),
baseUrl: attr('string', { baseUrl: attr('string', {
label: 'Base URL',
helpText: helpText:
'If set, will be used as the base domain for API requests. Examples are okta.com, oktapreview.com, and okta-emea.com', 'If set, will be used as the base domain for API requests. Examples are okta.com, oktapreview.com, and okta-emea.com',
}), }),
bypassOktaMfa: attr('boolean', { bypassOktaMfa: attr('boolean', {
defaultValue: false, defaultValue: false,
label: 'Bypass Okta MFA',
helpText: helpText:
"Useful if Vault's built-in MFA mechanisms. Will also cause certain other statuses to be ignored, such as PASSWORD_EXPIRED", "Useful if Vault's built-in MFA mechanisms. Will also cause certain other statuses to be ignored, such as PASSWORD_EXPIRED",
}), }),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ {
default: ['orgName'], default: ['orgName'],
}, },
@@ -35,6 +34,10 @@ export default AuthConfig.extend({
Options: ['apiToken', 'baseUrl', 'bypassOktaMfa'], Options: ['apiToken', 'baseUrl', 'bypassOktaMfa'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -1,38 +1,18 @@
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import AuthConfig from '../auth-config'; import AuthConfig from '../auth-config';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
const { attr } = DS; const { attr } = DS;
export default AuthConfig.extend({ export default AuthConfig.extend({
useOpenAPI: true,
host: attr('string'), host: attr('string'),
port: attr('number', {
defaultValue: 1812,
}),
secret: attr('string'), secret: attr('string'),
unregisteredUserPolicies: attr('string', {
label: 'Policies for unregistered users',
}),
dialTimeout: attr('number', {
defaultValue: 10,
}),
nasPort: attr('number', {
defaultValue: 10,
label: 'NAS Port',
}),
nasIdentifier: attr('string', {
label: 'NAS Identifier',
}),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ {
default: ['host', 'secret'], default: ['host', 'secret'],
}, },
@@ -40,6 +20,10 @@ export default AuthConfig.extend({
'RADIUS Options': ['port', 'nasPort', 'nasIdentifier', 'dialTimeout', 'unregisteredUserPolicies'], 'RADIUS Options': ['port', 'nasPort', 'nasIdentifier', 'dialTimeout', 'unregisteredUserPolicies'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -2,7 +2,7 @@ import { copy } from 'ember-copy';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import Certificate from './pki-certificate'; import Certificate from './pki-certificate';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
const { attr } = DS; const { attr } = DS;
export default Certificate.extend({ export default Certificate.extend({
@@ -10,7 +10,7 @@ export default Certificate.extend({
readOnly: true, readOnly: true,
defaultValue: false, defaultValue: false,
}), }),
useOpenAPI: true,
csr: attr('string', { csr: attr('string', {
label: 'Certificate Signing Request (CSR)', label: 'Certificate Signing Request (CSR)',
editType: 'textarea', editType: 'textarea',
@@ -18,11 +18,14 @@ export default Certificate.extend({
fieldGroups: computed('signVerbatim', function() { fieldGroups: computed('signVerbatim', function() {
const options = { Options: ['altNames', 'ipSans', 'ttl', 'excludeCnFromSans', 'otherSans'] }; const options = { Options: ['altNames', 'ipSans', 'ttl', 'excludeCnFromSans', 'otherSans'] };
const groups = [ let groups = [
{ {
default: ['csr', 'commonName', 'format', 'signVerbatim'], default: ['csr', 'commonName', 'format', 'signVerbatim'],
}, },
]; ];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, []);
}
if (this.get('signVerbatim') === false) { if (this.get('signVerbatim') === false) {
groups.push(options); groups.push(options);
} }

View File

@@ -29,6 +29,7 @@ export default DS.Model.extend({
fieldValue: 'id', fieldValue: 'id',
readOnly: true, readOnly: true,
}), }),
useOpenAPI: false,
// credentialTypes are for backwards compatibility. // credentialTypes are for backwards compatibility.
// we use this to populate "credentialType" in // we use this to populate "credentialType" in
// the serializer. if there is more than one, the // the serializer. if there is more than one, the
@@ -52,17 +53,15 @@ export default DS.Model.extend({
editType: 'json', editType: 'json',
}), }),
fields: computed('credentialType', function() { fields: computed('credentialType', function() {
let keys; let credentialType = this.credentialType;
let credentialType = this.get('credentialType');
let keysForType = { let keysForType = {
iam_user: ['name', 'credentialType', 'policyArns', 'policyDocument'], iam_user: ['name', 'credentialType', 'policyArns', 'policyDocument'],
assumed_role: ['name', 'credentialType', 'roleArns', 'policyDocument'], assumed_role: ['name', 'credentialType', 'roleArns', 'policyDocument'],
federation_token: ['name', 'credentialType', 'policyDocument'], federation_token: ['name', 'credentialType', 'policyDocument'],
}; };
keys = keysForType[credentialType];
return expandAttributeMeta(this, keys);
}),
return expandAttributeMeta(this, keysForType[credentialType]);
}),
updatePath: lazyCapabilities(apiPath`${'backend'}/roles/${'id'}`, 'backend', 'id'), updatePath: lazyCapabilities(apiPath`${'backend'}/roles/${'id'}`, 'backend', 'id'),
canDelete: alias('updatePath.canDelete'), canDelete: alias('updatePath.canDelete'),
canEdit: alias('updatePath.canUpdate'), canEdit: alias('updatePath.canUpdate'),

View File

@@ -3,7 +3,7 @@ import { computed } from '@ember/object';
import DS from 'ember-data'; import DS from 'ember-data';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import fieldToAttrs from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs';
import { combineFieldGroups } from 'vault/utils/openapi-to-attrs';
const { attr } = DS; const { attr } = DS;
export default DS.Model.extend({ export default DS.Model.extend({
@@ -15,101 +15,10 @@ export default DS.Model.extend({
fieldValue: 'id', fieldValue: 'id',
readOnly: true, readOnly: true,
}), }),
keyType: attr('string', { useOpenAPI: true,
possibleValues: ['rsa', 'ec'], getHelpUrl: function(backend) {
}), return `/v1/${backend}/roles/example?help=1`;
ttl: attr({ },
label: 'TTL',
editType: 'ttl',
}),
maxTtl: attr({
label: 'Max TTL',
editType: 'ttl',
}),
allowLocalhost: attr('boolean', {}),
allowedDomains: attr('string', {}),
allowBareDomains: attr('boolean', {}),
allowSubdomains: attr('boolean', {}),
allowGlobDomains: attr('boolean', {}),
allowAnyName: attr('boolean', {}),
enforceHostnames: attr('boolean', {}),
allowIpSans: attr('boolean', {
defaultValue: true,
label: 'Allow clients to request IP Subject Alternative Names (SANs)',
}),
allowedOtherSans: attr({
editType: 'stringArray',
label: 'Allowed Other SANs',
}),
serverFlag: attr('boolean', {
defaultValue: true,
}),
clientFlag: attr('boolean', {
defaultValue: true,
}),
codeSigningFlag: attr('boolean', {}),
emailProtectionFlag: attr('boolean', {}),
keyBits: attr('number', {
defaultValue: 2048,
}),
keyUsage: attr('string', {
defaultValue: 'DigitalSignature,KeyAgreement,KeyEncipherment',
editType: 'stringArray',
}),
extKeyUsageOids: attr({
label: 'Custom extended key usage OIDs',
editType: 'stringArray',
}),
requireCn: attr('boolean', {
label: 'Require common name',
defaultValue: true,
}),
useCsrCommonName: attr('boolean', {
label: 'Use CSR common name',
defaultValue: true,
}),
useCsrSans: attr('boolean', {
defaultValue: true,
label: 'Use CSR subject alternative names (SANs)',
}),
ou: attr({
label: 'OU (OrganizationalUnit)',
editType: 'stringArray',
}),
organization: attr({
editType: 'stringArray',
}),
country: attr({
editType: 'stringArray',
}),
locality: attr({
editType: 'stringArray',
label: 'Locality/City',
}),
province: attr({
editType: 'stringArray',
label: 'Province/State',
}),
streetAddress: attr({
editType: 'stringArray',
}),
postalCode: attr({
editType: 'stringArray',
}),
generateLease: attr('boolean', {}),
noStore: attr('boolean', {}),
policyIdentifiers: attr({
editType: 'stringArray',
}),
basicConstraintsValidForNonCA: attr('boolean', {
label: 'Mark Basic Constraints valid when issuing non-CA certificates.',
}),
notBeforeDuration: attr({
label: 'Not Before Duration',
editType: 'ttl',
defaultValue: '30s',
}),
updatePath: lazyCapabilities(apiPath`${'backend'}/roles/${'id'}`, 'backend', 'id'), updatePath: lazyCapabilities(apiPath`${'backend'}/roles/${'id'}`, 'backend', 'id'),
canDelete: alias('updatePath.canDelete'), canDelete: alias('updatePath.canDelete'),
canEdit: alias('updatePath.canUpdate'), canEdit: alias('updatePath.canUpdate'),
@@ -125,7 +34,7 @@ export default DS.Model.extend({
canSignVerbatim: alias('signVerbatimPath.canUpdate'), canSignVerbatim: alias('signVerbatimPath.canUpdate'),
fieldGroups: computed(function() { fieldGroups: computed(function() {
const groups = [ let groups = [
{ default: ['name', 'keyType'] }, { default: ['name', 'keyType'] },
{ {
Options: [ Options: [
@@ -167,10 +76,13 @@ export default DS.Model.extend({
], ],
}, },
{ {
Advanced: ['generateLease', 'noStore', 'basicConstraintsValidForNonCA', 'policyIdentifiers'], Advanced: ['generateLease', 'noStore', 'basicConstraintsValidForNonCa', 'policyIdentifiers'],
}, },
]; ];
let excludedFields = ['extKeyUsage'];
if (this.newFields) {
groups = combineFieldGroups(groups, this.newFields, excludedFields);
}
return fieldToAttrs(this, groups); return fieldToAttrs(this, groups);
}), }),
}); });

View File

@@ -38,7 +38,12 @@ const CA_FIELDS = [
'allowUserKeyIds', 'allowUserKeyIds',
'keyIdFormat', 'keyIdFormat',
]; ];
export default DS.Model.extend({ export default DS.Model.extend({
useOpenAPI: true,
getHelpUrl: function(backend) {
return `/v1/${backend}/roles/example?help=1`;
},
zeroAddress: attr('boolean', { zeroAddress: attr('boolean', {
readOnly: true, readOnly: true,
}), }),
@@ -46,12 +51,12 @@ export default DS.Model.extend({
readOnly: true, readOnly: true,
}), }),
name: attr('string', { name: attr('string', {
label: 'Role name', label: 'Role Name',
fieldValue: 'id', fieldValue: 'id',
readOnly: true, readOnly: true,
}), }),
keyType: attr('string', { keyType: attr('string', {
possibleValues: ['ca', 'otp'], possibleValues: ['ca', 'otp'], //overriding the API which also lists 'dynamic' as a type though it is deprecated
}), }),
adminUser: attr('string', { adminUser: attr('string', {
helpText: 'Username of the admin user at the remote host', helpText: 'Username of the admin user at the remote host',
@@ -68,25 +73,14 @@ export default DS.Model.extend({
'List of domains for which a client can request a certificate (e.g. `example.com`, or `*` to allow all)', 'List of domains for which a client can request a certificate (e.g. `example.com`, or `*` to allow all)',
}), }),
cidrList: attr('string', { cidrList: attr('string', {
label: 'CIDR list',
helpText: 'List of CIDR blocks for which this role is applicable', helpText: 'List of CIDR blocks for which this role is applicable',
}), }),
excludeCidrList: attr('string', { excludeCidrList: attr('string', {
label: 'Exclude CIDR list',
helpText: 'List of CIDR blocks that are not accepted by this role', helpText: 'List of CIDR blocks that are not accepted by this role',
}), }),
port: attr('number', { port: attr('number', {
defaultValue: 22,
helpText: 'Port number for the SSH connection (default is `22`)', helpText: 'Port number for the SSH connection (default is `22`)',
}), }),
ttl: attr({
label: 'TTL',
editType: 'ttl',
}),
maxTtl: attr({
label: 'Max TTL',
editType: 'ttl',
}),
allowedCriticalOptions: attr('string', { allowedCriticalOptions: attr('string', {
helpText: 'List of critical options that certificates have when signed', helpText: 'List of critical options that certificates have when signed',
}), }),
@@ -114,11 +108,9 @@ export default DS.Model.extend({
'Specifies if host certificates that are requested are allowed to be subdomains of those listed in Allowed Domains', 'Specifies if host certificates that are requested are allowed to be subdomains of those listed in Allowed Domains',
}), }),
allowUserKeyIds: attr('boolean', { allowUserKeyIds: attr('boolean', {
label: 'Allow user key IDs',
helpText: 'Specifies if users can override the key ID for a signed certificate with the "key_id" field', helpText: 'Specifies if users can override the key ID for a signed certificate with the "key_id" field',
}), }),
keyIdFormat: attr('string', { keyIdFormat: attr('string', {
label: 'Key ID format',
helpText: 'When supplied, this value specifies a custom format for the key id of a signed certificate', helpText: 'When supplied, this value specifies a custom format for the key id of a signed certificate',
}), }),

View File

@@ -18,7 +18,10 @@ export default DS.Model.extend({
role: attr('object', { role: attr('object', {
readOnly: true, readOnly: true,
}), }),
publicKey: attr('string'), publicKey: attr('string', {
label: 'Public Key',
editType: 'textarea',
}),
ttl: attr({ ttl: attr({
label: 'TTL', label: 'TTL',
editType: 'ttl', editType: 'ttl',

View File

@@ -2,6 +2,7 @@ import { inject as service } from '@ember/service';
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
export default Route.extend({ export default Route.extend({
flashMessages: service(), flashMessages: service(),
oldModel: null,
model(params) { model(params) {
let { backend } = params; let { backend } = params;
return this.store return this.store

View File

@@ -1,15 +1,28 @@
import { resolve } from 'rsvp'; import { resolve } from 'rsvp';
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { getOwner } from '@ember/application';
import { inject as service } from '@ember/service';
const SUPPORTED_DYNAMIC_BACKENDS = ['ssh', 'aws', 'pki']; const SUPPORTED_DYNAMIC_BACKENDS = ['ssh', 'aws', 'pki'];
export default Route.extend({ export default Route.extend({
templateName: 'vault/cluster/secrets/backend/credentials', templateName: 'vault/cluster/secrets/backend/credentials',
pathHelp: service('path-help'),
backendModel() { backendModel() {
return this.modelFor('vault.cluster.secrets.backend'); return this.modelFor('vault.cluster.secrets.backend');
}, },
beforeModel() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
if (backend != 'ssh') {
return;
}
let modelType = 'ssh-otp-credential';
let owner = getOwner(this);
return this.pathHelp.getNewModel(modelType, backend, owner);
},
model(params) { model(params) {
let role = params.secret; let role = params.secret;
let backendModel = this.backendModel(); let backendModel = this.backendModel();

View File

@@ -1,7 +1,9 @@
import { set } from '@ember/object'; import { set } from '@ember/object';
import { hash, all } from 'rsvp'; import { hash, all } from 'rsvp';
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { getOwner } from '@ember/application';
import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends';
import { inject as service } from '@ember/service';
const SUPPORTED_BACKENDS = supportedSecretBackends(); const SUPPORTED_BACKENDS = supportedSecretBackends();
@@ -19,24 +21,29 @@ export default Route.extend({
}, },
templateName: 'vault/cluster/secrets/backend/list', templateName: 'vault/cluster/secrets/backend/list',
pathHelp: service('path-help'),
beforeModel() { beforeModel() {
let { backend } = this.paramsFor('vault.cluster.secrets.backend'); let owner = getOwner(this);
let { secret } = this.paramsFor(this.routeName); let { secret } = this.paramsFor(this.routeName);
let backendModel = this.store.peekRecord('secret-engine', backend); let { backend, tab } = this.paramsFor('vault.cluster.secrets.backend');
let type = backendModel && backendModel.get('engineType'); let secretEngine = this.store.peekRecord('secret-engine', backend);
let type = secretEngine && secretEngine.get('engineType');
if (!type || !SUPPORTED_BACKENDS.includes(type)) { if (!type || !SUPPORTED_BACKENDS.includes(type)) {
return this.transitionTo('vault.cluster.secrets'); return this.transitionTo('vault.cluster.secrets');
} }
if (this.routeName === 'vault.cluster.secrets.backend.list' && !secret.endsWith('/')) { if (this.routeName === 'vault.cluster.secrets.backend.list' && !secret.endsWith('/')) {
return this.replaceWith('vault.cluster.secrets.backend.list', secret + '/'); return this.replaceWith('vault.cluster.secrets.backend.list', secret + '/');
} }
this.store.unloadAll('capabilities'); let modelType = this.getModelType(backend, tab);
return this.pathHelp.getNewModel(modelType, owner, backend).then(() => {
this.store.unloadAll('capabilities');
});
}, },
getModelType(backend, tab) { getModelType(backend, tab) {
let backendModel = this.store.peekRecord('secret-engine', backend); let secretEngine = this.store.peekRecord('secret-engine', backend);
let type = backendModel.get('engineType'); let type = secretEngine.get('engineType');
let types = { let types = {
transit: 'transit-key', transit: 'transit-key',
ssh: 'role-ssh', ssh: 'role-ssh',
@@ -44,8 +51,8 @@ export default Route.extend({
pki: tab === 'certs' ? 'pki-certificate' : 'role-pki', pki: tab === 'certs' ? 'pki-certificate' : 'role-pki',
// secret or secret-v2 // secret or secret-v2
cubbyhole: 'secret', cubbyhole: 'secret',
kv: backendModel.get('modelTypeForKV'), kv: secretEngine.get('modelTypeForKV'),
generic: backendModel.get('modelTypeForKV'), generic: secretEngine.get('modelTypeForKV'),
}; };
return types[type]; return types[type];
}, },

View File

@@ -1,11 +1,14 @@
import { set } from '@ember/object'; import { set } from '@ember/object';
import { hash, resolve } from 'rsvp'; import { hash, resolve } from 'rsvp';
import { inject as service } from '@ember/service';
import DS from 'ember-data';
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import utils from 'vault/lib/key-utils'; import utils from 'vault/lib/key-utils';
import { getOwner } from '@ember/application';
import UnloadModelRoute from 'vault/mixins/unload-model-route'; import UnloadModelRoute from 'vault/mixins/unload-model-route';
import DS from 'ember-data';
export default Route.extend(UnloadModelRoute, { export default Route.extend(UnloadModelRoute, {
pathHelp: service('path-help'),
capabilities(secret) { capabilities(secret) {
const { backend } = this.paramsFor('vault.cluster.secrets.backend'); const { backend } = this.paramsFor('vault.cluster.secrets.backend');
let backendModel = this.modelFor('vault.cluster.secrets.backend'); let backendModel = this.modelFor('vault.cluster.secrets.backend');
@@ -35,15 +38,27 @@ export default Route.extend(UnloadModelRoute, {
// perhaps in the future we could recurse _for_ users, but for now, just kick them // perhaps in the future we could recurse _for_ users, but for now, just kick them
// back to the list // back to the list
const { secret } = this.paramsFor(this.routeName); const { secret } = this.paramsFor(this.routeName);
const parentKey = utils.parentKeyForKey(secret); return this.buildModel(secret).then(() => {
const mode = this.routeName.split('.').pop(); const parentKey = utils.parentKeyForKey(secret);
if (mode === 'edit' && utils.keyIsFolder(secret)) { const mode = this.routeName.split('.').pop();
if (parentKey) { if (mode === 'edit' && utils.keyIsFolder(secret)) {
return this.transitionTo('vault.cluster.secrets.backend.list', parentKey); if (parentKey) {
} else { return this.transitionTo('vault.cluster.secrets.backend.list', parentKey);
return this.transitionTo('vault.cluster.secrets.backend.list-root'); } else {
return this.transitionTo('vault.cluster.secrets.backend.list-root');
}
} }
});
},
buildModel(secret) {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
let modelType = this.modelType(backend, secret);
if (['secret', 'secret-v2'].includes(modelType)) {
return resolve();
} }
let owner = getOwner(this);
return this.pathHelp.getNewModel(modelType, owner, backend);
}, },
modelType(backend, secret) { modelType(backend, secret) {

View File

@@ -14,6 +14,10 @@ export default Route.extend(UnloadModel, {
}; };
}, },
pathForType() {
return 'sign';
},
model(params) { model(params) {
const role = params.secret; const role = params.secret;
const backendModel = this.backendModel(); const backendModel = this.backendModel();

View File

@@ -4,10 +4,12 @@ import Route from '@ember/routing/route';
import RSVP from 'rsvp'; import RSVP from 'rsvp';
import DS from 'ember-data'; import DS from 'ember-data';
import UnloadModelRoute from 'vault/mixins/unload-model-route'; import UnloadModelRoute from 'vault/mixins/unload-model-route';
import { getOwner } from '@ember/application';
export default Route.extend(UnloadModelRoute, { export default Route.extend(UnloadModelRoute, {
modelPath: 'model.model', modelPath: 'model.model',
wizard: service(), pathHelp: service('path-help'),
modelType(backendType, section) { modelType(backendType, section) {
const MODELS = { const MODELS = {
'aws-client': 'auth-config/aws/client', 'aws-client': 'auth-config/aws/client',
@@ -25,15 +27,22 @@ export default Route.extend(UnloadModelRoute, {
return MODELS[`${backendType}-${section}`]; return MODELS[`${backendType}-${section}`];
}, },
beforeModel() {
const { section_name } = this.paramsFor(this.routeName);
if (section_name === 'options') {
return;
}
const { method } = this.paramsFor('vault.cluster.settings.auth.configure');
const backend = this.modelFor('vault.cluster.settings.auth.configure');
const modelType = this.modelType(backend.type, section_name);
let owner = getOwner(this);
return this.pathHelp.getNewModel(modelType, owner, method);
},
model(params) { model(params) {
const backend = this.modelFor('vault.cluster.settings.auth.configure'); const backend = this.modelFor('vault.cluster.settings.auth.configure');
const { section_name: section } = params; const { section_name: section } = params;
if (section === 'options') { if (section === 'options') {
this.get('wizard').transitionFeatureMachine(
this.get('wizard.featureState'),
'EDIT',
backend.get('type')
);
return RSVP.hash({ return RSVP.hash({
model: backend, model: backend,
section, section,
@@ -47,11 +56,6 @@ export default Route.extend(UnloadModelRoute, {
} }
const model = this.store.peekRecord(modelType, backend.id); const model = this.store.peekRecord(modelType, backend.id);
if (model) { if (model) {
this.get('wizard').transitionFeatureMachine(
this.get('wizard.featureState'),
'EDIT',
backend.get('type')
);
return RSVP.hash({ return RSVP.hash({
model, model,
section, section,
@@ -60,11 +64,6 @@ export default Route.extend(UnloadModelRoute, {
return this.store return this.store
.findRecord(modelType, backend.id) .findRecord(modelType, backend.id)
.then(config => { .then(config => {
this.get('wizard').transitionFeatureMachine(
this.get('wizard.featureState'),
'EDIT',
backend.get('type')
);
config.set('backend', backend); config.set('backend', backend);
return RSVP.hash({ return RSVP.hash({
model: config, model: config,

View File

@@ -0,0 +1,59 @@
/*
This service is used to pull an OpenAPI document describing the
shape of data at a specific path to hydrate a model with attrs it
has less (or no) information about.
*/
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { expandOpenApiProps, combineAttributes } from 'vault/utils/openapi-to-attrs';
import { resolve } from 'rsvp';
export function sanitizePath(path) {
//remove whitespace + remove trailing and leading slashes
return path.trim().replace(/^\/+|\/+$/g, '');
}
export default Service.extend({
attrs: null,
ajax(url, options = {}) {
let appAdapter = getOwner(this).lookup(`adapter:application`);
let { data } = options;
return appAdapter.ajax(url, 'GET', {
data,
});
},
//Makes a call to grab the OpenAPI document.
//Returns relevant information from OpenAPI
//as determined by the expandOpenApiProps util
getProps(helpUrl, backend) {
return this.ajax(helpUrl, backend).then(help => {
let path = Object.keys(help.openapi.paths)[0];
let props = help.openapi.paths[path].post.requestBody.content['application/json'].schema.properties;
return expandOpenApiProps(props);
});
},
getNewModel(modelType, owner, backend) {
let name = `model:${modelType}`;
let newModel = owner.factoryFor(name).class;
if (newModel.merged || newModel.prototype.useOpenAPI !== true) {
return resolve();
}
let helpUrl = newModel.prototype.getHelpUrl(backend);
return this.getProps(helpUrl, backend).then(props => {
if (owner.hasRegistration(name) && !newModel.merged) {
let { attrs, newFields } = combineAttributes(newModel.attributes, props);
newModel = newModel.extend(attrs, { newFields });
} else {
//generate a whole new model
}
newModel.reopenClass({ merged: true });
owner.unregister(name);
owner.register(name, newModel);
});
},
});

View File

@@ -11,8 +11,13 @@
{{/if}} {{/if}}
</div> </div>
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<button type="submit" data-test-save-config=true class="button is-primary {{if saveModel.isRunning 'loading'}}" disabled={{saveModel.isRunning}}> <button
type="submit"
data-test-save-config="true"
class="button is-primary {{if saveModel.isRunning "loading"}}"
disabled={{saveModel.isRunning}}
>
Save Save
</button> </button>
</div> </div>
</form> </form>

View File

@@ -7,8 +7,13 @@
{{/each}} {{/each}}
</div> </div>
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<button type="submit" data-test-save-config=true class="button is-primary {{if saveModel.isRunning 'loading'}}" disabled={{saveModel.isRunning}}> <button
type="submit"
data-test-save-config="true"
class="button is-primary {{if saveModel.isRunning "loading"}}"
disabled={{saveModel.isRunning}}
>
Update Options Update Options
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,7 +1,19 @@
{{#unless {{#unless
(or (or
(and attr.options.editType (not-eq attr.options.editType "textarea"))
(eq attr.type "boolean") (eq attr.type "boolean")
(contains
attr.options.editType
(array
"boolean"
"searchSelect"
"mountAccessor"
"kv"
"file"
"ttl"
"stringArray"
"json"
)
)
) )
}} }}
<label for="{{attr.name}}" class="is-label"> <label for="{{attr.name}}" class="is-label">
@@ -114,6 +126,7 @@
}} }}
{{else if (eq attr.options.editType "stringArray")}} {{else if (eq attr.options.editType "stringArray")}}
{{string-list {{string-list
data-test-input=attr.name
label=labelString label=labelString
warning=attr.options.warning warning=attr.options.warning
helpText=attr.options.helpText helpText=attr.options.helpText
@@ -124,9 +137,10 @@
<MaskedInput <MaskedInput
@value={{or (get model valuePath) attr.options.defaultValue}} @value={{or (get model valuePath) attr.options.defaultValue}}
@placeholder="" @placeholder=""
@allowCopy=true @allowCopy="true"
/> />
{{else if (or (eq attr.type 'number') (eq attr.type 'string'))}}
{{else if (or (eq attr.type "number") (eq attr.type "string"))}}
<div class="control"> <div class="control">
{{#if (eq attr.options.editType "textarea")}} {{#if (eq attr.options.editType "textarea")}}
<textarea <textarea

View File

@@ -1,96 +1,124 @@
<PageHeader as |p|> <PageHeader as |p|>
<p.levelLeft> <p.levelLeft>
<h1 class="title is-3" data-test-mount-form-header=true> <h1 class="title is-3" data-test-mount-form-header="true">
{{#if showConfig}} {{#if showEnable}}
{{#with (find-by 'type' mountModel.type mountTypes) as |typeInfo|}} {{#with (find-by "type" mountModel.type mountTypes) as |typeInfo|}}
<ICon @size=24 @glyph={{concat "enable/" (or typeInfo.glyph typeInfo.type)}} @class="has-text-grey-light" /> <ICon
@size="24"
@glyph={{concat "enable/" (or typeInfo.glyph typeInfo.type)}}
@class="has-text-grey-light"
/>
{{#if (eq mountType "auth")}} {{#if (eq mountType "auth")}}
Enable {{typeInfo.displayName}} authentication method {{concat "Enable " typeInfo.displayName " authentication method"}}
{{else}} {{else}}
Enable {{typeInfo.displayName}} secrets engine {{concat "Enable " typeInfo.displayName "secrets engine"}}
{{/if}} {{/if}}
{{/with}} {{/with}}
{{else if (eq mountType "auth")}}
Enable an authentication method
{{else}} {{else}}
{{#if (eq mountType "auth")}} Enable a secrets engine
Enable an authentication method
{{else}}
Enable a secrets engine
{{/if}}
{{/if}} {{/if}}
</h1> </h1>
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
<form {{action (perform mountBackend) on="submit"}}> <form {{action (perform mountBackend) on="submit"}}>
<div class="box is-sideless is-fullwidth is-marginless"> <div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="enable" @noun={{if (eq mountType "auth") "auth method" "secret engine"}} /> <NamespaceReminder
@mode="enable"
@noun={{if (eq mountType "auth") "auth method" "secret engine"}}
/>
{{message-error model=mountModel}} {{message-error model=mountModel}}
{{#if showConfig}} {{#if showEnable}}
{{form-field-groups model=mountModel onChange=(action "onTypeChange") renderGroup="default"}} {{form-field-groups
{{#if mountModel.authConfigs.firstObject}} model=mountModel
{{form-field-groups model=mountModel.authConfigs.firstObject}} onChange=(action "onTypeChange")
{{/if}} renderGroup="default"
{{form-field-groups model=mountModel onChange=(action "onTypeChange") renderGroup="Method Options"}} }}
{{form-field-groups
model=mountModel
onChange=(action "onTypeChange")
renderGroup="Method Options"
}}
{{else}} {{else}}
{{#each (array "generic" "cloud" "infra") as |category|}} {{#each (array "generic" "cloud" "infra") as |category|}}
<h3 class="title box-radio-header"> <h3 class="title box-radio-header">
{{capitalize category}} {{capitalize category}}
</h3> </h3>
<div class="box-radio-container"> <div class="box-radio-container">
{{#each (filter-by "category" category mountTypes) as |type|}} {{#each (filter-by "category" category mountTypes) as |type|}}
<label <label
for={{type.type}} for={{type.type}}
class="box-radio{{if (eq mountModel.type type.type) " is-selected"}}" class="box-radio
data-test-mount-type-radio {{if (eq mountModel.type type.type) " is-selected"}}"
data-test-mount-type={{type.type}} data-test-mount-type-radio
> data-test-mount-type={{type.type}}
<ICon @size=36 @excludeIconClass={{true}} @glyph={{concat "enable/" (or type.glyph type.type)}} @class="has-text-grey-light" /> >
{{type.displayName}} <ICon
<RadioButton @size="36"
@value={{type.type}} @excludeIconClass={{true}}
@radioClass="radio" @glyph={{concat "enable/" (or type.glyph type.type)}}
@groupValue={{mountModel.type}} @class="has-text-grey-light"
@changed={{queue (action (mut mountModel.type)) (action "onTypeChange" "type")}} />
@name="mount-type"
@radioId={{type.type}} {{type.displayName}}
/> <RadioButton
<label for={{type.type}}></label> @value={{type.type}}
</label> @radioClass="radio"
{{/each}} @groupValue={{mountModel.type}}
@changed={{queue
(action (mut mountModel.type))
(action "onTypeChange" "type")
}}
@name="mount-type"
@radioId={{type.type}}
/>
<label for={{type.type}}></label>
</label>
{{/each}}
</div> </div>
{{/each}} {{/each}}
{{/if}} {{/if}}
</div> </div>
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
{{#if showConfig}} {{#if showEnable}}
<div class="control"> <div class="control">
<button type="submit" data-test-mount-submit=true class="button is-primary {{if mountBackend.isRunning 'loading'}}" disabled={{mountBackend.isRunning}}> <button
{{#if (eq mountType "auth")}} type="submit"
Enable Method data-test-mount-submit="true"
{{else}} class="button is-primary {{if mountBackend.isRunning "loading"}}"
Enable Engine disabled={{mountBackend.isRunning}}
{{/if}} >
</button> {{#if (eq mountType "auth")}}
</div> Enable Method
<div class="control"> {{else}}
<button Enable Engine
data-test-mount-back {{/if}}
type="button" </button>
class="button" </div>
onclick={{action "toggleShowConfig" false}} <div class="control">
> <button
Back data-test-mount-back
</button> type="button"
</div> class="button"
onclick={{action "toggleShowEnable" false}}
>
Back
</button>
</div>
{{else}} {{else}}
<button <button
data-test-mount-next data-test-mount-next
type="button" type="button"
class="button is-primary" class="button is-primary"
onclick={{action "toggleShowConfig" true}} onclick={{action "toggleShowEnable" true}}
disabled={{not mountModel.type}} disabled={{not mountModel.type}}
> >
Next Next
</button> </button>
{{/if}} {{/if}}
</div> </div>
</form> </form>

View File

@@ -2,17 +2,12 @@
<label class="title is-5" data-test-string-list-label="true"> <label class="title is-5" data-test-string-list-label="true">
{{label}} {{label}}
{{#if helpText}} {{#if helpText}}
{{#info-tooltip}} {{#info-tooltip}}{{helpText}}{{/info-tooltip}}
{{helpText}}
{{/info-tooltip}}
{{/if}} {{/if}}
</label> </label>
{{/if}} {{/if}}
{{#if warning}} {{#if warning}}
<AlertBanner <AlertBanner @type="warning" @message={{warning}} />
@type="warning"
@message={{warning}}
/>
{{/if}} {{/if}}
{{#each inputList as |data index|}} {{#each inputList as |data index|}}
<div class="field is-grouped" data-test-string-list-row="{{index}}"> <div class="field is-grouped" data-test-string-list-row="{{index}}">
@@ -30,14 +25,29 @@
</div> </div>
<div class="control"> <div class="control">
{{#if (eq (inc index) inputList.length)}} {{#if (eq (inc index) inputList.length)}}
<button type="button" class="button is-outlined is-primary" {{action "addInput"}} data-test-string-list-button="add"> <button
type="button"
class="button is-outlined is-primary"
data-test-string-list-button="add"
{{action "addInput"}}
>
Add Add
</button> </button>
{{else}} {{else}}
<button type="button" class="button is-expanded is-icon" {{action "removeInput" index}} data-test-string-list-button="delete" > <button
{{i-con size=22 glyph='trash-a' excludeIconClass=true class="is-large has-text-grey-light"}} type="button"
class="button is-expanded is-icon"
data-test-string-list-button="delete"
{{action "removeInput" index}}
>
{{i-con
size=22
glyph="trash-a"
excludeIconClass=true
class="is-large has-text-grey-light"
}}
</button> </button>
{{/if}} {{/if}}
</div> </div>
</div> </div>
{{/each}} {{/each}}

View File

@@ -0,0 +1,10 @@
<WizardSection
@headerText="Configuring Your Auth Method"
@docText="Docs: Authentication Methods"
@docPath="/docs/auth/index.html"
@instructions="Click the 'Save' link to save any extra configuration. Saving without filling anything in will use the defaults."
>
<p>
You can update your new auth method configuration here.
</p>
</WizardSection>

View File

@@ -2,9 +2,9 @@
@headerText="Entering Auth Method details" @headerText="Entering Auth Method details"
@docText="Docs: Authentication Methods" @docText="Docs: Authentication Methods"
@docPath="/docs/auth/index.html" @docPath="/docs/auth/index.html"
@instructions='Customize your new method and click "Enable Method".' @instructions="Name your method and click 'Enable Method'."
> >
<p> <p>
Great! Now you can customize this method with a name and description that makes sense for your team, and fill out any options that are specific to this method. Great! Now you can customize this method with a name and fill out general configuration under "Method Options".
</p> </p>
</WizardSection> </WizardSection>

View File

@@ -1,23 +1,38 @@
<div class="field"> <div class="field">
{{#unless (or attr.options.editType (eq attr.type 'boolean'))}} {{#unless
(or
(contains
attr.options.editType
(array
"boolean"
"searchSelect"
"mountAccessor"
"kv"
"file"
"ttl"
"stringArray"
"json"
)
)
(eq attr.type "boolean")
)
}}
<label for="{{attr.name}}" class="is-label"> <label for="{{attr.name}}" class="is-label">
{{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} {{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
{{#if attr.options.helpText}} {{#if attr.options.helpText}}
{{#info-tooltip}} {{#info-tooltip}}{{attr.options.helpText}}{{/info-tooltip}}
{{attr.options.helpText}}
{{/info-tooltip}}
{{/if}} {{/if}}
</label> </label>
{{/unless}} {{/unless}}
{{#if attr.options.possibleValues}} {{#if attr.options.possibleValues}}
<div class="control is-expanded" > <div class="control is-expanded">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select <select
name="{{attr.name}}" name="{{attr.name}}"
id="{{attr.name}}" id="{{attr.name}}"
onchange={{action (mut (get model attr.name)) value="target.value"}} onchange={{action (mut (get model attr.name)) value="target.value"}}
data-test-input={{attr.name}} data-test-input={{attr.name}}
> >
{{#each attr.options.possibleValues as |val|}} {{#each attr.options.possibleValues as |val|}}
<option selected={{eq (get model attr.name) val}} value={{val}}> <option selected={{eq (get model attr.name) val}} value={{val}}>
{{val}} {{val}}
@@ -26,34 +41,48 @@
</select> </select>
</div> </div>
</div> </div>
{{else if (eq attr.options.editType 'ttl')}} {{else if (eq attr.options.editType "ttl")}}
{{ttl-picker initialValue=(or (get model attr.name) attr.options.defaultValue) labelText=(if attr.options.label attr.options.label (humanize (dasherize attr.name))) setDefaultValue=false onChange=(action (mut (get model attr.name)))}} {{ttl-picker
{{else if (or (eq attr.type 'number') (eq attr.type 'string'))}} initialValue=(or (get model attr.name) attr.options.defaultValue)
labelText=(if
attr.options.label attr.options.label (humanize (dasherize attr.name))
)
setDefaultValue=false
onChange=(action (mut (get model attr.name)))
}}
{{else if (or (eq attr.type "number") (eq attr.type "string"))}}
<div class="control"> <div class="control">
{{input id=attr.name value=(get model (or attr.options.fieldValue attr.name)) class="input" data-test-input=attr.name}} {{input
</div> id=attr.name
{{else if (eq attr.type 'boolean')}} value=(get model (or attr.options.fieldValue attr.name))
<div class="b-checkbox"> class="input"
<input type="checkbox" data-test-input=attr.name
id="{{attr.name}}"
class="styled"
checked={{get model attr.name}}
onchange={{action (mut (get model attr.name)) value="target.checked"}}
data-test-input={{attr.name}}
/>
<label for="{{attr.name}}" class="is-label">
{{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
{{#if attr.options.helpText}}
{{#info-tooltip}}
{{attr.options.helpText}}
{{/info-tooltip}}
{{/if}}
</label>
</div>
{{else if (eq attr.type 'object')}}
{{json-editor
value=(if (get model attr.name) (stringify (get model attr.name)) emptyData)
valueUpdated=(action "codemirrorUpdated" attr.name)
}} }}
{{/if}} </div>
</div> {{else if (eq attr.type "boolean")}}
<div class="b-checkbox">
<input
type="checkbox"
id="{{attr.name}}"
class="styled"
checked={{get model attr.name}}
onchange={{action (mut (get model attr.name)) value="target.checked"}}
data-test-input={{attr.name}}
/>
<label for="{{attr.name}}" class="is-label">
{{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
{{#if attr.options.helpText}}
{{#info-tooltip}}{{attr.options.helpText}}{{/info-tooltip}}
{{/if}}
</label>
</div>
{{else if (eq attr.type "object")}}
{{json-editor
value=(if
(get model attr.name) (stringify (get model attr.name)) emptyData
)
valueUpdated=(action "codemirrorUpdated" attr.name)
}}
{{/if}}
</div>

View File

@@ -1,5 +1,5 @@
{{#if (eq model.section "options") }} {{#if (eq model.section "options")}}
{{auth-config-form/options model.model}} {{auth-config-form/options model.model}}
{{else}} {{else}}
{{auth-config-form/config model.model}} {{auth-config-form/config model.model}}
{{/if}} {{/if}}

View File

@@ -1,4 +1 @@
<MountBackendForm <MountBackendForm @onMountSuccess={{action "onMountSuccess"}} />
@onMountSuccess={{action "onMountSuccess"}}
@onConfigError={{action "onConfigError"}}
/>

View File

@@ -0,0 +1,89 @@
import DS from 'ember-data';
const { attr } = DS;
import { assign } from '@ember/polyfills';
import { isEmpty } from '@ember/utils';
export const expandOpenApiProps = function(props) {
let attrs = {};
// expand all attributes
for (let prop in props) {
let details = props[prop];
if (details.deprecated === true) {
continue;
}
if (details.type === 'integer') {
details.type = 'number';
}
let editType = details.type;
if (details.format === 'seconds') {
editType = 'ttl';
} else if (details.items) {
editType = details.items.type + details.type.capitalize();
}
attrs[prop.camelize()] = {
editType: editType,
type: details.type,
};
if (details['x-vault-displayName']) {
attrs[prop.camelize()].label = details['x-vault-displayName'];
}
if (details['enum']) {
attrs[prop.camelize()].possibleValues = details['enum'];
}
if (details['x-vault-displayValue']) {
attrs[prop.camelize()].defaultValue = details['x-vault-displayValue'];
} else {
if (!isEmpty(details['default'])) {
attrs[prop.camelize()].defaultValue = details['default'];
}
}
}
return attrs;
};
export const combineAttributes = function(oldAttrs, newProps) {
let newAttrs = {};
let newFields = [];
oldAttrs.forEach(function(value, name) {
if (newProps[name]) {
newAttrs[name] = attr(newProps[name].type, assign({}, newProps[name], value.options));
} else {
newAttrs[name] = attr(value.type, value.options);
}
});
for (let prop in newProps) {
if (newAttrs[prop]) {
continue;
} else {
newAttrs[prop] = attr(newProps[prop].type, newProps[prop]);
newFields.push(prop);
}
}
return { attrs: newAttrs, newFields };
};
export const combineFields = function(currentFields, newFields, excludedFields) {
let otherFields = newFields.filter(field => {
return !currentFields.includes(field) && !excludedFields.includes(field);
});
if (otherFields.length) {
currentFields = currentFields.concat(otherFields);
}
return currentFields;
};
export const combineFieldGroups = function(currentGroups, newFields, excludedFields) {
let allFields = [];
for (let group of currentGroups) {
let fieldName = Object.keys(group)[0];
allFields = allFields.concat(group[fieldName]);
}
let otherFields = newFields.filter(field => {
return !allFields.includes(field) && !excludedFields.includes(field);
});
if (otherFields.length) {
currentGroups[0].default = currentGroups[0].default.concat(otherFields);
}
return currentGroups;
};

View File

@@ -33,7 +33,7 @@ module('Acceptance | settings/auth/configure/section', function(hooks) {
await withFlash(page.save(), () => { await withFlash(page.save(), () => {
assert.equal( assert.equal(
page.flash.latestMessage, page.flash.latestMessage,
`The configuration options were saved successfully.`, `The configuration was saved successfully.`,
'success flash shows' 'success flash shows'
); );
}); });

View File

@@ -28,9 +28,11 @@ module('Acceptance | settings/auth/enable', function(hooks) {
}); });
assert.equal( assert.equal(
currentRouteName(), currentRouteName(),
'vault.cluster.access.methods', 'vault.cluster.settings.auth.configure.section',
'redirects to the auth backend list page' 'redirects to the auth config page'
); );
await listPage.visit();
assert.ok(listPage.findLinkById(path), 'mount is present in the list'); assert.ok(listPage.findLinkById(path), 'mount is present in the list');
}); });
}); });

View File

@@ -5,17 +5,33 @@ import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers'; import { render, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon'; import sinon from 'sinon';
import Service from '@ember/service';
import { create } from 'ember-cli-page-object'; import { create } from 'ember-cli-page-object';
import authConfigForm from 'vault/tests/pages/components/auth-config-form/options'; import authConfigForm from 'vault/tests/pages/components/auth-config-form/options';
const component = create(authConfigForm); const component = create(authConfigForm);
const routerService = Service.extend({
transitionTo() {
return {
followRedirects() {
return resolve();
},
};
},
replaceWith() {
return resolve();
},
});
module('Integration | Component | auth-config-form options', function(hooks) { module('Integration | Component | auth-config-form options', function(hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
hooks.beforeEach(function() { hooks.beforeEach(function() {
this.owner.lookup('service:flash-messages').registerTypes(['success']); this.owner.lookup('service:flash-messages').registerTypes(['success']);
this.owner.register('service:router', routerService);
this.router = this.owner.lookup('service:router');
component.setContext(this); component.setContext(this);
}); });

View File

@@ -36,9 +36,6 @@ const routerService = Service.extend({
}, },
}; };
}, },
replaceWith() {
return resolve();
},
}); });
module('Integration | Component | auth form', function(hooks) { module('Integration | Component | auth form', function(hooks) {

View File

@@ -71,59 +71,4 @@ module('Integration | Component | mount backend form', function(hooks) {
assert.ok(enableRequest, 'it calls enable on an auth method'); assert.ok(enableRequest, 'it calls enable on an auth method');
assert.ok(spy.calledOnce, 'calls the passed success method'); assert.ok(spy.calledOnce, 'calls the passed success method');
}); });
test('it calls the correct jwt config', async function(assert) {
this.server.post('/v1/sys/auth/jwt', () => {
return [204, { 'Content-Type': 'application/json' }];
});
this.server.post('/v1/auth/jwt/config', () => {
return [
400,
{ 'Content-Type': 'application/json' },
JSON.stringify({ errors: ['there was an error'] }),
];
});
await render(hbs`<MountBackendForm />`);
await component.selectType('jwt');
await component.next();
await component.fillIn('oidcDiscoveryUrl', 'host');
component.submit();
later(() => run.cancelTimers(), 50);
await settled();
let configRequest = this.server.handledRequests.findBy('url', '/v1/auth/jwt/config');
assert.ok(configRequest, 'it calls the config url');
});
test('it calls mount config error', async function(assert) {
this.server.post('/v1/sys/auth/bar', () => {
return [204, { 'Content-Type': 'application/json' }];
});
this.server.post('/v1/auth/bar/config', () => {
return [
400,
{ 'Content-Type': 'application/json' },
JSON.stringify({ errors: ['there was an error'] }),
];
});
const spy = sinon.spy();
const spy2 = sinon.spy();
this.set('onMountSuccess', spy);
this.set('onConfigError', spy2);
await render(hbs`{{mount-backend-form onMountSuccess=onMountSuccess onConfigError=onConfigError}}`);
await component.selectType('kubernetes');
await component.next().path('bar');
// kubernetes requires a host + a cert / pem, so only filling the host will error
await component.fillIn('kubernetesHost', 'host');
component.submit();
later(() => run.cancelTimers(), 50);
await settled();
let enableRequest = this.server.handledRequests.findBy('url', '/v1/sys/auth/bar');
assert.ok(enableRequest, 'it calls enable on an auth method');
assert.equal(spy.callCount, 0, 'does not call the success method');
assert.ok(spy2.calledOnce, 'calls the passed error method');
});
}); });

View File

@@ -9,7 +9,7 @@ export default create({
toggleOptions: clickable('[data-test-toggle-group="Options"]'), toggleOptions: clickable('[data-test-toggle-group="Options"]'),
name: fillable('[data-test-input="name"]'), name: fillable('[data-test-input="name"]'),
allowAnyName: clickable('[data-test-input="allowAnyName"]'), allowAnyName: clickable('[data-test-input="allowAnyName"]'),
allowedDomains: fillable('[data-test-input="allowedDomains"]'), allowedDomains: fillable('[data-test-input="allowedDomains"] input'),
save: clickable('[data-test-role-create]'), save: clickable('[data-test-role-create]'),
deleteBtn: clickable('[data-test-role-delete] button'), deleteBtn: clickable('[data-test-role-delete] button'),
confirmBtn: clickable('[data-test-confirm-button]'), confirmBtn: clickable('[data-test-confirm-button]'),

View File

@@ -23,16 +23,16 @@ module('Unit | Machine | auth-machine', function() {
event: 'CONTINUE', event: 'CONTINUE',
params: null, params: null,
expectedResults: { expectedResults: {
value: 'list', value: 'config',
actions: [ actions: [
{ component: 'wizard/auth-list', level: 'step', type: 'render' }, { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' },
{ component: 'wizard/mounts-wizard', level: 'feature', type: 'render' }, { type: 'render', level: 'step', component: 'wizard/auth-config' },
], ],
}, },
}, },
{ {
currentState: 'list', currentState: 'config',
event: 'DETAILS', event: 'CONTINUE',
expectedResults: { expectedResults: {
value: 'details', value: 'details',
actions: [ actions: [

View File

@@ -0,0 +1,191 @@
import { expandOpenApiProps, combineAttributes, combineFieldGroups } from 'vault/utils/openapi-to-attrs';
import { module, test } from 'qunit';
import DS from 'ember-data';
const { attr } = DS;
module('Unit | Util | OpenAPI Data Utilities', function() {
const OPENAPI_RESPONSE_PROPS = {
ttl: {
type: 'string',
format: 'seconds',
'x-vault-displayName': 'TTL',
},
'awesome-people': {
type: 'array',
items: {
type: 'string',
},
'x-vault-displayValue': 'Grace Hopper,Lady Ada',
},
'favorite-ice-cream': {
type: 'string',
enum: ['vanilla', 'chocolate', 'strawberry'],
},
'default-value': {
default: 30,
'x-vault-displayValue': 300,
type: 'integer',
},
default: {
default: 30,
type: 'integer',
},
};
const EXPANDED_PROPS = {
ttl: {
editType: 'ttl',
type: 'string',
label: 'TTL',
},
awesomePeople: {
editType: 'stringArray',
type: 'array',
defaultValue: 'Grace Hopper,Lady Ada',
},
favoriteIceCream: {
editType: 'string',
type: 'string',
possibleValues: ['vanilla', 'chocolate', 'strawberry'],
},
defaultValue: {
editType: 'number',
type: 'number',
defaultValue: 300,
},
default: {
editType: 'number',
type: 'number',
defaultValue: 30,
},
};
const EXISTING_MODEL_ATTRS = [
{
key: 'name',
value: {
isAttribute: true,
name: 'name',
options: {
editType: 'string',
label: 'Role name',
},
},
},
{
key: 'awesomePeople',
value: {
isAttribute: true,
name: 'awesomePeople',
options: {
label: 'People Who Are Awesome',
},
},
},
];
const COMBINED_ATTRS = {
name: attr('string', {
editType: 'string',
type: 'string',
label: 'Role name',
}),
ttl: attr('string', {
editType: 'ttl',
type: 'string',
label: 'TTL',
}),
awesomePeople: attr({
label: 'People Who Are Awesome',
editType: 'stringArray',
type: 'array',
defaultValue: 'Grace Hopper,Lady Ada',
}),
favoriteIceCream: attr('string', {
type: 'string',
editType: 'string',
possibleValues: ['vanilla', 'chocolate', 'strawberry'],
}),
};
const NEW_FIELDS = ['one', 'two', 'three'];
test('it creates objects from OpenAPI schema props', function(assert) {
const generatedProps = expandOpenApiProps(OPENAPI_RESPONSE_PROPS);
for (let propName in EXPANDED_PROPS) {
assert.deepEqual(EXPANDED_PROPS[propName], generatedProps[propName], `correctly expands ${propName}`);
}
});
test('it combines OpenAPI props with existing model attrs', function(assert) {
const combined = combineAttributes(EXISTING_MODEL_ATTRS, EXPANDED_PROPS);
for (let propName in EXISTING_MODEL_ATTRS) {
assert.deepEqual(COMBINED_ATTRS[propName], combined[propName]);
}
});
test('it adds new fields from OpenAPI to fieldGroups except for exclusions', function(assert) {
let modelFieldGroups = [
{ default: ['name', 'awesomePeople'] },
{
Options: ['ttl'],
},
];
const excludedFields = ['two'];
const expectedGroups = [
{ default: ['name', 'awesomePeople', 'one', 'three'] },
{
Options: ['ttl'],
},
];
const newFieldGroups = combineFieldGroups(modelFieldGroups, NEW_FIELDS, excludedFields);
for (let groupName in modelFieldGroups) {
assert.deepEqual(
newFieldGroups[groupName],
expectedGroups[groupName],
'it incorporates all new fields except for those excluded'
);
}
});
test('it adds all new fields from OpenAPI to fieldGroups when excludedFields is empty', function(assert) {
let modelFieldGroups = [
{ default: ['name', 'awesomePeople'] },
{
Options: ['ttl'],
},
];
const excludedFields = [];
const expectedGroups = [
{ default: ['name', 'awesomePeople', 'one', 'two', 'three'] },
{
Options: ['ttl'],
},
];
const nonExcludedFieldGroups = combineFieldGroups(modelFieldGroups, NEW_FIELDS, excludedFields);
for (let groupName in modelFieldGroups) {
assert.deepEqual(
nonExcludedFieldGroups[groupName],
expectedGroups[groupName],
'it incorporates all new fields'
);
}
});
test('it keeps fields the same when there are no brand new fields from OpenAPI', function(assert) {
let modelFieldGroups = [
{ default: ['name', 'awesomePeople', 'two', 'one', 'three'] },
{
Options: ['ttl'],
},
];
const excludedFields = [];
const expectedGroups = [
{ default: ['name', 'awesomePeople', 'two', 'one', 'three'] },
{
Options: ['ttl'],
},
];
const fieldGroups = combineFieldGroups(modelFieldGroups, NEW_FIELDS, excludedFields);
for (let groupName in modelFieldGroups) {
assert.deepEqual(fieldGroups[groupName], expectedGroups[groupName], 'it incorporates all new fields');
}
});
});