UI Database Secrets Engine (MongoDB) (#10655)
* move the ttls on enable for db to default and not as options * refactor form field to angle brackets * add database to supported backend * initial setup of components and models * setup selectable cards, need to make own component * styling setup * subtext and links * number styling * search select put in place and button, all pretty things * search label text * messy but closer to data configuration. making models and fetching those models on routes * connection adapter and serializer that is pulled in by the overview route * clean up and add new model params connections and roles to overview route hbs * setting up overview as route with SecretHeader component. TODO, show Overview tab, but have link to route. It's going be on the secret header list component * setup overview tab on secret-list-header to go to overview page * setup id in overview route * Correct link on secrets engine list for database and others * Roles tab on database fetches correct model * Update options for backend with hasOverview param so overview tab is rendered conditionally on secret list header * create new getCrendentialsComponent * Rename database connection parent component and start working on display * setup routing to credentials route for database from overview page * setup network request for the credentials of role * setup serializer for credentials * redirect previous route * fix border color on button disable * add margin to back button * change to glimmer component * glimmerize and clean up the get-credentials-card * Begin database connection show and create form * add component test for the get-credentials-card * Database connection model and field groups * add static roles to searhSelect * add staticRoles on overview page * Toolbar and tabs on database connection show view looks correct * combine static and dynamic role models for pagination * Update database-list-item with real link to connection * Add support for optionalText edit type on form-field * handle situation when no static and/or dynamic roles * turn partial into component so can handle computed and eventually click actions, similar to transform * glimmerize database-list-item * use lazy capabilities on list role and static-role actions * Create connection works and redirects to show page * creds request based on dynamic or static and unload the store by record creds when they transition away. * dynamcially add in backend for queries * fixes on overview page for get credentials with hardcoded backend and layout for static creds * Rotate and Reset connection actions working on connection * get credentials set the query params * setup async for handling permission errors on overivew * Move query logic to store for getting both types of role * Filtering works on combined role models * cleanup * Fix no meta on connections list * better handle the situation where you don't have access to list roles but do to generate * implment updated empty state component and add to credentials page when roleType is noRoleType * glimmerize the input search component * move logic for generate credentials urlto the generate creds component * remove query param for role type * handle permissions on the overview page * permissions for role list * New roles route for backends * handle different permissions for empty return on 404 vs 403 on overview page * fix links on overview page * Connetions WIP * setup lazy caps for the connections model and list * add computed to role and static role models to clean up permissions * setup actions for connections list * Update form-field to show password type and update json input to angle bracket syntax with optional theme option * setup capabilities on overview for empty state * fix hardcoded on the backend * toggle inner label has width 100% * Add custom update password togglable input on database connection edit form, and only submit defined attrs * Add updateRecord to connection adapter * glimmerize secret list header and make new component which either shows or does not show the tab based on permissions * Remove tabs on show connection * add peek record * Update database role to get both models on a single model, remove static-role model and adapter, remove roles route * fix creds permissions on database-list-item * add component info and rename for secret-list-header-tab * fix issues on overview page * Add path to individual role on serializer * add accetpance test for testing the engine * fix transform test * test fix * Update connection before role created, disable button with tooltip if user cannot update path * Add add-to-array and remove-from-array helpers with tests * Clean up connection update on delete or create role, cleanup logs, role create link works * Database role create and edit forms with readonly fields and validation. Add readonly-form-field * Add field div around ttl picker for correct spacing on form-field * fix the breadcrumbs * PLaceholder test for readonly form field * create new helper to format time duration * tooltip and formatting on static role * more on static roles time stuff * clean up * clean up * fixes on the test and addition of another helper test * fix secrets machine test * Add modal to connection creation flow * fix issue with readonly form field test * Add is-empty-object helper and tests * Role error handling * Remove Atlas option from connection list, add defaults to db role form * clean up stuff though might have made it uglier * clean up * Add capabilities checks on connection actions * Fix jsdocs on readonly-form-field * Fix json editor height on form field * Readonly form has notallowed cursor, readonly form field updates * Add blank field rendering to info-table-row * Start writing readonly form field tests * Address some PR comments * fix fallback action on search select * cleanup per comments * fix readonly form field test and lint * Cleanup string helpers * Replace renderBlank with alwaysRender logic * re-humanize label on readonly form field * Show defaultShown value on info-table-row if no value and always render * Show default on role and connection show table * Add changelog Co-authored-by: Chelsea Shaw <chelshaw.dev@gmail.com>
This commit is contained in:
parent
52845525e9
commit
59e83e2e6d
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
ui: Database secrets engine, supporting MongoDB only
|
||||
```
|
|
@ -0,0 +1,76 @@
|
|||
import ApplicationAdapter from '../application';
|
||||
|
||||
export default ApplicationAdapter.extend({
|
||||
namespace: 'v1',
|
||||
|
||||
urlFor(backend, id, type = '') {
|
||||
if (type === 'ROTATE') {
|
||||
return `${this.buildURL()}/${backend}/rotate-root/${id}`;
|
||||
} else if (type === 'RESET') {
|
||||
return `${this.buildURL()}/${backend}/reset/${id}`;
|
||||
}
|
||||
let url = `${this.buildURL()}/${backend}/config`;
|
||||
if (id) {
|
||||
url = `${this.buildURL()}/${backend}/config/${id}`;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
optionsForQuery(id) {
|
||||
let data = {};
|
||||
if (!id) {
|
||||
data['list'] = true;
|
||||
}
|
||||
return { data };
|
||||
},
|
||||
fetchByQuery(store, query) {
|
||||
const { backend, id } = query;
|
||||
return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id)).then(resp => {
|
||||
resp.backend = backend;
|
||||
if (id) {
|
||||
resp.id = id;
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
},
|
||||
query(store, type, query) {
|
||||
return this.fetchByQuery(store, query);
|
||||
},
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
return this.fetchByQuery(store, query);
|
||||
},
|
||||
|
||||
createRecord(store, type, snapshot) {
|
||||
const serializer = store.serializerFor(type.modelName);
|
||||
const data = serializer.serialize(snapshot);
|
||||
const id = snapshot.attr('name');
|
||||
const backend = snapshot.attr('backend');
|
||||
|
||||
return this.ajax(this.urlFor(backend, id), 'POST', { data }).then(() => {
|
||||
// ember data doesn't like 204s if it's not a DELETE
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateRecord() {
|
||||
return this.createRecord(...arguments);
|
||||
},
|
||||
|
||||
deleteRecord(store, type, snapshot) {
|
||||
const id = snapshot.id;
|
||||
return this.ajax(this.urlFor('database', id), 'DELETE');
|
||||
},
|
||||
|
||||
rotateRootCredentials(backend, id) {
|
||||
return this.ajax(this.urlFor('database', id, 'ROTATE'), 'POST');
|
||||
},
|
||||
|
||||
resetConnection(backend, id) {
|
||||
return this.ajax(this.urlFor(backend, id, 'RESET'), 'POST');
|
||||
},
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import ApplicationAdapter from '../application';
|
||||
|
||||
export default ApplicationAdapter.extend({
|
||||
namespace: 'v1',
|
||||
|
||||
fetchByQuery(store, query) {
|
||||
const { backend, roleType, secret } = query;
|
||||
let creds = roleType === 'static' ? 'static-creds' : 'creds';
|
||||
return this.ajax(
|
||||
`${this.buildURL()}/${encodeURIComponent(backend)}/${creds}/${encodeURIComponent(secret)}`,
|
||||
'GET'
|
||||
);
|
||||
},
|
||||
queryRecord(store, type, query) {
|
||||
return this.fetchByQuery(store, query);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,168 @@
|
|||
import { assign } from '@ember/polyfills';
|
||||
import { assert } from '@ember/debug';
|
||||
import ApplicationAdapter from '../application';
|
||||
import { allSettled } from 'rsvp';
|
||||
import { addToArray } from 'vault/helpers/add-to-array';
|
||||
import { removeFromArray } from 'vault/helpers/remove-from-array';
|
||||
|
||||
export default ApplicationAdapter.extend({
|
||||
namespace: 'v1',
|
||||
pathForType() {
|
||||
assert('Generate the url dynamically based on role type', false);
|
||||
},
|
||||
|
||||
urlFor(backend, id, type = 'dynamic') {
|
||||
let role = 'roles';
|
||||
if (type === 'static') {
|
||||
role = 'static-roles';
|
||||
}
|
||||
let url = `${this.buildURL()}/${backend}/${role}`;
|
||||
if (id) {
|
||||
url = `${this.buildURL()}/${backend}/${role}/${id}`;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
|
||||
staticRoles(backend, id) {
|
||||
return this.ajax(this.urlFor(backend, id, 'static'), 'GET', this.optionsForQuery(id));
|
||||
},
|
||||
|
||||
dynamicRoles(backend, id) {
|
||||
return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id));
|
||||
},
|
||||
|
||||
optionsForQuery(id) {
|
||||
let data = {};
|
||||
if (!id) {
|
||||
data['list'] = true;
|
||||
}
|
||||
return { data };
|
||||
},
|
||||
|
||||
fetchByQuery(store, query) {
|
||||
const { backend, id } = query;
|
||||
return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id)).then(resp => {
|
||||
resp.id = id;
|
||||
resp.backend = backend;
|
||||
return resp;
|
||||
});
|
||||
},
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const { backend, id } = query;
|
||||
const staticReq = this.staticRoles(backend, id);
|
||||
const dynamicReq = this.dynamicRoles(backend, id);
|
||||
|
||||
return allSettled([staticReq, dynamicReq]).then(([staticResp, dynamicResp]) => {
|
||||
if (!staticResp.value && !dynamicResp.value) {
|
||||
// Throw error, both reqs failed
|
||||
throw dynamicResp.reason;
|
||||
}
|
||||
// Names are distinct across both types of role,
|
||||
// so only one request should ever come back with value
|
||||
let type = staticResp.value ? 'static' : 'dynamic';
|
||||
let successful = staticResp.value || dynamicResp.value;
|
||||
let resp = {
|
||||
data: {},
|
||||
backend,
|
||||
secret: id,
|
||||
};
|
||||
|
||||
resp.data = assign({}, resp.data, successful.data, { backend, type, secret: id });
|
||||
|
||||
return resp;
|
||||
});
|
||||
},
|
||||
|
||||
query(store, type, query) {
|
||||
const { backend } = query;
|
||||
const staticReq = this.staticRoles(backend);
|
||||
const dynamicReq = this.dynamicRoles(backend);
|
||||
|
||||
return allSettled([staticReq, dynamicReq]).then(([staticResp, dynamicResp]) => {
|
||||
let resp = {
|
||||
backend,
|
||||
data: { keys: [] },
|
||||
};
|
||||
|
||||
if (staticResp.reason && dynamicResp.reason) {
|
||||
// both failed, throw error
|
||||
throw dynamicResp.reason;
|
||||
}
|
||||
// at least one request has data
|
||||
let staticRoles = [];
|
||||
let dynamicRoles = [];
|
||||
|
||||
if (staticResp.value) {
|
||||
staticRoles = staticResp.value.data.keys;
|
||||
}
|
||||
if (dynamicResp.value) {
|
||||
dynamicRoles = dynamicResp.value.data.keys;
|
||||
}
|
||||
|
||||
resp.data = assign(
|
||||
{},
|
||||
resp.data,
|
||||
{ keys: [...staticRoles, ...dynamicRoles] },
|
||||
{ backend },
|
||||
{ staticRoles, dynamicRoles }
|
||||
);
|
||||
|
||||
return resp;
|
||||
});
|
||||
},
|
||||
|
||||
async _updateAllowedRoles(store, { role, backend, db, type = 'add' }) {
|
||||
const connection = await store.queryRecord('database/connection', { backend, id: db });
|
||||
let roles = [...connection.allowed_roles];
|
||||
const allowedRoles = type === 'add' ? addToArray([roles, role]) : removeFromArray([roles, role]);
|
||||
connection.allowed_roles = allowedRoles;
|
||||
return connection.save();
|
||||
},
|
||||
|
||||
async createRecord(store, type, snapshot) {
|
||||
const serializer = store.serializerFor(type.modelName);
|
||||
const data = serializer.serialize(snapshot);
|
||||
const roleType = snapshot.attr('type');
|
||||
const backend = snapshot.attr('backend');
|
||||
const id = snapshot.attr('name');
|
||||
const db = snapshot.attr('database');
|
||||
await this._updateAllowedRoles(store, {
|
||||
role: id,
|
||||
backend,
|
||||
db: db[0],
|
||||
});
|
||||
|
||||
return this.ajax(this.urlFor(backend, id, roleType), 'POST', { data }).then(() => {
|
||||
// ember data doesn't like 204s if it's not a DELETE
|
||||
return {
|
||||
data: assign({}, data, { id }),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async deleteRecord(store, type, snapshot) {
|
||||
const roleType = snapshot.attr('type');
|
||||
const backend = snapshot.attr('backend');
|
||||
const id = snapshot.attr('name');
|
||||
const db = snapshot.attr('database');
|
||||
await this._updateAllowedRoles(store, {
|
||||
role: id,
|
||||
backend,
|
||||
db: db[0],
|
||||
type: 'remove',
|
||||
});
|
||||
|
||||
return this.ajax(this.urlFor(backend, id, roleType), 'DELETE');
|
||||
},
|
||||
|
||||
async updateRecord(store, type, snapshot) {
|
||||
const serializer = store.serializerFor(type.modelName);
|
||||
const data = serializer.serialize(snapshot);
|
||||
const roleType = snapshot.attr('type');
|
||||
const backend = snapshot.attr('backend');
|
||||
const id = snapshot.attr('name');
|
||||
|
||||
return this.ajax(this.urlFor(backend, id, roleType), 'POST', { data }).then(() => data);
|
||||
},
|
||||
});
|
|
@ -7,6 +7,7 @@ export default ApplicationAdapter.extend({
|
|||
urlForItem() {},
|
||||
dynamicApiPath: '',
|
||||
getDynamicApiPath: task(function*(id) {
|
||||
// TODO: remove yield at some point.
|
||||
let result = yield this.store.peekRecord('auth-method', id);
|
||||
this.dynamicApiPath = result.apiPath;
|
||||
return;
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
|
||||
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
||||
|
||||
const getErrorMessage = errors => {
|
||||
let errorMessage = errors?.join('. ') || 'Something went wrong. Check the Vault logs for more information.';
|
||||
if (errors?.join(' ').indexOf('failed to verify')) {
|
||||
errorMessage =
|
||||
'There was a verification error for this connection. Check the Vault logs for more information.';
|
||||
}
|
||||
return errorMessage;
|
||||
};
|
||||
|
||||
export default class DatabaseConnectionEdit extends Component {
|
||||
@service store;
|
||||
@service router;
|
||||
@service flashMessages;
|
||||
|
||||
@tracked
|
||||
showPasswordField = false; // used for edit mode
|
||||
|
||||
@tracked
|
||||
showSaveModal = false; // used for create mode
|
||||
|
||||
rotateCredentials(backend, name) {
|
||||
let adapter = this.store.adapterFor('database/connection');
|
||||
return adapter.rotateRootCredentials(backend, name);
|
||||
}
|
||||
|
||||
transitionToRoute() {
|
||||
return this.router.transitionTo(...arguments);
|
||||
}
|
||||
|
||||
@action
|
||||
updateShowPassword(showForm) {
|
||||
this.showPasswordField = showForm;
|
||||
if (!showForm) {
|
||||
// unset password if hidden
|
||||
this.args.model.password = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
updatePassword(attr, evt) {
|
||||
const value = evt.target.value;
|
||||
this.args.model[attr] = value;
|
||||
}
|
||||
|
||||
@action
|
||||
async handleCreateConnection(evt) {
|
||||
evt.preventDefault();
|
||||
let secret = this.args.model;
|
||||
let secretId = secret.name;
|
||||
secret.set('id', secretId);
|
||||
secret
|
||||
.save()
|
||||
.then(() => {
|
||||
this.showSaveModal = true;
|
||||
})
|
||||
.catch(e => {
|
||||
const errorMessage = getErrorMessage(e.errors);
|
||||
this.flashMessages.danger(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
continueWithoutRotate(evt) {
|
||||
evt.preventDefault();
|
||||
const { name } = this.args.model;
|
||||
this.transitionToRoute(SHOW_ROUTE, name);
|
||||
}
|
||||
|
||||
@action
|
||||
continueWithRotate(evt) {
|
||||
evt.preventDefault();
|
||||
const { backend, name } = this.args.model;
|
||||
this.rotateCredentials(backend, name)
|
||||
.then(() => {
|
||||
this.flashMessages.success(`Successfully rotated root credentials for connection "${name}"`);
|
||||
this.transitionToRoute(SHOW_ROUTE, name);
|
||||
})
|
||||
.catch(e => {
|
||||
this.flashMessages.danger(`Error rotating root credentials: ${e.errors}`);
|
||||
this.transitionToRoute(SHOW_ROUTE, name);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
handleUpdateConnection(evt) {
|
||||
evt.preventDefault();
|
||||
let secret = this.args.model;
|
||||
let secretId = secret.name;
|
||||
secret.save().then(() => {
|
||||
this.transitionToRoute(SHOW_ROUTE, secretId);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
delete(evt) {
|
||||
evt.preventDefault();
|
||||
const secret = this.args.model;
|
||||
const backend = secret.backend;
|
||||
secret.destroyRecord().then(() => {
|
||||
this.transitionToRoute(LIST_ROOT_ROUTE, backend);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
reset() {
|
||||
const { name, backend } = this.args.model;
|
||||
let adapter = this.store.adapterFor('database/connection');
|
||||
adapter
|
||||
.resetConnection(backend, name)
|
||||
.then(() => {
|
||||
// TODO: Why isn't the confirmAction closing?
|
||||
this.flashMessages.success('Successfully reset connection');
|
||||
})
|
||||
.catch(e => {
|
||||
const errorMessage = getErrorMessage(e.errors);
|
||||
this.flashMessages.danger(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
rotate() {
|
||||
const { name, backend } = this.args.model;
|
||||
this.rotateCredentials(backend, name)
|
||||
.then(() => {
|
||||
// TODO: Why isn't the confirmAction closing?
|
||||
this.flashMessages.success('Successfully rotated credentials');
|
||||
})
|
||||
.catch(e => {
|
||||
const errorMessage = getErrorMessage(e.errors);
|
||||
this.flashMessages.danger(errorMessage);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @module DatabaseListItem
|
||||
* DatabaseListItem components are used for the list items for the Database Secret Engines for Roles.
|
||||
* This component automatically handles read-only list items if capabilities are not granted or the item is internal only.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <DatabaseListItem @item={item} />
|
||||
* ```
|
||||
* @param {object} item - item refers to the model item used on the list item partial
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class DatabaseListItem extends Component {
|
||||
@tracked roleType = '';
|
||||
@service store;
|
||||
@service flashMessages;
|
||||
|
||||
get keyTypeValue() {
|
||||
const item = this.args.item;
|
||||
// basing this on path in case we want to remove 'type' later
|
||||
if (item.path === 'roles') {
|
||||
return 'dynamic';
|
||||
} else if (item.path === 'static-roles') {
|
||||
return 'static';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
resetConnection(id) {
|
||||
const { backend } = this.args.item;
|
||||
let adapter = this.store.adapterFor('database/connection');
|
||||
adapter
|
||||
.resetConnection(backend, id)
|
||||
.then(() => {
|
||||
this.flashMessages.success(`Success: ${id} connection was reset`);
|
||||
})
|
||||
.catch(e => {
|
||||
this.flashMessages.danger(e.errors);
|
||||
});
|
||||
}
|
||||
@action
|
||||
rotateRootCred(id) {
|
||||
const { backend } = this.args.item;
|
||||
let adapter = this.store.adapterFor('database/connection');
|
||||
adapter
|
||||
.rotateRootCredentials(backend, id)
|
||||
.then(() => {
|
||||
this.flashMessages.success(`Success: ${id} connection was reset`);
|
||||
})
|
||||
.catch(e => {
|
||||
this.flashMessages.danger(e.errors);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
|
||||
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
||||
|
||||
export default class DatabaseRoleEdit extends Component {
|
||||
@service router;
|
||||
@service flashMessages;
|
||||
|
||||
get warningMessages() {
|
||||
let warnings = {};
|
||||
if (this.args.model.canUpdateDb === false) {
|
||||
warnings.database = `You don’t have permissions to update this database connection, so this role cannot be created.`;
|
||||
}
|
||||
if (
|
||||
(this.args.model.type === 'dynamic' && this.args.model.canCreateDynamic === false) ||
|
||||
(this.args.model.type === 'static' && this.args.model.canCreateStatic === false)
|
||||
) {
|
||||
warnings.type = `You don't have permissions to create this type of role.`;
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
get databaseType() {
|
||||
if (this.args.model?.database) {
|
||||
// TODO: Calculate this
|
||||
return 'mongodb-database-plugin';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@action
|
||||
generateCreds(roleId) {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.credentials', roleId);
|
||||
}
|
||||
|
||||
@action
|
||||
delete() {
|
||||
const secret = this.args.model;
|
||||
const backend = secret.backend;
|
||||
secret
|
||||
.destroyRecord()
|
||||
.then(() => {
|
||||
this.router.transitionTo(LIST_ROOT_ROUTE, backend, { queryParams: { tab: 'role' } });
|
||||
})
|
||||
.catch(e => {
|
||||
this.flashMessages.danger(e.errors?.join('. '));
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
handleCreateRole(evt) {
|
||||
evt.preventDefault();
|
||||
let roleSecret = this.args.model;
|
||||
let secretId = roleSecret.name;
|
||||
roleSecret.set('id', secretId);
|
||||
let path = roleSecret.type === 'static' ? 'static-roles' : 'roles';
|
||||
roleSecret.set('path', path);
|
||||
roleSecret.save().then(() => {
|
||||
this.router.transitionTo(SHOW_ROUTE, `role/${secretId}`);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
handleCreateEditRole(evt) {
|
||||
evt.preventDefault();
|
||||
const mode = this.args.mode;
|
||||
let roleSecret = this.args.model;
|
||||
let secretId = roleSecret.name;
|
||||
if (mode === 'create') {
|
||||
roleSecret.set('id', secretId);
|
||||
let path = roleSecret.type === 'static' ? 'static-roles' : 'roles';
|
||||
roleSecret.set('path', path);
|
||||
}
|
||||
roleSecret.save().then(() => {
|
||||
this.router.transitionTo(SHOW_ROUTE, `role/${secretId}`);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @module DatabaseRoleSettingForm
|
||||
* DatabaseRoleSettingForm components are used to handle the role settings section on the database/role form
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <DatabaseRoleSettingForm @requiredParam={requiredParam} @optionalParam={optionalParam} @param1={{param1}}/>
|
||||
* ```
|
||||
* @param {Array<object>} attrs - all available attrs from the model to iterate over
|
||||
* @param {object} model - ember data model which should be updated on change
|
||||
* @param {string} [roleType] - role type controls which attributes are shown
|
||||
* @param {string} [mode=create] - mode of the form (eg. create or edit)
|
||||
* @param {string} [dbType=default] - type of database, eg 'mongodb-database-plugin'
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
// Below fields are intended to be dynamic based on type of role and db.
|
||||
// example of usage: FIELDS[roleType][db]
|
||||
const ROLE_FIELDS = {
|
||||
static: {
|
||||
default: ['ttl', 'max_ttl', 'username', 'rotation_period'],
|
||||
'mongodb-database-plugin': ['username', 'rotation_period'],
|
||||
},
|
||||
dynamic: {
|
||||
default: ['ttl', 'max_ttl', 'username', 'rotation_period'],
|
||||
'mongodb-database-plugin': ['ttl', 'max_ttl'],
|
||||
},
|
||||
};
|
||||
|
||||
const STATEMENT_FIELDS = {
|
||||
static: {
|
||||
default: ['creation_statements', 'revocation_statements', 'rotation_statements'],
|
||||
'mongodb-database-plugin': 'NONE', // will not show the section
|
||||
},
|
||||
dynamic: {
|
||||
default: ['creation_statements', 'revocation_statements', 'rotation_statements'],
|
||||
'mongodb-database-plugin': ['creation_statement'],
|
||||
},
|
||||
};
|
||||
|
||||
export default class DatabaseRoleSettingForm extends Component {
|
||||
get settingFields() {
|
||||
const type = this.args.roleType;
|
||||
if (!type) return null;
|
||||
const db = this.args.dbType || 'default';
|
||||
const fields = ROLE_FIELDS[type][db];
|
||||
if (!Array.isArray(fields)) return fields;
|
||||
const filtered = this.args.attrs.filter(a => {
|
||||
const includes = fields.includes(a.name);
|
||||
return includes;
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
get statementFields() {
|
||||
const type = this.args.roleType;
|
||||
if (!type) return null;
|
||||
const db = this.args.dbType || 'default';
|
||||
const fields = STATEMENT_FIELDS[type][db];
|
||||
if (!Array.isArray(fields)) return fields;
|
||||
const filtered = this.args.attrs.filter(a => {
|
||||
const includes = fields.includes(a.name);
|
||||
return includes;
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* @module GenerateCredentialsDatabase
|
||||
* GenerateCredentialsDatabase component is used on the credentials route for the Database metrics.
|
||||
* The component assumes that you will need to make an ajax request using queryRecord to return a model for the component that has username, password, leaseId and leaseDuration
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <GenerateCredentialsDatabase @backendPath="database" @backendType="database" @roleName="my-role"/>
|
||||
* ```
|
||||
* @param {string} backendPath - the secret backend name. This is used in the breadcrumb.
|
||||
* @param {object} backendType - the secret type. Expected to be database.
|
||||
* @param {string} roleName - the id of the credential returning.
|
||||
*/
|
||||
|
||||
import { inject as service } from '@ember/service';
|
||||
import Component from '@glimmer/component';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class GenerateCredentialsDatabase extends Component {
|
||||
@service store;
|
||||
// set on the component
|
||||
backendType = null;
|
||||
backendPath = null;
|
||||
roleName = null;
|
||||
@tracked roleType = '';
|
||||
@tracked model = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.fetchCredentials.perform();
|
||||
}
|
||||
|
||||
@task(function*() {
|
||||
let { roleName, backendPath } = this.args;
|
||||
let errors = [];
|
||||
try {
|
||||
let newModel = yield this.store.queryRecord('database/credential', {
|
||||
backend: backendPath,
|
||||
secret: roleName,
|
||||
roleType: 'static',
|
||||
});
|
||||
// if successful will return result
|
||||
this.model = newModel;
|
||||
this.roleType = 'static';
|
||||
return;
|
||||
} catch (error) {
|
||||
errors.push(error.errors);
|
||||
}
|
||||
try {
|
||||
let newModel = yield this.store.queryRecord('database/credential', {
|
||||
backend: backendPath,
|
||||
secret: roleName,
|
||||
roleType: 'dynamic',
|
||||
});
|
||||
this.model = newModel;
|
||||
this.roleType = 'dynamic';
|
||||
return;
|
||||
} catch (error) {
|
||||
errors.push(error.errors);
|
||||
}
|
||||
this.roleType = 'noRoleFound';
|
||||
})
|
||||
fetchCredentials;
|
||||
|
||||
@action redirectPreviousPage() {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* @module GetCredentialsCard
|
||||
* GetCredentialsCard components are card-like components that display a title, and SearchSelect component that sends you to a credentials route for the selected item.
|
||||
* They are designed to be used in containers that act as flexbox or css grid containers.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <GetCredentialsCard @title="Get Credentials" @searchLabel="Role to use" @models={{array 'database/roles'}} />
|
||||
* ```
|
||||
* @param title=null {String} - The title displays the card title
|
||||
* @param searchLabel=null {String} - The text above the searchSelect component
|
||||
* @param models=null {Array} - An array of model types to fetch from the API. Passed through to SearchSelect component
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
export default class GetCredentialsCard extends Component {
|
||||
@service router;
|
||||
@service store;
|
||||
@tracked role = '';
|
||||
|
||||
@action
|
||||
async transitionToCredential() {
|
||||
const role = this.role;
|
||||
if (role) {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.credentials', role);
|
||||
}
|
||||
}
|
||||
|
||||
get buttonDisabled() {
|
||||
return !this.role;
|
||||
}
|
||||
@action
|
||||
handleRoleInput(value) {
|
||||
// if it comes in from the fallback component then the value is a string otherwise it's an array
|
||||
let role = value;
|
||||
if (Array.isArray(value)) {
|
||||
role = value[0];
|
||||
}
|
||||
this.role = role;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class inputSelect extends Component {
|
||||
/*
|
||||
* @public
|
||||
* @param Function
|
||||
*
|
||||
* Function called when any of the inputs change
|
||||
*
|
||||
*/
|
||||
@tracked searchInput = '';
|
||||
@action
|
||||
inputChanged() {
|
||||
this.args.onChange(this.searchInput);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* @module SecretListHeaderTab
|
||||
* SecretListHeaderTab component passes in properties that are used to check capabilities and either display or not display the component.
|
||||
* Use case was first for the Database Secret Engine, but should be used in future iterations as we don't generally want to show things the user does not
|
||||
* have access to
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <SecretListHeaderTab @displayName='Database' @id='database-2' @path='roles' @label='Roles' @tab='roles'/>
|
||||
* ```
|
||||
* @param {string} [displayName] - set on options-for-backend this sets a conditional to see if capabilities are being checked
|
||||
* @param {string} [id] - if fetching capabilities used for making the query url. It is the name the user has assigned to the instance of the engine.
|
||||
* @param {string} [path] - set on options-for-backend this tells us the specifics of the URL the query should hit.
|
||||
* @param {string} label - The name displayed on the tab. Set on the options-for-backend.
|
||||
* @param {string} [tab] - The name of the tab. Set on the options-for-backend.
|
||||
*
|
||||
*/
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class SecretListHeaderTab extends Component {
|
||||
@service store;
|
||||
@tracked dontShowTab;
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.fetchCapabilities();
|
||||
}
|
||||
|
||||
pathQuery(backend, path) {
|
||||
return {
|
||||
id: `${backend}/${path}/`,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchCapabilities() {
|
||||
let capabilitiesArray = ['canList', 'canCreate', 'canUpdate'];
|
||||
let checkCapabilities = function(object) {
|
||||
let array = [];
|
||||
// we only want to look at the canList, canCreate and canUpdate on the capabilities record
|
||||
capabilitiesArray.forEach(item => {
|
||||
array.push(object[item]);
|
||||
});
|
||||
return array;
|
||||
};
|
||||
let checker = arr => arr.every(item => !item); // same things as listing every item as !item && !item, etc.
|
||||
// For now only check capabilities for the Database Secrets Engine
|
||||
if (this.args.displayName === 'Database') {
|
||||
let peekRecordRoles = this.store.peekRecord('capabilities', 'database/roles/');
|
||||
let peekRecordStaticRoles = this.store.peekRecord('capabilities', 'database/static-roles/');
|
||||
let peekRecordConnections = this.store.peekRecord('capabilities', 'database/config/');
|
||||
// peekRecord if the capabilities store data is there for the connections (config) and roles model
|
||||
if (
|
||||
(peekRecordRoles && this.args.path === 'roles') ||
|
||||
(peekRecordStaticRoles && this.args.path === 'roles')
|
||||
) {
|
||||
let roles = checker(checkCapabilities(peekRecordRoles));
|
||||
let staticRoles = checker(checkCapabilities(peekRecordStaticRoles));
|
||||
|
||||
this.dontShowTab = roles && staticRoles;
|
||||
return;
|
||||
}
|
||||
if (peekRecordConnections && this.args.path === 'config') {
|
||||
this.dontShowTab = checker(checkCapabilities(peekRecordConnections));
|
||||
return;
|
||||
}
|
||||
// otherwise queryRecord and create an instance on the capabilities.
|
||||
let response = await this.store.queryRecord(
|
||||
'capabilities',
|
||||
this.pathQuery(this.args.id, this.args.path)
|
||||
);
|
||||
this.dontShowTab = checker(checkCapabilities(response));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,11 @@
|
|||
import Component from '@ember/component';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: '',
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export default class SecretListHeader extends Component {
|
||||
// api
|
||||
isCertTab: false,
|
||||
isConfigure: false,
|
||||
baseKey: null,
|
||||
backendCrumb: null,
|
||||
model: null,
|
||||
options: null,
|
||||
});
|
||||
isCertTab = false;
|
||||
isConfigure = false;
|
||||
baseKey = null;
|
||||
backendCrumb = null;
|
||||
model = null;
|
||||
options = null;
|
||||
}
|
||||
|
|
|
@ -12,12 +12,16 @@ import { computed } from '@ember/object';
|
|||
* @param cardTitle=null {String} - cardTitle displays the card title
|
||||
* @param total=0 {Number} - the Total number displays like a title, it's the largest text in the component
|
||||
* @param subText=null {String} - subText describes the total
|
||||
* @param actionCard=false {Boolean} - false default selectable card container used in metrics, true a card that focus on actions as seen in database secret engine overview
|
||||
* @param actionText=null {String} - that action that happens in an actionCard
|
||||
*/
|
||||
|
||||
export default Component.extend({
|
||||
cardTitle: '',
|
||||
total: 0,
|
||||
subText: '',
|
||||
actionCard: false,
|
||||
actionText: '',
|
||||
gridContainer: false,
|
||||
tagName: '', // do not wrap component with div
|
||||
formattedCardTitle: computed('total', function() {
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { helper as buildHelper } from '@ember/component/helper';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
function dedupe(items) {
|
||||
return items.filter((v, i) => items.indexOf(v) === i);
|
||||
}
|
||||
|
||||
export function addToArray([array, string]) {
|
||||
if (!Array.isArray(array)) {
|
||||
assert(`Value provided is not an array`, false);
|
||||
}
|
||||
const newArray = [...array];
|
||||
newArray.push(string);
|
||||
return dedupe(newArray);
|
||||
}
|
||||
|
||||
export default buildHelper(addToArray);
|
|
@ -0,0 +1,16 @@
|
|||
import { helper } from '@ember/component/helper';
|
||||
import { assert } from '@ember/debug';
|
||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||
|
||||
export function duration([time]) {
|
||||
// intervalToDuration creates a durationObject that turns the seconds (ex 3600) to respective:
|
||||
// { years: 0, months: 0, days: 0, hours: 1, minutes: 0, seconds: 0 }
|
||||
// then formatDuration returns the filled in keys of the durationObject
|
||||
|
||||
// time must be in seconds
|
||||
let duration = Number.parseInt(time, 10);
|
||||
assert('could not parse time', !isNaN(duration));
|
||||
return formatDuration(intervalToDuration({ start: 0, end: duration * 1000 }));
|
||||
}
|
||||
|
||||
export default helper(duration);
|
|
@ -0,0 +1,5 @@
|
|||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export default helper(function isEmptyObject([object] /*, hash*/) {
|
||||
return Object.keys(object).length === 0;
|
||||
});
|
|
@ -55,6 +55,34 @@ const SECRET_BACKENDS = {
|
|||
editComponent: 'role-ssh-edit',
|
||||
listItemPartial: 'partials/secret-list/ssh-role-item',
|
||||
},
|
||||
database: {
|
||||
displayName: 'Database',
|
||||
navigateTree: false,
|
||||
listItemPartial: 'partials/secret-list/database-list-item',
|
||||
hasOverview: true,
|
||||
tabs: [
|
||||
{
|
||||
name: 'connection',
|
||||
label: 'Connections',
|
||||
searchPlaceholder: 'Filter connections',
|
||||
item: 'connection',
|
||||
create: 'Create connection',
|
||||
editComponent: 'database-connection',
|
||||
checkCapabilitiesPath: 'config',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
modelPrefix: 'role/',
|
||||
label: 'Roles',
|
||||
searchPlaceholder: 'Filter roles',
|
||||
item: 'role',
|
||||
create: 'Create role',
|
||||
tab: 'role',
|
||||
editComponent: 'database-role-edit',
|
||||
checkCapabilitiesPath: 'roles',
|
||||
},
|
||||
],
|
||||
},
|
||||
transform: {
|
||||
displayName: 'Transformation',
|
||||
navigateTree: false,
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { helper as buildHelper } from '@ember/component/helper';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
function dedupe(items) {
|
||||
return items.filter((v, i) => items.indexOf(v) === i);
|
||||
}
|
||||
|
||||
export function removeFromArray([array, string]) {
|
||||
if (!Array.isArray(array)) {
|
||||
assert(`Value provided is not an array`, false);
|
||||
}
|
||||
const newArray = [...array];
|
||||
const idx = newArray.indexOf(string);
|
||||
if (idx >= 0) {
|
||||
newArray.splice(idx, 1);
|
||||
}
|
||||
return dedupe(newArray);
|
||||
}
|
||||
|
||||
export default buildHelper(removeFromArray);
|
|
@ -2,6 +2,7 @@ import { helper as buildHelper } from '@ember/component/helper';
|
|||
|
||||
const SUPPORTED_SECRET_BACKENDS = [
|
||||
'aws',
|
||||
'database',
|
||||
'cubbyhole',
|
||||
'generic',
|
||||
'kv',
|
||||
|
|
|
@ -9,9 +9,9 @@ import { isArray } from '@ember/array';
|
|||
*
|
||||
* @param modelClass = An Ember Data model class
|
||||
* @param capabilities - an Object whose keys will added to the model class as related 'capabilities' models
|
||||
* and whose values should be functions that return the id of the related capabilites model
|
||||
* and whose values should be functions that return the id of the related capabilities model
|
||||
*
|
||||
* definition of capabilities be done shorthand with the apiPath tagged template funtion
|
||||
* definition of capabilities be done shorthand with the apiPath tagged template function
|
||||
*
|
||||
*
|
||||
* @usage
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
import Model, { attr } from '@ember-data/model';
|
||||
import { computed } from '@ember/object';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
||||
|
||||
const AVAILABLE_PLUGIN_TYPES = [
|
||||
{
|
||||
value: 'mongodb-database-plugin',
|
||||
displayName: 'MongoDB',
|
||||
fields: [
|
||||
{ attr: 'name' },
|
||||
{ attr: 'plugin_name' },
|
||||
{ attr: 'password_policy' },
|
||||
{ attr: 'username', group: 'pluginConfig' },
|
||||
{ attr: 'password', group: 'pluginConfig' },
|
||||
{ attr: 'connection_url', group: 'pluginConfig' },
|
||||
{ attr: 'write_concern' },
|
||||
{ attr: 'creation_statements' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default Model.extend({
|
||||
backend: attr('string', {
|
||||
readOnly: true,
|
||||
}),
|
||||
name: attr('string', {
|
||||
label: 'Connection Name',
|
||||
}),
|
||||
plugin_name: attr('string', {
|
||||
label: 'Database plugin',
|
||||
possibleValues: AVAILABLE_PLUGIN_TYPES,
|
||||
}),
|
||||
verify_connection: attr('boolean', {
|
||||
defaultValue: true,
|
||||
}),
|
||||
allowed_roles: attr('array', {
|
||||
readOnly: true,
|
||||
}),
|
||||
|
||||
password_policy: attr('string', {
|
||||
editType: 'optionalText',
|
||||
subText:
|
||||
'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.',
|
||||
}),
|
||||
|
||||
hosts: attr('string', {}),
|
||||
host: attr('string', {}),
|
||||
url: attr('string', {}),
|
||||
port: attr('string', {}),
|
||||
// connection_details
|
||||
username: attr('string', {}),
|
||||
password: attr('string', {
|
||||
editType: 'password',
|
||||
}),
|
||||
connection_url: attr('string', {
|
||||
subText:
|
||||
'The connection string used to connect to the database. This allows for simple templating of username and password of the root user.',
|
||||
}),
|
||||
|
||||
write_concern: attr('string', {
|
||||
subText: 'Optional. Must be in JSON. See our documentation for help.',
|
||||
editType: 'json',
|
||||
theme: 'hashi short',
|
||||
defaultShown: 'Default',
|
||||
// defaultValue: '# For example: { "wmode": "majority", "wtimeout": 5000 }',
|
||||
}),
|
||||
max_open_connections: attr('string', {}),
|
||||
max_idle_connections: attr('string'),
|
||||
max_connection_lifetime: attr('string'),
|
||||
tls: attr('string', {
|
||||
label: 'TLS Certificate Key',
|
||||
subText: 'x509 certificate for connecting to the database.',
|
||||
editType: 'file',
|
||||
}),
|
||||
tls_ca: attr('string', {
|
||||
label: 'TLS CA',
|
||||
subText: 'x509 CA file for validating the certificate presented by the MongoDB server.',
|
||||
editType: 'file',
|
||||
}),
|
||||
root_rotation_statements: attr('string', {
|
||||
subText: `The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.`,
|
||||
editType: 'json',
|
||||
theme: 'hashi short',
|
||||
defaultShown: 'Default',
|
||||
}),
|
||||
|
||||
allowedFields: computed(function() {
|
||||
return [
|
||||
// required
|
||||
'plugin_name',
|
||||
'name',
|
||||
// fields
|
||||
'connection_url', // * MongoDB, HanaDB, MSSQL, MySQL/MariaDB, Oracle, PostgresQL, Redshift
|
||||
'verify_connection', // default true
|
||||
'password_policy', // default ""
|
||||
|
||||
// plugin config
|
||||
'username',
|
||||
'password',
|
||||
|
||||
'hosts',
|
||||
'host',
|
||||
'url',
|
||||
'port',
|
||||
'write_concern',
|
||||
'max_open_connections',
|
||||
'max_idle_connections',
|
||||
'max_connection_lifetime',
|
||||
'tls',
|
||||
'tls_ca',
|
||||
];
|
||||
}),
|
||||
|
||||
// for both create and edit fields
|
||||
mainFields: computed('plugin_name', function() {
|
||||
return [
|
||||
'plugin_name',
|
||||
'name',
|
||||
'connection_url',
|
||||
'verify_connection',
|
||||
'password_policy',
|
||||
'pluginConfig',
|
||||
'root_rotation_statements',
|
||||
];
|
||||
}),
|
||||
|
||||
showAttrs: computed('plugin_name', function() {
|
||||
const f = [
|
||||
'name',
|
||||
'plugin_name',
|
||||
'connection_url',
|
||||
'write_concern',
|
||||
'verify_connection',
|
||||
'root_rotation_statements',
|
||||
'allowed_roles',
|
||||
];
|
||||
return expandAttributeMeta(this, f);
|
||||
}),
|
||||
|
||||
pluginFieldGroups: computed('plugin_name', function() {
|
||||
let groups = [{ default: ['username', 'password', 'write_concern'] }];
|
||||
// TODO: Get plugin options based on plugin
|
||||
groups.push({
|
||||
'TLS options': ['tls', 'tls_ca'],
|
||||
});
|
||||
return fieldToAttrs(this, groups);
|
||||
}),
|
||||
|
||||
fieldAttrs: computed('mainFields', function() {
|
||||
// Main Field Attrs only
|
||||
return expandAttributeMeta(this, this.mainFields);
|
||||
}),
|
||||
|
||||
/* CAPABILITIES */
|
||||
editConnectionPath: lazyCapabilities(apiPath`${'backend'}/config/${'id'}`, 'backend', 'id'),
|
||||
canEdit: alias('editConnectionPath.canUpdate'),
|
||||
canDelete: alias('editConnectionPath.canDelete'),
|
||||
resetConnectionPath: lazyCapabilities(apiPath`${'backend'}/reset/${'id'}`, 'backend', 'id'),
|
||||
canReset: computed.or('resetConnectionPath.canUpdate', 'resetConnectionPath.canCreate'),
|
||||
rotateRootPath: lazyCapabilities(apiPath`${'backend'}/rotate-root/${'id'}`, 'backend', 'id'),
|
||||
canRotateRoot: computed.or('rotateRootPath.canUpdate', 'rotateRootPath.canCreate'),
|
||||
rolePath: lazyCapabilities(apiPath`${'backend'}/role/*`, 'backend'),
|
||||
staticRolePath: lazyCapabilities(apiPath`${'backend'}/static-role/*`, 'backend'),
|
||||
canAddRole: computed.or('rolePath.canCreate', 'staticRolePath.canCreate'),
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
import Model, { attr } from '@ember-data/model';
|
||||
|
||||
export default Model.extend({
|
||||
username: attr('string'),
|
||||
password: attr('string'),
|
||||
leaseId: attr('string'),
|
||||
leaseDuration: attr('string'),
|
||||
lastVaultRotation: attr('string'),
|
||||
rotationPeriod: attr('number'),
|
||||
ttl: attr('number'),
|
||||
});
|
|
@ -0,0 +1,110 @@
|
|||
import Model, { attr } from '@ember-data/model';
|
||||
import { computed } from '@ember/object';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
||||
|
||||
export default Model.extend({
|
||||
idPrefix: 'role/',
|
||||
backend: attr('string', { readOnly: true }),
|
||||
name: attr('string', {
|
||||
label: 'Role name',
|
||||
}),
|
||||
database: attr('array', {
|
||||
label: '',
|
||||
editType: 'searchSelect',
|
||||
fallbackComponent: 'string-list',
|
||||
models: ['database/connection'],
|
||||
selectLimit: 1,
|
||||
onlyAllowExisting: true,
|
||||
subLabel: 'Database name',
|
||||
subText: 'The database for which credentials will be generated.',
|
||||
}),
|
||||
type: attr('string', {
|
||||
label: 'Type of role',
|
||||
possibleValues: ['static', 'dynamic'],
|
||||
}),
|
||||
ttl: attr({
|
||||
editType: 'ttl',
|
||||
defaultValue: '1h',
|
||||
label: 'Generated credentials’s Time-to-Live (TTL)',
|
||||
subText: 'Vault will use the engine default of 1 hour',
|
||||
defaultShown: 'Engine default',
|
||||
}),
|
||||
max_ttl: attr({
|
||||
editType: 'ttl',
|
||||
defaultValue: '24h',
|
||||
label: 'Generated credentials’s maximum Time-to-Live (Max TTL)',
|
||||
subText: 'Vault will use the engine default of 24 hours',
|
||||
defaultShown: 'Engine default',
|
||||
}),
|
||||
username: attr('string', {
|
||||
subText: 'The database username that this Vault role corresponds to.',
|
||||
}),
|
||||
rotation_period: attr({
|
||||
editType: 'ttl',
|
||||
defaultValue: '5s',
|
||||
subText:
|
||||
'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds.',
|
||||
}),
|
||||
creation_statements: attr('array', {
|
||||
editType: 'stringArray',
|
||||
defaultShown: 'Default',
|
||||
}),
|
||||
revocation_statements: attr('array', {
|
||||
editType: 'stringArray',
|
||||
defaultShown: 'Default',
|
||||
}),
|
||||
rotation_statements: attr('array', {
|
||||
editType: 'stringArray',
|
||||
defaultShown: 'Default',
|
||||
}),
|
||||
creation_statement: attr('string', {
|
||||
editType: 'json',
|
||||
theme: 'hashi short',
|
||||
defaultShown: 'Default',
|
||||
}),
|
||||
rotation_statement: attr('string', {
|
||||
editType: 'json',
|
||||
theme: 'hashi short',
|
||||
defaultShown: 'Default',
|
||||
}),
|
||||
|
||||
/* FIELD ATTRIBUTES */
|
||||
get fieldAttrs() {
|
||||
let fields = ['database', 'name', 'type'];
|
||||
return expandAttributeMeta(this, fields);
|
||||
},
|
||||
|
||||
roleSettingAttrs: computed(function() {
|
||||
// logic for which get displayed is on DatabaseRoleSettingForm
|
||||
let allRoleSettingFields = [
|
||||
'ttl',
|
||||
'max_ttl',
|
||||
'username',
|
||||
'rotation_period',
|
||||
'creation_statements',
|
||||
'creation_statement', // only for MongoDB (styling difference)
|
||||
'revocation_statements',
|
||||
'rotation_statements',
|
||||
'rotation_statement', // only for MongoDB (styling difference)
|
||||
];
|
||||
return expandAttributeMeta(this, allRoleSettingFields);
|
||||
}),
|
||||
|
||||
/* CAPABILITIES */
|
||||
// only used for secretPath
|
||||
path: attr('string', { readOnly: true }),
|
||||
|
||||
secretPath: lazyCapabilities(apiPath`${'backend'}/${'path'}/${'id'}`, 'backend', 'path', 'id'),
|
||||
canEditRole: alias('secretPath.canUpdate'),
|
||||
canDelete: alias('secretPath.canDelete'),
|
||||
dynamicPath: lazyCapabilities(apiPath`${'backend'}/roles/+`, 'backend'),
|
||||
canCreateDynamic: alias('dynamicPath.canCreate'),
|
||||
staticPath: lazyCapabilities(apiPath`${'backend'}/static-roles/+`, 'backend'),
|
||||
canCreateStatic: alias('staticPath.canCreate'),
|
||||
credentialPath: lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id'),
|
||||
canGenerateCredentials: alias('credentialPath.canRead'),
|
||||
databasePath: lazyCapabilities(apiPath`${'backend'}/config/${'database[0]'}`, 'backend', 'database'),
|
||||
canUpdateDb: alias('databasePath.canUpdate'),
|
||||
});
|
|
@ -63,6 +63,22 @@ export default Model.extend({
|
|||
if (type === 'kv' || type === 'generic') {
|
||||
defaultGroup.default.push('options.{version}');
|
||||
}
|
||||
if (type === 'database') {
|
||||
// For the Database Secret Engine we want to highlight the defaultLeaseTtl and maxLeaseTtl, removing them from the options object
|
||||
defaultGroup.default.push('config.{defaultLeaseTtl}', 'config.{maxLeaseTtl}');
|
||||
return [
|
||||
defaultGroup,
|
||||
{
|
||||
'Method Options': [
|
||||
'description',
|
||||
'config.listingVisibility',
|
||||
'local',
|
||||
'sealWrap',
|
||||
'config.{auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}',
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
defaultGroup,
|
||||
{
|
||||
|
|
|
@ -118,6 +118,8 @@ Router.map(function() {
|
|||
// transit-specific routes
|
||||
this.route('actions-root', { path: '/actions/' });
|
||||
this.route('actions', { path: '/actions/*secret' });
|
||||
// database specific route
|
||||
this.route('overview');
|
||||
});
|
||||
});
|
||||
this.route('policies', { path: '/policies/:type' }, function() {
|
||||
|
|
|
@ -38,6 +38,9 @@ export default EditBase.extend({
|
|||
if (modelType === 'transform') {
|
||||
modelType = transformModel(transition.to.queryParams);
|
||||
}
|
||||
if (modelType === 'database/connection' && transition.to?.queryParams?.itemType === 'role') {
|
||||
modelType = 'database/role';
|
||||
}
|
||||
if (modelType !== 'secret' && modelType !== 'secret-v2') {
|
||||
if (this.wizard.featureState === 'details' && this.wizard.componentState === 'transit') {
|
||||
this.wizard.transitionFeatureMachine('details', 'CONTINUE', 'transit');
|
||||
|
|
|
@ -2,11 +2,12 @@ import { resolve } from 'rsvp';
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
const SUPPORTED_DYNAMIC_BACKENDS = ['ssh', 'aws', 'pki'];
|
||||
const SUPPORTED_DYNAMIC_BACKENDS = ['database', 'ssh', 'aws', 'pki'];
|
||||
|
||||
export default Route.extend({
|
||||
templateName: 'vault/cluster/secrets/backend/credentials',
|
||||
pathHelp: service('path-help'),
|
||||
store: service(),
|
||||
|
||||
backendModel() {
|
||||
return this.modelFor('vault.cluster.secrets.backend');
|
||||
|
@ -26,6 +27,7 @@ export default Route.extend({
|
|||
let backendModel = this.backendModel();
|
||||
let backendPath = backendModel.get('id');
|
||||
let backendType = backendModel.get('type');
|
||||
let roleType = params.roleType;
|
||||
|
||||
if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendModel.get('type'))) {
|
||||
return this.transitionTo('vault.cluster.secrets.backend.list-root', backendPath);
|
||||
|
@ -34,10 +36,19 @@ export default Route.extend({
|
|||
backendPath,
|
||||
backendType,
|
||||
roleName: role,
|
||||
roleType,
|
||||
});
|
||||
},
|
||||
|
||||
resetController(controller) {
|
||||
controller.reset();
|
||||
},
|
||||
|
||||
actions: {
|
||||
willTransition() {
|
||||
// we do not want to save any of the credential information in the store.
|
||||
// once the user navigates away from this page, remove all credential info.
|
||||
this.store.unloadAll('database/credential');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -73,6 +73,7 @@ export default Route.extend({
|
|||
let secretEngine = this.store.peekRecord('secret-engine', backend);
|
||||
let type = secretEngine.get('engineType');
|
||||
let types = {
|
||||
database: tab === 'role' ? 'database/role' : 'database/connection',
|
||||
transit: 'transit-key',
|
||||
ssh: 'role-ssh',
|
||||
transform: this.modelTypeForTransform(tab),
|
||||
|
@ -86,15 +87,16 @@ export default Route.extend({
|
|||
return types[type];
|
||||
},
|
||||
|
||||
model(params) {
|
||||
async model(params) {
|
||||
const secret = this.secretParam() || '';
|
||||
const backend = this.enginePathParam();
|
||||
const backendModel = this.modelFor('vault.cluster.secrets.backend');
|
||||
const modelType = this.getModelType(backend, params.tab);
|
||||
|
||||
return hash({
|
||||
secret,
|
||||
secrets: this.store
|
||||
.lazyPaginatedQuery(this.getModelType(backend, params.tab), {
|
||||
.lazyPaginatedQuery(modelType, {
|
||||
id: secret,
|
||||
backend,
|
||||
responsePath: 'data.keys',
|
||||
|
@ -151,7 +153,6 @@ export default Route.extend({
|
|||
if (secret !== controller.get('baseKey.id')) {
|
||||
this.store.clearAllDatasets();
|
||||
}
|
||||
|
||||
controller.set('hasModel', true);
|
||||
controller.setProperties({
|
||||
model,
|
||||
|
@ -171,7 +172,7 @@ export default Route.extend({
|
|||
}
|
||||
controller.setProperties({
|
||||
filter: filter || '',
|
||||
page: model.get('meta.currentPage') || 1,
|
||||
page: model.meta?.currentPage || 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default Route.extend({
|
||||
type: '',
|
||||
enginePathParam() {
|
||||
let { backend } = this.paramsFor('vault.cluster.secrets.backend');
|
||||
return backend;
|
||||
},
|
||||
async fetchConnection(queryOptions) {
|
||||
try {
|
||||
return await this.store.query('database/connection', queryOptions);
|
||||
} catch (e) {
|
||||
return e.httpStatus;
|
||||
}
|
||||
},
|
||||
async fetchAllRoles(queryOptions) {
|
||||
try {
|
||||
return await this.store.query('database/role', queryOptions);
|
||||
} catch (e) {
|
||||
return e.httpStatus;
|
||||
}
|
||||
},
|
||||
pathQuery(backend, endpoint) {
|
||||
return {
|
||||
id: `${backend}/${endpoint}/`,
|
||||
};
|
||||
},
|
||||
async fetchCapabilitiesRole(queryOptions) {
|
||||
return this.store.queryRecord('capabilities', this.pathQuery(queryOptions.backend, 'roles'));
|
||||
},
|
||||
async fetchCapabilitiesStaticRole(queryOptions) {
|
||||
return this.store.queryRecord('capabilities', this.pathQuery(queryOptions.backend, 'static-roles'));
|
||||
},
|
||||
async fetchCapabilitiesConnection(queryOptions) {
|
||||
return this.store.queryRecord('capabilities', this.pathQuery(queryOptions.backend, 'config'));
|
||||
},
|
||||
model() {
|
||||
let backend = this.enginePathParam();
|
||||
let queryOptions = { backend, id: '' };
|
||||
|
||||
let connection = this.fetchConnection(queryOptions);
|
||||
let role = this.fetchAllRoles(queryOptions);
|
||||
let roleCapabilities = this.fetchCapabilitiesRole(queryOptions);
|
||||
let staticRoleCapabilities = this.fetchCapabilitiesStaticRole(queryOptions);
|
||||
let connectionCapabilities = this.fetchCapabilitiesConnection(queryOptions);
|
||||
|
||||
return hash({
|
||||
backend,
|
||||
connections: connection,
|
||||
roles: role,
|
||||
engineType: 'database',
|
||||
id: backend,
|
||||
roleCapabilities,
|
||||
staticRoleCapabilities,
|
||||
connectionCapabilities,
|
||||
});
|
||||
},
|
||||
setupController(controller, model) {
|
||||
this._super(...arguments);
|
||||
let showEmptyState = model.connections === 404 && model.roles === 404;
|
||||
let noConnectionCapabilities =
|
||||
!model.connectionCapabilities.canList &&
|
||||
!model.connectionCapabilities.canCreate &&
|
||||
!model.connectionCapabilities.canUpdate;
|
||||
|
||||
let emptyStateMessage = function() {
|
||||
if (noConnectionCapabilities) {
|
||||
return 'You cannot yet generate credentials. Ask your administrator if you think you should have access.';
|
||||
} else {
|
||||
return 'You can connect and external database to Vault. We recommend that you create a user for Vault rather than using the database root user.';
|
||||
}
|
||||
};
|
||||
controller.set('showEmptyState', showEmptyState);
|
||||
controller.set('emptyStateMessage', emptyStateMessage());
|
||||
},
|
||||
});
|
|
@ -95,6 +95,7 @@ export default Route.extend(UnloadModelRoute, {
|
|||
let backendModel = this.modelFor('vault.cluster.secrets.backend', backend);
|
||||
let type = backendModel.get('engineType');
|
||||
let types = {
|
||||
database: secret && secret.startsWith('role/') ? 'database/role' : 'database/connection',
|
||||
transit: 'transit-key',
|
||||
ssh: 'role-ssh',
|
||||
transform: this.modelTypeForTransform(secret),
|
||||
|
@ -227,6 +228,9 @@ export default Route.extend(UnloadModelRoute, {
|
|||
if (modelType.startsWith('transform/')) {
|
||||
secret = this.transformSecretName(secret, modelType);
|
||||
}
|
||||
if (modelType === 'database/role') {
|
||||
secret = secret.replace('role/', '');
|
||||
}
|
||||
let secretModel;
|
||||
|
||||
let capabilities = this.capabilities(secret, modelType);
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import RESTSerializer from '@ember-data/serializer/rest';
|
||||
|
||||
export default RESTSerializer.extend({
|
||||
primaryKey: 'name',
|
||||
|
||||
serializeAttribute(snapshot, json, key, attributes) {
|
||||
// Don't send values that are undefined
|
||||
if (
|
||||
undefined !== snapshot.attr(key) &&
|
||||
(snapshot.record.get('isNew') || snapshot.changedAttributes()[key])
|
||||
) {
|
||||
this._super(snapshot, json, key, attributes);
|
||||
}
|
||||
},
|
||||
|
||||
normalizeSecrets(payload) {
|
||||
if (payload.data.keys && Array.isArray(payload.data.keys)) {
|
||||
const connections = payload.data.keys.map(secret => ({ name: secret, backend: payload.backend }));
|
||||
return connections;
|
||||
}
|
||||
// Query single record response:
|
||||
return {
|
||||
id: payload.id,
|
||||
name: payload.id,
|
||||
backend: payload.backend,
|
||||
...payload.data,
|
||||
...payload.data.connection_details,
|
||||
};
|
||||
},
|
||||
|
||||
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord'];
|
||||
const connections = nullResponses.includes(requestType)
|
||||
? { name: id, backend: payload.backend }
|
||||
: this.normalizeSecrets(payload);
|
||||
const { modelName } = primaryModelClass;
|
||||
let transformedPayload = { [modelName]: connections };
|
||||
if (requestType === 'queryRecord') {
|
||||
// comes back as object anyway
|
||||
transformedPayload = { [modelName]: { id, ...connections } };
|
||||
}
|
||||
return this._super(store, primaryModelClass, transformedPayload, id, requestType);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import RESTSerializer from '@ember-data/serializer/rest';
|
||||
|
||||
export default RESTSerializer.extend({
|
||||
primaryKey: 'request_id',
|
||||
|
||||
normalizePayload(payload) {
|
||||
if (payload.data) {
|
||||
const credentials = {
|
||||
request_id: payload.request_id,
|
||||
username: payload.data.username,
|
||||
password: payload.data.password,
|
||||
leaseId: payload.lease_id,
|
||||
leaseDuration: payload.lease_duration,
|
||||
lastVaultRotation: payload.data.last_vault_rotation,
|
||||
rotationPeriod: payload.data.rotation_period,
|
||||
ttl: payload.data.ttl,
|
||||
};
|
||||
return credentials;
|
||||
}
|
||||
},
|
||||
|
||||
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
const credentials = this.normalizePayload(payload);
|
||||
const { modelName } = primaryModelClass;
|
||||
let transformedPayload = { [modelName]: credentials };
|
||||
|
||||
return this._super(store, primaryModelClass, transformedPayload, id, requestType);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,70 @@
|
|||
import RESTSerializer from '@ember-data/serializer/rest';
|
||||
|
||||
export default RESTSerializer.extend({
|
||||
primaryKey: 'name',
|
||||
|
||||
normalizeSecrets(payload) {
|
||||
if (payload.data.keys && Array.isArray(payload.data.keys)) {
|
||||
const roles = payload.data.keys.map(secret => {
|
||||
let type = 'dynamic';
|
||||
let path = 'roles';
|
||||
if (payload.data.staticRoles.includes(secret)) {
|
||||
type = 'static';
|
||||
path = 'static-roles';
|
||||
}
|
||||
return { name: secret, backend: payload.backend, type, path };
|
||||
});
|
||||
return roles;
|
||||
}
|
||||
let path = 'roles';
|
||||
if (payload.data.type === 'static') {
|
||||
path = 'static-roles';
|
||||
}
|
||||
let database = [];
|
||||
if (payload.data.db_name) {
|
||||
database = [payload.data.db_name];
|
||||
}
|
||||
return {
|
||||
id: payload.secret,
|
||||
name: payload.secret,
|
||||
backend: payload.backend,
|
||||
database,
|
||||
path,
|
||||
...payload.data,
|
||||
};
|
||||
},
|
||||
|
||||
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord'];
|
||||
const roles = nullResponses.includes(requestType)
|
||||
? { name: id, backend: payload.backend }
|
||||
: this.normalizeSecrets(payload);
|
||||
const { modelName } = primaryModelClass;
|
||||
let transformedPayload = { [modelName]: roles };
|
||||
if (requestType === 'queryRecord') {
|
||||
transformedPayload = { [modelName]: roles };
|
||||
}
|
||||
return this._super(store, primaryModelClass, transformedPayload, id, requestType);
|
||||
},
|
||||
|
||||
serializeAttribute(snapshot, json, key, attributes) {
|
||||
// Don't send values that are undefined
|
||||
if (
|
||||
undefined !== snapshot.attr(key) &&
|
||||
(snapshot.record.get('isNew') || snapshot.changedAttributes()[key])
|
||||
) {
|
||||
this._super(snapshot, json, key, attributes);
|
||||
}
|
||||
},
|
||||
|
||||
serialize(snapshot, requestType) {
|
||||
let data = this._super(snapshot, requestType);
|
||||
if (data.database) {
|
||||
const db = data.database[0];
|
||||
data.db_name = db;
|
||||
delete data.database;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
|
@ -198,3 +198,7 @@ $gutter-grey: #2a2f36;
|
|||
.cm-s-auto-height.CodeMirror {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cm-s-short.CodeMirror {
|
||||
height: 100px;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,16 @@
|
|||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-state-subTitle {
|
||||
font-size: $size-7;
|
||||
margin-top: -10px;
|
||||
padding-bottom: $spacing-s;
|
||||
}
|
||||
|
||||
.empty-state-message.has-border-bottom-light {
|
||||
padding-bottom: $spacing-s;
|
||||
}
|
||||
|
||||
.empty-state-actions {
|
||||
margin-top: $spacing-xs;
|
||||
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
.replication-page {
|
||||
.empty-state {
|
||||
background: none;
|
||||
|
||||
.empty-state-message {
|
||||
padding-bottom: $spacing-s;
|
||||
border-bottom: 1px solid $grey-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,13 @@
|
|||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-rows: 1fr;
|
||||
grid-gap: 2rem;
|
||||
|
||||
&.one-card {
|
||||
max-width: 33%;
|
||||
min-width: 350px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.selectable-card-container.has-grid {
|
||||
|
|
|
@ -5,6 +5,20 @@
|
|||
padding: $spacing-l 0 $spacing-l $spacing-l;
|
||||
line-height: 0;
|
||||
|
||||
&.no-flex {
|
||||
padding: $spacing-l;
|
||||
display: initial;
|
||||
line-height: initial;
|
||||
|
||||
.title-number {
|
||||
padding-top: $spacing-s;
|
||||
}
|
||||
|
||||
.search-label {
|
||||
margin-bottom: -$spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px $grey-light, $box-shadow-middle;
|
||||
}
|
||||
|
@ -13,6 +27,12 @@
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
&:disabled {
|
||||
border-color: $vault-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.card-details {
|
||||
grid-column-start: 2;
|
||||
grid-row-start: 3;
|
||||
|
|
|
@ -218,6 +218,7 @@ label {
|
|||
// cannot use :read-only selector because tag used for other purposes
|
||||
&.is-readOnly {
|
||||
background-color: $ui-gray-100;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.field.has-addons {
|
||||
|
|
|
@ -98,6 +98,12 @@
|
|||
.has-short-padding {
|
||||
padding: 0.25rem 1.25rem;
|
||||
}
|
||||
.has-tall-padding {
|
||||
padding: 2.25rem;
|
||||
}
|
||||
.has-top-bottom-margin {
|
||||
margin: 1.25rem 0rem;
|
||||
}
|
||||
|
||||
.is-sideless.has-short-padding {
|
||||
padding: 0.25rem 1.25rem;
|
||||
|
|
|
@ -100,3 +100,7 @@
|
|||
.toggle[type='checkbox'].is-success:checked + label::before {
|
||||
background: $blue;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{mode}} @root={{@root}} @showCurrent={{true}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-secret-header="true">
|
||||
{{#if (eq @mode "create") }}
|
||||
Create Connection
|
||||
{{else if (eq @mode "edit")}}
|
||||
Edit Connection
|
||||
{{else}}
|
||||
{{@model.id}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if (eq @mode "show")}}
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if @model.canDelete}}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
{{on 'click' this.delete}}
|
||||
data-test-database-connection-delete
|
||||
>
|
||||
Delete connection
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if @model.canReset}}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{action 'reset'}}
|
||||
@confirmTitle="Reset connection?"
|
||||
@confirmMessage="This will close the connection and its underlying plugin and restart it with the configuration stored in the barrier."
|
||||
@confirmButtonText="Reset"
|
||||
data-test-database-connection-reset
|
||||
>
|
||||
Reset connection
|
||||
</ConfirmAction>
|
||||
{{/if}}
|
||||
{{#if (and @model.canReset @model.canDelete)}}
|
||||
<div class="toolbar-separator" />
|
||||
{{/if}}
|
||||
{{#if @model.canRotate }}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{this.rotate}}
|
||||
@confirmTitle="Rotate credentials?"
|
||||
@confirmMessage={{'This will rotate the "root" user credentials stored for the database connection. The password will not be accessible once rotated.'}}
|
||||
@confirmButtonText="Rotate"
|
||||
data-test-database-connection-rotate
|
||||
>
|
||||
Rotate root credentials
|
||||
</ConfirmAction>
|
||||
{{/if}}
|
||||
{{#if @model.canAddRole}}
|
||||
<ToolbarSecretLink
|
||||
@secret=''
|
||||
@mode="create"
|
||||
@type="add"
|
||||
@queryParams={{query-params initialKey=(or filter baseKey.id) itemType="role"}}
|
||||
@data-test-secret-create=true
|
||||
>
|
||||
Add role
|
||||
</ToolbarSecretLink>
|
||||
{{/if}}
|
||||
{{#if @model.canEdit}}
|
||||
<ToolbarSecretLink
|
||||
@secret={{@model.id}}
|
||||
@mode="edit"
|
||||
@data-test-edit-link=true
|
||||
@replace=true
|
||||
>
|
||||
Edit configuration
|
||||
</ToolbarSecretLink>
|
||||
{{/if}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq @mode 'create')}}
|
||||
<form {{on 'submit' this.handleCreateConnection}}>
|
||||
{{#each @model.fieldAttrs as |attr|}}
|
||||
{{#if (eq attr.name "pluginConfig")}}
|
||||
{{!-- Plugin Config Section --}}
|
||||
<div class="form-section">
|
||||
<h3 class="title is-5">Plugin config</h3>
|
||||
{{#unless @model.plugin_name}}
|
||||
<EmptyState
|
||||
@title="No plugin selected"
|
||||
@message="Select a plugin type to be able to configure it."
|
||||
/>
|
||||
{{else}}
|
||||
{{#each @model.pluginFieldGroups as |fieldGroup|}}
|
||||
{{#each-in fieldGroup as |group fields|}}
|
||||
{{#if (eq group "default")}}
|
||||
{{#each fields as |attr|}}
|
||||
{{!-- TODO: special password edit mode --}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<ToggleButton @class="is-block" @toggleAttr={{concat "show" (camelize group)}} @toggleTarget={{this}} @openLabel={{concat "Hide " group}} @closedLabel={{group}} @data-test-toggle-group={{group}} />
|
||||
{{#if (get this (concat "show" (camelize group)))}}
|
||||
<div class="box is-marginless">
|
||||
{{#each fields as |attr|}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each-in}}
|
||||
{{/each}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{else if (not-eq attr.options.readOnly true)}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button
|
||||
data-test-secret-save
|
||||
type="submit"
|
||||
disabled={{buttonDisabled}}
|
||||
class="button is-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<SecretLink @mode="list" @class="button">
|
||||
Cancel
|
||||
</SecretLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{else if (eq @mode 'edit')}}
|
||||
<form {{on 'submit' this.handleUpdateConnection}}>
|
||||
{{#each @model.fieldAttrs as |attr|}}
|
||||
{{#if (eq attr.name "pluginConfig")}}
|
||||
<div class="form-section">
|
||||
<h3 class="title is-5">Plugin config</h3>
|
||||
{{#each @model.pluginFieldGroups as |fieldGroup|}}
|
||||
{{#each-in fieldGroup as |group fields|}}
|
||||
{{#if (eq group "default")}}
|
||||
{{#each fields as |attr|}}
|
||||
{{#if (eq attr.name "password")}}
|
||||
<label for="{{attr.name}}" class="is-label">
|
||||
{{capitalize (or attr.options.label attr.name)}}
|
||||
</label>
|
||||
<div class="field">
|
||||
<Toggle
|
||||
@name="show-{{attr.name}}"
|
||||
@status="success"
|
||||
@size="small"
|
||||
@onChange={{fn this.updateShowPassword (not this.showPasswordField)}}
|
||||
@checked={{this.showPasswordField}}
|
||||
data-test-toggle={{attr.name}}
|
||||
>
|
||||
<span class="ttl-picker-label has-text-grey">Update password</span><br/>
|
||||
<div class="description has-text-grey">
|
||||
<span>{{if this.showPasswordField 'The new password that will be used when connecting to the database' 'Vault will use the existing password'}}</span>
|
||||
</div>
|
||||
{{#if this.showPasswordField}}
|
||||
<Input
|
||||
{{on "change" (fn this.updatePassword attr.name)}}
|
||||
@type="password"
|
||||
@value={{get @model attr.name}}
|
||||
@name={{attr.name}}
|
||||
class="input"
|
||||
{{!-- Prevents browsers from auto-filling --}}
|
||||
@autocomplete="new-password"
|
||||
@spellcheck="false" />
|
||||
{{/if}}
|
||||
</Toggle>
|
||||
</div>
|
||||
{{else}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<ToggleButton @class="is-block" @toggleAttr={{concat "show" (camelize group)}} @toggleTarget={{this}} @openLabel={{concat "Hide " group}} @closedLabel={{group}} @data-test-toggle-group={{group}} />
|
||||
{{#if (get this (concat "show" (camelize group)))}}
|
||||
<div class="box is-marginless">
|
||||
{{#each fields as |attr|}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each-in}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}}
|
||||
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
|
||||
{{else if (not-eq attr.options.readOnly true)}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button
|
||||
data-test-secret-save
|
||||
type="submit"
|
||||
disabled={{buttonDisabled}}
|
||||
class="button is-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<SecretLink @mode="list" @class="button">
|
||||
Cancel
|
||||
</SecretLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
{{#each @model.showAttrs as |attr|}}
|
||||
{{#let attr.options.defaultDisplay as |defaultDisplay|}}
|
||||
{{#if (eq attr.type "object")}}
|
||||
<InfoTableRow @alwaysRender={{true}} @defaultShown={{attr.options.defaultShown}} @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{stringify (get @model attr.name)}} />
|
||||
{{else}}
|
||||
<InfoTableRow @alwaysRender={{true}} @defaultShown={{attr.options.defaultShown}} @label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @value={{or (get @model attr.name) defaultDisplay}} />
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<Modal
|
||||
@title="Rotate your root credentials?"
|
||||
@onClose={{action 'continueWithoutRotate'}}
|
||||
@isActive={{this.showSaveModal}}
|
||||
@type="info"
|
||||
@showCloseButton={{false}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
<p class="has-bottom-margin-s">It’s best practice to rotate the root credential immediately after the initial configuration of each database. Once rotated, <strong>only Vault knows the new root password</strong>.</p>
|
||||
<p>Would you like to rotate your new credentials? You can also do this later.</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
{{on 'click' this.continueWithRotate}}
|
||||
data-test-enable-rotate-connection
|
||||
>
|
||||
Rotate and enable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-secondary"
|
||||
{{on 'click' this.continueWithoutRotate}}
|
||||
data-test-enable-connection
|
||||
>
|
||||
Enable without rotating
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
|
@ -0,0 +1,72 @@
|
|||
{{#linked-block
|
||||
"vault.cluster.secrets.backend.show"
|
||||
(if keyTypeValue (concat 'role/' @item.id) @item.id)
|
||||
class="list-item-row"
|
||||
data-test-secret-link=item.id
|
||||
encode=true
|
||||
queryParams=(secret-query-params backendType)
|
||||
}}
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-10">
|
||||
<LinkTo @route={{concat "vault.cluster.secrets.backend.show" }} @model={{@item.id}} class="has-text-black has-text-weight-semibold">
|
||||
<Icon
|
||||
@glyph="user-square-outline"
|
||||
class="has-text-grey-light is-pulled-left"
|
||||
/>
|
||||
<div class="role-item-details">
|
||||
<span class="is-underline">{{if (eq @item.id ' ') '(self)' (or @item.keyWithoutParent @item.id)}}</span>
|
||||
<span class="tag has-text-grey-dark">{{this.keyTypeValue}}</span>
|
||||
</div>
|
||||
</LinkTo>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<PopupMenu name="secret-menu">
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
{{#if @item.canEdit}}
|
||||
<li class="action">
|
||||
<SecretLink
|
||||
@mode="show"
|
||||
@secret={{@item.id}}
|
||||
@class="has-text-black has-text-weight-semibold">
|
||||
Edit connection
|
||||
</SecretLink>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if @item.canEditRole}}
|
||||
<li class="action">
|
||||
<SecretLink
|
||||
@mode="edit"
|
||||
@secret={{concat 'role/' @item.id}}
|
||||
@class="has-text-black has-text-weight-semibold">
|
||||
Edit Role
|
||||
</SecretLink>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if @item.canReset}}
|
||||
<li class="action">
|
||||
<button type="button" class="link" onclick={{action "resetConnection" @item.id}}>
|
||||
Reset connection
|
||||
</button>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if @item.canGenerateCredentials}}
|
||||
<li class="action">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.credentials" @model={{@item.id}} @query={{hash roleType=this.keyTypeValue}}>
|
||||
Generate credentials
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if @item.canRotateRoot}}
|
||||
<li class="action">
|
||||
<button type="button" class="link" onclick={{action "rotateRootCred" @item.id}}>
|
||||
Rotate root credentials
|
||||
</button>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
</div>
|
||||
{{/linked-block}}
|
|
@ -0,0 +1,140 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{mode}} @root={{@root}} @showCurrent={{true}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-secret-header="true">
|
||||
{{#if (eq @mode "create") }}
|
||||
Create Role
|
||||
{{else if (eq @mode "edit")}}
|
||||
Edit Role
|
||||
{{else}}
|
||||
{{@model.id}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if (eq @mode 'show')}}
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if @model.canGenerateCredentials}}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
{{on 'click' (fn this.generateCreds @model.id)}}
|
||||
data-test-database-role-generate-creds
|
||||
>
|
||||
Generate credentials
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if @model.canDelete}}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{action 'delete'}}
|
||||
@confirmTitle="Delete role?"
|
||||
@confirmMessage="This role will be permanently deleted. You will need to re-create it to use it again."
|
||||
@confirmButtonText="Delete"
|
||||
data-test-database-role-delete
|
||||
>
|
||||
Delete role
|
||||
</ConfirmAction>
|
||||
{{/if}}
|
||||
{{#if @model.canEditRole}}
|
||||
<ToolbarSecretLink
|
||||
@secret={{concat 'role/' @model.id}}
|
||||
@mode="edit"
|
||||
@replace=true
|
||||
@queryParams={{query-params itemType="role"}}
|
||||
@data-test-edit-link=true
|
||||
>
|
||||
Edit role
|
||||
</ToolbarSecretLink>
|
||||
{{/if}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{#each @model.fieldAttrs as |attr|}}
|
||||
{{#let attr.options.defaultDisplay as |defaultDisplay|}}
|
||||
{{#if (eq attr.type "object")}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@defaultShown={{attr.options.defaultShown}}
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{stringify (get @model attr.name)}}
|
||||
/>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@defaultShown={{attr.options.defaultShown}}
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{or (get @model attr.name) defaultDisplay}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{!-- Edit or Create --}}
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<form {{on 'submit' this.handleCreateEditRole}}>
|
||||
{{#each @model.fieldAttrs as |attr|}}
|
||||
{{#if (eq @mode 'edit')}}
|
||||
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
|
||||
{{else if (not-eq attr.options.readOnly true)}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{!-- TODO: If database && !updateDB show warning --}}
|
||||
{{#if (get this.warningMessages attr.name)}}
|
||||
<AlertBanner @type="warning" @message={{get this.warningMessages attr.name}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
<DatabaseRoleSettingForm
|
||||
@attrs={{@model.roleSettingAttrs}}
|
||||
@roleType={{@model.type}}
|
||||
@model={{@model}}
|
||||
@mode={{@mode}}
|
||||
@dbType={{this.databaseType}}
|
||||
/>
|
||||
|
||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
{{#if (is-empty-object this.warningMessages)}}
|
||||
<button
|
||||
data-test-secret-save
|
||||
type="submit"
|
||||
{{!-- disabled={{this.missingFields}} // TODO validation --}}
|
||||
class="button is-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{{else}}
|
||||
<ToolTip @horizontalPosition="left" as |T|>
|
||||
<T.trigger>
|
||||
<button
|
||||
data-test-secret-save
|
||||
type="submit"
|
||||
disabled={{true}}
|
||||
class="button is-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</T.trigger>
|
||||
<T.content @class="tool-tip">
|
||||
<div class="box">
|
||||
You don't have permissions required to {{if (eq @mode 'create') "create" "update"}} this role. See form for details.
|
||||
</div>
|
||||
</T.content>
|
||||
</ToolTip>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="control">
|
||||
<SecretLink @mode="list" @class="button">
|
||||
Cancel
|
||||
</SecretLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -0,0 +1,38 @@
|
|||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<h3 class="title is-5">Role Settings</h3>
|
||||
{{#unless this.settingFields}}
|
||||
<EmptyState
|
||||
@title="No role type selected"
|
||||
@message="Select a type of role to be able to configure it"
|
||||
/>
|
||||
{{else}}
|
||||
<div class="form-section">
|
||||
{{#each this.settingFields as |attr|}}
|
||||
{{#if (and (eq @mode 'edit') (eq attr.name 'username'))}}
|
||||
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
|
||||
{{else}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/if}}
|
||||
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
{{#unless (eq this.statementFields 'NONE')}}
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<h3 class="title is-5">Statements</h3>
|
||||
{{#unless this.statementFields}}
|
||||
<EmptyState
|
||||
@title="No role type selected"
|
||||
@message="Select a type of role to be able to add statements for creation, revocation, and/or rotation."
|
||||
/>
|
||||
{{else}}
|
||||
<div class="form-section">
|
||||
{{#each this.statementFields as |attr|}}
|
||||
{{form-field data-test-field attr=attr model=@model}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{/unless}}
|
|
@ -0,0 +1,102 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<nav class="breadcrumb">
|
||||
<ul>
|
||||
<li>
|
||||
<span class="sep">/</span>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.overview" @model={{@backendPath}}>
|
||||
{{@backendPath}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 data-test-title class="title is-3">
|
||||
{{@roleName}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<div class={{unless (eq this.roleType 'noRoleFound') "box is-fullwidth is-sideless is-marginless"}}>
|
||||
{{!-- ROLE TYPE NOT FOUND, returned when query on the creds and static creds both returned error --}}
|
||||
{{#if (eq this.roleType 'noRoleFound') }}
|
||||
<EmptyState
|
||||
@title="You are not authorized"
|
||||
@subTitle="Something went wrong"
|
||||
@icon="alert-circle-outline"
|
||||
@bottomBorder={{true}}
|
||||
@message="Role wasn't found or you do not have permissions. Ask your administrator if you think you should have access."
|
||||
>
|
||||
<nav class="breadcrumb">
|
||||
<ul class="is-grouped-split">
|
||||
<li>
|
||||
{{!-- Empty because they can't go "back" anywhere --}}
|
||||
</li>
|
||||
<li>
|
||||
<LearnLink @path="/vault/database-secrets" @class="has-text-grey">
|
||||
Need help?
|
||||
</LearnLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{#unless (or model.errorMessage (eq this.roleType 'noRoleFound'))}}
|
||||
<AlertBanner
|
||||
@type="warning"
|
||||
@message="You will not be able to access these credentials later, so please copy them now."
|
||||
data-test-warning
|
||||
/>
|
||||
{{/unless}}
|
||||
{{!-- DYNAMIC ROLE --}}
|
||||
{{#if (and (eq this.roleType 'dynamic') model.username)}}
|
||||
<InfoTableRow @label="Username" @value={{model.username}}>
|
||||
<MaskedInput
|
||||
@value={{model.username}}
|
||||
@name="Username"
|
||||
@displayOnly={{true}}
|
||||
@allowCopy={{true}}
|
||||
/>
|
||||
</InfoTableRow>
|
||||
<InfoTableRow @label="Password" @value={{model.password}}>
|
||||
<MaskedInput
|
||||
@value={{model.password}}
|
||||
@name="Password"
|
||||
@displayOnly={{true}}
|
||||
@allowCopy={{true}}
|
||||
/>
|
||||
</InfoTableRow>
|
||||
<InfoTableRow @label="Lease ID" @value={{model.leaseId}} />
|
||||
<InfoTableRow @label="Lease Duration" @value={{format-duration model.leaseDuration }} />
|
||||
{{/if}}
|
||||
{{!-- STATIC ROLE --}}
|
||||
{{#if (and (eq this.roleType 'static') model.username)}}
|
||||
<InfoTableRow
|
||||
@label="Last Vault rotation"
|
||||
@value={{date-format model.lastVaultRotation 'MMMM d yyyy, h:mm:ss a'}}
|
||||
@tooltipText={{model.lastVaultRotation}}
|
||||
/>
|
||||
<InfoTableRow @label="Password" @value={{model.password}}>
|
||||
<MaskedInput
|
||||
@value={{model.password}}
|
||||
@name="Password"
|
||||
@displayOnly={{true}}
|
||||
@allowCopy={{true}}
|
||||
/>
|
||||
</InfoTableRow>
|
||||
<InfoTableRow @label="Username" @value={{model.username}} />
|
||||
<InfoTableRow @label="Rotation Period" @value={{format-duration model.rotationPeriod}} />
|
||||
<InfoTableRow @label="Time Remaining" @value={{format-duration model.ttl}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="has-top-bottom-margin">
|
||||
<button
|
||||
type="button"
|
||||
onclick={{action "redirectPreviousPage"}}
|
||||
class="button is-primary"
|
||||
data-test-secret-generate-back="true"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
|
@ -0,0 +1,27 @@
|
|||
<div class="selectable-card is-rounded no-flex">
|
||||
<div class="is-flex-between is-fullwidth card-details" >
|
||||
<h3 class="title is-5">{{@title}}</h3>
|
||||
</div>
|
||||
<div class="has-top-bottom-margin">
|
||||
<p class="is-label search-label">{{@searchLabel}}</p>
|
||||
</div>
|
||||
<SearchSelect
|
||||
@id={{id}}
|
||||
@models={{@models}}
|
||||
@selectLimit='1'
|
||||
@backend={{@backend}}
|
||||
@fallbackComponent='input-search'
|
||||
@onChange={{action 'handleRoleInput' }}
|
||||
@inputValue={{get model valuePath}}
|
||||
data-test-search-roles
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-secondary"
|
||||
disabled={{buttonDisabled}}
|
||||
onclick={{action "transitionToCredential"}}
|
||||
data-test-get-credentials
|
||||
>
|
||||
{{@title}}
|
||||
</button>
|
||||
</div>
|
|
@ -0,0 +1,10 @@
|
|||
<div class="field is-grouped">
|
||||
<div class="control is-expanded">
|
||||
<Input
|
||||
@class="input"
|
||||
@type="text"
|
||||
@value={{this.searchInput}}
|
||||
{{on 'keyup' this.inputChanged}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,7 @@
|
|||
{{#unless this.dontShowTab}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @query={{hash tab=@tab}} @tagName="li" @activeClass="is-active" data-test-secret-list-tab={{@label}}>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @query={{hash tab=@tab}}>
|
||||
{{@label}}
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
{{/unless}}
|
|
@ -1,7 +1,7 @@
|
|||
{{#with (options-for-backend model.engineType) as |options|}}
|
||||
{{#with (options-for-backend @model.engineType) as |options|}}
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader @baseKey={{baseKey}} @path="vault.cluster.secrets.backend.list" @root={{backendCrumb}}>
|
||||
<KeyValueHeader @baseKey={{baseKey}} @path="vault.cluster.secrets.backend.list" @root={{@backendCrumb}}>
|
||||
<li>
|
||||
<span class="sep">
|
||||
/
|
||||
|
@ -14,9 +14,9 @@
|
|||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
<Icon @glyph={{or model.engineType "secrets"}} @size="xl" class="has-text-grey-light" />
|
||||
{{model.id}}
|
||||
{{#if (eq model.options.version 2)}}
|
||||
<Icon @glyph={{or @model.engineType "secrets"}} @size="xl" class="has-text-grey-light" />
|
||||
{{@model.id}}
|
||||
{{#if (eq @model.options.version 2)}}
|
||||
<span class="tag">
|
||||
Version 2
|
||||
</span>
|
||||
|
@ -28,19 +28,30 @@
|
|||
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{{#if options.hasOverview}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.overview" @tagName="li" @activeClass="is-active" data-test-tab="overview">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.overview">
|
||||
Overview
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
{{#each options.tabs as |oTab|}}
|
||||
{{#if oTab.tab}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @query={{hash tab=oTab.tab}} @tagName="li" @activeClass="is-active" data-test-tab={{oTab.label}}>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @query={{hash tab=oTab.tab}}>
|
||||
{{oTab.label}}
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
<SecretListHeaderTab
|
||||
@displayName={{options.displayName}}
|
||||
@id={{@model.id}}
|
||||
@path={{oTab.checkCapabilitiesPath}}
|
||||
@label={{oTab.label}}
|
||||
@tab={{oTab.tab}}
|
||||
/>
|
||||
{{else}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @query={{hash tab=""}} @tagName="li" @activeClass="is-active" data-test-tab={{oTab.label}}>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @query={{hash tab=""}}>
|
||||
{{oTab.label}}
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
<SecretListHeaderTab
|
||||
@displayName={{options.displayName}}
|
||||
@id={{@model.id}}
|
||||
@path={{oTab.checkCapabilitiesPath}}
|
||||
@label={{oTab.label}}
|
||||
@tab={{""}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.configuration" @tagName="li" @activeClass="is-active">
|
||||
|
@ -56,7 +67,7 @@
|
|||
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{{#if (contains model.engineType (supported-secret-backends))}}
|
||||
{{#if (contains @model.engineType (supported-secret-backends))}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root" @tagName="li" @activeClass="is-active" @current-when="vault.cluster.secrets.backend.list-root vault.cluster.secrets.backend.list">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.list-root">
|
||||
{{capitalize (pluralize options.item)}}
|
||||
|
|
|
@ -9,12 +9,29 @@
|
|||
{{yield}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="selectable-card is-rounded">
|
||||
<div>
|
||||
<h2 class="title-number">{{format-number total}}</h2>
|
||||
<h3 class="title is-5" data-test-selectable-card-title={{formattedCardTitle}}>{{formattedCardTitle}}</h3>
|
||||
<p class="has-text-grey is-size-8">{{subText}}</p>
|
||||
</div>
|
||||
<div class="selectable-card is-rounded {{if actionCard "no-flex"}}">
|
||||
{{#unless actionCard}}
|
||||
<div>
|
||||
<h2 class="title-number">{{format-number total}}</h2>
|
||||
<h3 class="title is-5" data-test-selectable-card-title={{formattedCardTitle}}>{{formattedCardTitle}}</h3>
|
||||
<p class="has-text-grey is-size-8">{{subText}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="is-flex-between is-fullwidth card-details" data-test-selectable-card={{formattedCardTitle}}>
|
||||
<h3 class="title is-5">{{formattedCardTitle}}</h3>
|
||||
<LinkTo
|
||||
@route={{actionTo}}
|
||||
@class="has-icon-right is-ghost is-no-underline has-text-semibold"
|
||||
@query={{hash itemType=queryParam}}
|
||||
data-test-action-text={{actionText}}
|
||||
>
|
||||
{{actionText}}
|
||||
{{#if actionText}}<Icon @glyph="chevron-right" />{{/if}}
|
||||
</LinkTo>
|
||||
</div>
|
||||
<p class="has-text-grey is-size-8">{{subText}}</p>
|
||||
<h2 class="title-number">{{format-number total}}</h2>
|
||||
{{/unless}}
|
||||
{{yield}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<DatabaseListItem
|
||||
@item={{item}}
|
||||
/>
|
||||
|
|
@ -1,2 +1,12 @@
|
|||
{{#if (eq model.backendType 'database')}}
|
||||
<GenerateCredentialsDatabase
|
||||
@backendPath={{model.backendPath}}
|
||||
@backendType={{model.backendType}}
|
||||
@roleName={{model.roleName}}
|
||||
@roleType={{model.roleType}}
|
||||
/>
|
||||
{{else}}
|
||||
{{!-- TODO smells a little to have action off of query param requiring a conditional --}}
|
||||
<GenerateCredentials @backendPath={{model.backendPath}} @backendType={{model.backendType}} @roleName={{model.roleName}} @action={{if action action ""}} />
|
||||
<GenerateCredentials @backendPath={{model.backendPath}} @backendType={{model.backendType}} @roleName={{model.roleName}} @action={{if action action ""}} />
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
<SecretListHeader
|
||||
@isCertTab={{eq tab "certs"}}
|
||||
@model={{model}}
|
||||
@baseKey={{baseKey}}
|
||||
@backendCrumb={{backendCrumb}}
|
||||
@filter={{filter}}
|
||||
/>
|
||||
<div class="box is-fullwidth is-shadowless has-tall-padding">
|
||||
{{#if showEmptyState}}
|
||||
<EmptyState
|
||||
@title='Connect a database'
|
||||
@message={{emptyStateMessage}}
|
||||
>
|
||||
{{#if (or model.connectionCapabilities.canCreate model.connectionCapabilities.canUpdate)}}
|
||||
<SecretLink @mode="create" @secret="" @queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}} @class="link" @data-test-secret-create="connections">
|
||||
Connect a database
|
||||
</SecretLink>
|
||||
{{/if}}
|
||||
</EmptyState>
|
||||
{{else}}
|
||||
<div class="selectable-card-container {{if (and (eq model.connections 403) (eq model.roles 403)) 'one-card'}}">
|
||||
{{#if model.connectionCapabilities.canList}}
|
||||
<SelectableCard
|
||||
@cardTitle="Connections"
|
||||
@total={{if (eq model.connections 404) 0 model.connections.length}}
|
||||
@subText="The total number of connections to external databases that you have access to."
|
||||
@actionCard={{true}}
|
||||
@actionText="Configure new"
|
||||
@actionTo="vault.cluster.secrets.backend.create-root"
|
||||
@queryParam={{'connection'}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (or model.roleCapabilities.canList model.staticRoleCapabilities.canList) }}
|
||||
<SelectableCard
|
||||
@cardTitle="Roles"
|
||||
@total={{if (eq model.roles 404) 0 model.roles.length}}
|
||||
{{!-- TODO: Messaging needs massaging --}}
|
||||
@subText="The total number of roles that have been set up that you can list."
|
||||
@actionCard={{true}}
|
||||
@actionText="Create new"
|
||||
@actionTo="vault.cluster.secrets.backend.create-root"
|
||||
@queryParam={{'role'}}
|
||||
/>
|
||||
{{/if}}
|
||||
<GetCredentialsCard
|
||||
@title="Get Credentials"
|
||||
@searchLabel="Role to use"
|
||||
@backend={{model.backend}}
|
||||
@models={{array 'database/role'}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -0,0 +1,116 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader @baseKey={{this.baseKey}} @path="vault.cluster.secrets.backend.list" @mode="show" @root={{this.root}} @showCurrent={{true}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-secret-header="true">
|
||||
{{@model.secret}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
|
||||
<nav class="tabs sub-nav">
|
||||
<ul>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.roles" @models={{array this.backend @model.secret}} @tagName="li">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.roles" @models={{array this.backend @model.secret}}>
|
||||
Roles
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @models={{array this.backend @model.secret}} @tagName="li">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @models={{array this.backend @model.secret}}>
|
||||
Configuration
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{#with (options-for-backend backendType tab) as |options|}}
|
||||
{{#if (or model.meta.total (not isConfigurableTab))}}
|
||||
<Toolbar>
|
||||
{{#if model.meta.total}}
|
||||
<ToolbarFilters>
|
||||
<NavigateInput
|
||||
@enterpriseProduct="vault"
|
||||
@filterFocusDidChange={{action "setFilterFocus"}}
|
||||
@filterDidChange={{action "setFilter"}}
|
||||
@filter={{this.filter}}
|
||||
@filterMatchesKey={{filterMatchesKey}}
|
||||
@firstPartialMatch={{firstPartialMatch}}
|
||||
@baseKey={{get baseKey "id"}}
|
||||
@shouldNavigateTree={{options.navigateTree}}
|
||||
@placeholder={{options.searchPlaceholder}}
|
||||
@mode={{if (eq tab 'certs') 'secrets-cert' 'secrets'}}
|
||||
@data-test-nav-input={{true}}
|
||||
/>
|
||||
{{#if filterFocused}}
|
||||
{{#if filterMatchesKey}}
|
||||
{{#unless filterIsFolder}}
|
||||
<p class="input-hint">
|
||||
<kbd>Enter</kbd> to view {{filter}}
|
||||
</p>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{#if firstPartialMatch}}
|
||||
<p class="input-hint">
|
||||
<kbd>Tab</kbd> to autocomplete
|
||||
</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</ToolbarFilters>
|
||||
{{/if}}
|
||||
|
||||
<ToolbarActions>
|
||||
<ToolbarSecretLink
|
||||
@secret=''
|
||||
@mode="create"
|
||||
@type="add"
|
||||
@queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}}
|
||||
@data-test-secret-create=true
|
||||
>
|
||||
{{options.create}}
|
||||
</ToolbarSecretLink>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if model.meta.total}}
|
||||
{{#each model as |item|}}
|
||||
{{partial options.listItemPartial}}
|
||||
{{else}}
|
||||
<div class="box is-sideless">
|
||||
{{#if filterFocused}}
|
||||
There are no {{pluralize options.item}} matching <code>{{filter}}</code>, press <kbd>ENTER</kbd> to add one.
|
||||
{{else}}
|
||||
There are no {{pluralize options.item}} matching <code>{{filter}}</code>.
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
{{#if (gt model.meta.lastPage 1) }}
|
||||
<ListPagination @page={{model.meta.currentPage}} @lastPage={{model.meta.lastPage}} @link={{concat "vault.cluster.secrets.backend.list" (unless baseKey.id "-root")}} @model={{compact (array backend (if baseKey.id baseKey.id))}} />
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if (eq baseKey.id '')}}
|
||||
<EmptyState
|
||||
@title="No {{pluralize options.item}} in this backend yet"
|
||||
@message="Secrets in this backend will be listed here. Add a secret to get started."
|
||||
>
|
||||
<SecretLink @mode="create" @secret="" @queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}} @class="link">
|
||||
{{options.create}}
|
||||
</SecretLink>
|
||||
</EmptyState>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title={{if (eq filter baseKey.id)
|
||||
(concat
|
||||
"No " (pluralize options.item) " under “" this.filter "”"
|
||||
)
|
||||
(concat
|
||||
"No folders matching “" this.filter "”"
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/with}}
|
|
@ -18,85 +18,89 @@
|
|||
</Toolbar>
|
||||
|
||||
{{#each supportedBackends as |backend|}}
|
||||
{{#linked-block
|
||||
(if (eq backend.engineType "kmip")
|
||||
{{#let (if (eq backend.engineType "kmip")
|
||||
"vault.cluster.secrets.backend.kmip.scopes"
|
||||
(if (eq backend.engineType "database")
|
||||
"vault.cluster.secrets.backend.overview"
|
||||
"vault.cluster.secrets.backend.list-root"
|
||||
)
|
||||
backend.id
|
||||
class="list-item-row"
|
||||
data-test-secret-backend-row=backend.id
|
||||
}}
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<ToolTip @horizontalPosition="left" as |T|>
|
||||
<T.trigger>
|
||||
<Icon
|
||||
@glyph={{or (if (eq backend.engineType "kmip") "secrets" backend.engineType) "secrets"}}
|
||||
@size="l"
|
||||
class="has-text-grey-light"
|
||||
/>
|
||||
</T.trigger>
|
||||
<T.content @class="tool-tip">
|
||||
<div class="box">
|
||||
{{backend.engineType}}
|
||||
</div>
|
||||
</T.content>
|
||||
</ToolTip>
|
||||
<LinkTo @route={{if (eq backend.engineType "kmip") "vault.cluster.secrets.backend.kmip.scopes" "vault.cluster.secrets.backend.list-root" }} @model={{backend.id}} class="has-text-black has-text-weight-semibold" data-test-secret-path={{true}}>
|
||||
{{backend.path}}
|
||||
</LinkTo>
|
||||
<br />
|
||||
<code class="has-text-grey is-size-8">
|
||||
{{#if (eq backend.options.version 2)}}
|
||||
v2
|
||||
{{/if}}
|
||||
</code>
|
||||
<code class="has-text-grey is-size-8">
|
||||
{{backend.accessor}}
|
||||
</code>
|
||||
)) as |backendLink|}}
|
||||
{{#linked-block
|
||||
backendLink
|
||||
backend.id
|
||||
class="list-item-row"
|
||||
data-test-secret-backend-row=backend.id
|
||||
}}
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<ToolTip @horizontalPosition="left" as |T|>
|
||||
<T.trigger>
|
||||
<Icon
|
||||
@glyph={{or (if (eq backend.engineType "kmip") "secrets" backend.engineType) "secrets"}}
|
||||
@size="l"
|
||||
class="has-text-grey-light"
|
||||
/>
|
||||
</T.trigger>
|
||||
<T.content @class="tool-tip">
|
||||
<div class="box">
|
||||
{{backend.engineType}}
|
||||
</div>
|
||||
</T.content>
|
||||
</ToolTip>
|
||||
<LinkTo @route={{backendLink}} @model={{backend.id}} class="has-text-black has-text-weight-semibold" data-test-secret-path={{true}}>
|
||||
{{backend.path}}
|
||||
</LinkTo>
|
||||
<br />
|
||||
<code class="has-text-grey is-size-8">
|
||||
{{#if (eq backend.options.version 2)}}
|
||||
v2
|
||||
{{/if}}
|
||||
</code>
|
||||
<code class="has-text-grey is-size-8">
|
||||
{{backend.accessor}}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right is-flex is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<PopupMenu @name="engine-menu">
|
||||
<Confirm as |c|>
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.configuration" @model={{backend.id}}>
|
||||
View configuration
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{#unless (eq backend.type "cubbyhole")}}
|
||||
<div class="level-right is-flex is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<PopupMenu @name="engine-menu">
|
||||
<Confirm as |c|>
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
<c.Message
|
||||
@id={{backend.id}}
|
||||
@triggerText="Disable"
|
||||
@message="Any data in this engine will be permanently deleted."
|
||||
@title="Disable engine?"
|
||||
@confirmButtonText="Disable"
|
||||
@onConfirm={{perform disableEngine backend}}
|
||||
data-test-engine-disable="true"
|
||||
/>
|
||||
</li>
|
||||
{{/unless}}
|
||||
{{#if item.updatePath.isPending}}
|
||||
<li class="action">
|
||||
<button disabled type="button" class="link button is-loading is-transparent">
|
||||
loading
|
||||
</button>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.configuration" @model={{backend.id}}>
|
||||
View configuration
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</Confirm>
|
||||
</PopupMenu>
|
||||
{{#unless (eq backend.type "cubbyhole")}}
|
||||
<li class="action">
|
||||
<c.Message
|
||||
@id={{backend.id}}
|
||||
@triggerText="Disable"
|
||||
@message="Any data in this engine will be permanently deleted."
|
||||
@title="Disable engine?"
|
||||
@confirmButtonText="Disable"
|
||||
@onConfirm={{perform disableEngine backend}}
|
||||
data-test-engine-disable="true"
|
||||
/>
|
||||
</li>
|
||||
{{/unless}}
|
||||
{{#if item.updatePath.isPending}}
|
||||
<li class="action">
|
||||
<button disabled type="button" class="link button is-loading is-transparent">
|
||||
loading
|
||||
</button>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</Confirm>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/linked-block}}
|
||||
{{/linked-block}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
{{#each unsupportedBackends as |backend|}}
|
||||
<div class="list-item-row" data-test-secret-backend-row={{backend.id}}>
|
||||
|
|
|
@ -12,8 +12,10 @@ import layout from '../templates/components/empty-state';
|
|||
* ```
|
||||
*
|
||||
* @param title=null{String} - A short label for the empty state
|
||||
* @param subTitle=null{String} - A sub title that goes underneath the main title
|
||||
* @param message=null{String} - A description of why a user might be seeing the empty state and possibly instructions for actions they may take.
|
||||
* @param [icon='']{String} - A optional param to display icon to the right of the title
|
||||
* @param [icon='']{String} - An optional param to display icon to the right of the title
|
||||
* @param bottomBorder=false{Boolean} - A bottom border underneath the message. Generally used when you have links under the message
|
||||
*/
|
||||
|
||||
export default Component.extend({
|
||||
|
@ -22,4 +24,5 @@ export default Component.extend({
|
|||
title: null,
|
||||
message: null,
|
||||
icon: '',
|
||||
bottomBorder: false,
|
||||
});
|
||||
|
|
|
@ -84,6 +84,9 @@ export default Component.extend({
|
|||
|
||||
model: null,
|
||||
|
||||
// This is only used internally for `optional-text` editType
|
||||
showInput: false,
|
||||
|
||||
/*
|
||||
* @private
|
||||
* @param object
|
||||
|
@ -130,5 +133,13 @@ export default Component.extend({
|
|||
this.onChange(path, valToSet);
|
||||
}
|
||||
},
|
||||
|
||||
toggleShow(path) {
|
||||
const value = !this.showInput;
|
||||
this.set('showInput', value);
|
||||
if (!value) {
|
||||
this.send('setAndBroadcast', path, null);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -23,7 +23,9 @@ import layout from '../templates/components/info-table-row';
|
|||
* @param [modelType=null] {string} - Passed through to InfoTableItemArray. Tells what model you want data for the allOptions to be returned from. Used in conjunction with the the isLink.
|
||||
* @param [queryParam] {String} - Passed through to InfoTableItemArray. If you want to specific a tab for the View All XX to display to. Ex: role
|
||||
* @param [backend] {String} - Passed through to InfoTableItemArray. To specify secrets backend to point link to Ex: transformation
|
||||
* @param [viewAll] {String} - Passed through to InfoTableItemArray. Specify the word at the end of the link View all xx.
|
||||
* @param [viewAll] {String} - Passed through to InfoTableItemArray. Specify the word at the end of the link View all.
|
||||
* @param [tooltipText] {String} - Text if a tooltip should display over the value.
|
||||
* @param [defaultShown] {String} - Text that renders as value if alwaysRender=true. Eg. "Vault default"
|
||||
*/
|
||||
|
||||
export default Component.extend({
|
||||
|
@ -36,6 +38,8 @@ export default Component.extend({
|
|||
label: null,
|
||||
helperText: null,
|
||||
value: null,
|
||||
tooltipText: '',
|
||||
defaultShown: '',
|
||||
|
||||
valueIsBoolean: computed('value', function() {
|
||||
return typeOf(this.value) === 'boolean';
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* @module ReadonlyFormField
|
||||
* ReadonlyFormField components are used to...
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <ReadonlyFormField @attr={attr} />
|
||||
* ```
|
||||
* @param {object} attr - Should be an attribute from a model exported with expandAttributeMeta
|
||||
* @param {any} value - The value that should be displayed on the input
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { setComponentTemplate } from '@ember/component';
|
||||
import { capitalize, dasherize } from '@ember/string';
|
||||
import { humanize } from 'vault/helpers/humanize';
|
||||
import layout from '../templates/components/readonly-form-field';
|
||||
|
||||
class ReadonlyFormField extends Component {
|
||||
get labelString() {
|
||||
if (!this.args.attr) {
|
||||
return '';
|
||||
}
|
||||
const label = this.args.attr.options ? this.args.attr.options.label : '';
|
||||
const name = this.args.attr.name;
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
if (name) {
|
||||
return capitalize(humanize([dasherize(name)]));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default setComponentTemplate(layout, ReadonlyFormField);
|
|
@ -12,7 +12,7 @@ import layout from '../templates/components/search-select';
|
|||
* <SearchSelect @id="group-policies" @models={{["policies/acl"]}} @onChange={{onChange}} @selectLimit={{2}} @inputValue={{get model valuePath}} @helpText="Policies associated with this group" @label="Policies" @fallbackComponent="string-list" />
|
||||
*
|
||||
* @param id {String} - The name of the form field
|
||||
* @param models {String} - An array of model types to fetch from the API.
|
||||
* @param models {Array} - An array of model types to fetch from the API.
|
||||
* @param onChange {Func} - The onchange action for this form field.
|
||||
* @param inputValue {String | Array} - A comma-separated string or an array of strings.
|
||||
* @param label {String} - Label for this form field
|
||||
|
@ -147,6 +147,7 @@ export default Component.extend({
|
|||
},
|
||||
discardSelection(selected) {
|
||||
this.selectedOptions.removeObject(selected);
|
||||
// fire off getSelectedValue action higher up in get-credentials-card component
|
||||
if (!selected.new) {
|
||||
this.options.pushObject(selected);
|
||||
}
|
||||
|
|
|
@ -7,13 +7,23 @@
|
|||
{{title}}
|
||||
</h3>
|
||||
</div>
|
||||
{{#if subTitle}}
|
||||
<p class="empty-state-subTitle" data-test-empty-state-subText>
|
||||
{{subTitle}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<h3 class="empty-state-title" data-test-empty-state-title>
|
||||
{{title}}
|
||||
</h3>
|
||||
{{#if subTitle}}
|
||||
<p class="empty-state-subTitle" data-test-empty-state-subText>
|
||||
{{subTitle}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if message}}
|
||||
<p class="empty-state-message" data-test-empty-state-message>
|
||||
<p class={{concat "empty-state-message" (if bottomBorder " has-border-bottom-light")}} data-test-empty-state-message>
|
||||
{{message}}
|
||||
</p>
|
||||
{{/if}}
|
||||
|
|
|
@ -4,8 +4,12 @@
|
|||
{{#if (eq group "default")}}
|
||||
{{#each fields as |attr|}}
|
||||
{{#unless (and (not-eq mode "create") (eq attr.name "name"))}}
|
||||
{{form-field data-test-field attr=attr model=model onChange=onChange
|
||||
}}
|
||||
<FormField
|
||||
data-test-field
|
||||
@attr={{attr}}
|
||||
@model={{model}}
|
||||
@onChange={{onChange}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
|
@ -20,7 +24,11 @@
|
|||
{{#if (get this (concat "show" (camelize group)))}}
|
||||
<div class="box is-marginless">
|
||||
{{#each fields as |attr|}}
|
||||
{{form-field data-test-field attr=attr model=model}}
|
||||
<FormField
|
||||
data-test-field
|
||||
@attr={{attr}}
|
||||
@model={{model}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
attr.options.editType
|
||||
(array
|
||||
"boolean"
|
||||
"optionalText"
|
||||
"searchSelect"
|
||||
"mountAccessor"
|
||||
"kv"
|
||||
|
@ -37,6 +38,11 @@
|
|||
(action "setAndBroadcast" valuePath)
|
||||
value="target.value"
|
||||
}} data-test-input={{attr.name}}>
|
||||
{{#unless attr.options.defaultValue}}
|
||||
<option value="">
|
||||
Select one
|
||||
</option>
|
||||
{{/unless}}
|
||||
{{#each (path-or-array attr.options.possibleValues model) as |val|}}
|
||||
<option selected={{eq (get model valuePath) (or val.value val)}} value={{or val.value val}}>
|
||||
{{or val.displayName val}}
|
||||
|
@ -93,6 +99,7 @@
|
|||
onChange=(action "setAndBroadcast" valuePath)
|
||||
}}
|
||||
{{else if (eq attr.options.editType "kv")}}
|
||||
{{!-- KV Object Editor --}}
|
||||
{{kv-object-editor
|
||||
value=(get model valuePath)
|
||||
onChange=(action "setAndBroadcast" valuePath)
|
||||
|
@ -101,6 +108,7 @@
|
|||
helpText=attr.options.helpText
|
||||
}}
|
||||
{{else if (eq attr.options.editType "file")}}
|
||||
{{!-- File Input --}}
|
||||
{{text-file
|
||||
index=""
|
||||
helpText=attr.options.helpText
|
||||
|
@ -110,14 +118,47 @@
|
|||
label=labelString
|
||||
}}
|
||||
{{else if (eq attr.options.editType "ttl")}}
|
||||
<TtlPicker2
|
||||
@onChange={{action (action "setAndBroadcastTtl" valuePath)}}
|
||||
@label={{labelString}}
|
||||
@helperTextDisabled="Vault will use the default lease duration"
|
||||
@helperTextEnabled="Lease will expire after"
|
||||
@description={{attr.helpText}}
|
||||
@initialValue={{or (get model valuePath) attr.options.setDefault}}
|
||||
/>
|
||||
{{!-- TTL Picker --}}
|
||||
<div class="field">
|
||||
<TtlPicker2
|
||||
@onChange={{action (action "setAndBroadcastTtl" valuePath)}}
|
||||
@label={{labelString}}
|
||||
@helperTextDisabled="Vault will use the default lease duration"
|
||||
@helperTextEnabled="Lease will expire after"
|
||||
@description={{attr.helpText}}
|
||||
@initialValue={{or (get model valuePath) attr.options.setDefault}}
|
||||
/>
|
||||
</div>
|
||||
{{else if (eq attr.options.editType "optionalText")}}
|
||||
{{!-- Togglable Text Input --}}
|
||||
<Toggle
|
||||
@name="show-{{attr.name}}"
|
||||
@status="success"
|
||||
@size="small"
|
||||
@onChange={{action 'toggleShow' attr.name}}
|
||||
@checked={{showInput}}
|
||||
data-test-toggle={{attr.name}}
|
||||
>
|
||||
<span class="ttl-picker-label is-large">{{labelString}}</span><br/>
|
||||
<div class="description has-text-grey">
|
||||
<span>{{attr.options.subText}}</span>
|
||||
</div>
|
||||
{{#if showInput}}
|
||||
<input
|
||||
data-test-input={{attr.name}}
|
||||
id={{attr.name}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
value={{or (get model valuePath)
|
||||
attr.options.defaultValue}}
|
||||
onChange={{action
|
||||
(action "setAndBroadcast" valuePath)
|
||||
value="target.value"
|
||||
}}
|
||||
class="input"
|
||||
maxLength={{attr.options.characterLimit}} />
|
||||
{{/if}}
|
||||
</Toggle>
|
||||
{{else if (eq attr.options.editType "stringArray")}}
|
||||
{{string-list
|
||||
data-test-input=attr.name
|
||||
|
@ -128,17 +169,29 @@
|
|||
onChange=(action (action "setAndBroadcast" valuePath))
|
||||
}}
|
||||
{{else if (eq attr.options.sensitive true)}}
|
||||
{{!-- Masked Input --}}
|
||||
<MaskedInput @value={{or (get model valuePath) attr.options.defaultValue}} @placeholder="" @allowCopy="true"
|
||||
@onChange={{action (action "setAndBroadcast" valuePath)}} @maskWhileTyping={{if (eq attr.name "bindpass") true}}/>
|
||||
{{else if (or (eq attr.type "number") (eq attr.type "string"))}}
|
||||
<div class="control">
|
||||
{{#if (eq attr.options.editType "textarea")}}
|
||||
{{!-- Text area --}}
|
||||
<textarea data-test-input={{attr.name}} id={{attr.name}}
|
||||
value={{or (get model valuePath) attr.options.defaultValue}} oninput={{action
|
||||
(action "setAndBroadcast" valuePath)
|
||||
value="target.value"
|
||||
}} class="textarea"></textarea>
|
||||
{{else if (eq attr.options.editType "password")}}
|
||||
<Input
|
||||
@type="password"
|
||||
@value={{get model valuePath}}
|
||||
@name={{attr.name}}
|
||||
class="input"
|
||||
{{!-- Prevents browsers from auto-filling --}}
|
||||
@autocomplete="new-password"
|
||||
@spellcheck="false" />
|
||||
{{else if (eq attr.options.editType "json")}}
|
||||
{{!-- JSON Editor --}}
|
||||
<label for="{{attr.name}}" class="is-label">
|
||||
{{labelString}}
|
||||
{{#if attr.options.helpText}}
|
||||
|
@ -149,13 +202,22 @@
|
|||
{{/info-tooltip}}
|
||||
{{/if}}
|
||||
</label>
|
||||
{{json-editor
|
||||
value=(if
|
||||
(get model valuePath) (stringify (jsonify (get model valuePath)))
|
||||
)
|
||||
valueUpdated=(action "codemirrorUpdated" attr.name "string")
|
||||
}}
|
||||
{{#if attr.options.subText}}
|
||||
<p class="sub-text">{{attr.options.subText}}</p>
|
||||
{{/if}}
|
||||
<JsonEditor
|
||||
@value={{if
|
||||
(get model valuePath)
|
||||
(stringify (jsonify (get model valuePath)))
|
||||
attr.options.defaultValue
|
||||
}}
|
||||
@valueUpdated={{action "codemirrorUpdated" attr.name "string"}}
|
||||
@options={{hash
|
||||
theme=(or attr.options.theme 'hashi')
|
||||
}}
|
||||
/>
|
||||
{{else}}
|
||||
{{!-- Regular Text Input --}}
|
||||
<input data-test-input={{attr.name}} id={{attr.name}} autocomplete="off" spellcheck="false"
|
||||
value={{or (get model valuePath) attr.options.defaultValue}} onChange={{action
|
||||
(action "setAndBroadcast" valuePath)
|
||||
|
|
|
@ -28,6 +28,8 @@
|
|||
@glyph="cancel-square-outline"
|
||||
/> No
|
||||
{{/if}}
|
||||
{{else if (and alwaysRender defaultShown)}}
|
||||
{{defaultShown}}
|
||||
{{else}}
|
||||
{{#if (eq type 'array')}}
|
||||
<InfoTableItemArray
|
||||
|
@ -41,7 +43,23 @@
|
|||
@wildcardLabel={{wildcardLabel}}
|
||||
/>
|
||||
{{else}}
|
||||
<code class="is-word-break has-text-black" data-test-row-value="{{label}}">{{value}}</code>
|
||||
{{#if tooltipText}}
|
||||
<ToolTip
|
||||
@verticalPosition="above"
|
||||
@horizontalPosition="left"
|
||||
as |T|>
|
||||
<T.trigger @tabindex=false>
|
||||
<code class="is-word-break has-text-black" data-test-row-value="{{label}}">{{value}}</code>
|
||||
</T.trigger>
|
||||
<T.content @class="tool-tip">
|
||||
<div class="box">
|
||||
{{tooltipText}}
|
||||
</div>
|
||||
</T.content>
|
||||
</ToolTip>
|
||||
{{else}}
|
||||
<code class="is-word-break has-text-black" data-test-row-value="{{label}}">{{value}}</code>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
{{#unless
|
||||
(or
|
||||
(eq @attr.type "boolean")
|
||||
(contains
|
||||
@attr.options.editType
|
||||
(array
|
||||
"boolean"
|
||||
"optionalText"
|
||||
"searchSelect"
|
||||
"mountAccessor"
|
||||
"kv"
|
||||
"file"
|
||||
"ttl"
|
||||
"stringArray"
|
||||
"json"
|
||||
)
|
||||
)
|
||||
)
|
||||
}}
|
||||
<label for="{{@attr.name}}" class="is-label" data-test-readonly-label>
|
||||
{{labelString}}
|
||||
{{#if @attr.options.helpText}}
|
||||
{{#info-tooltip}}
|
||||
<span data-test-help-text>
|
||||
{{@attr.options.helpText}}
|
||||
</span>
|
||||
{{/info-tooltip}}
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if @attr.options.subText}}
|
||||
<p class="sub-text">{{@attr.options.subText}}</p>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
|
||||
{{#if @attr.options.possibleValues}}
|
||||
<div class="control is-expanded field is-readOnly">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="{{@attr.name}}" id="{{@attr.name}}" disabled readonly data-test-input={{@attr.name}}>
|
||||
<option selected="true" value={{@value}}>
|
||||
{{@value}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<input
|
||||
data-test-input={{@attr.name}}
|
||||
id={{@attr.name}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
value={{@value}}
|
||||
readonly
|
||||
class="field input is-readOnly"
|
||||
type={{@attr.type}}
|
||||
/>
|
||||
{{/if}}
|
|
@ -8,7 +8,7 @@
|
|||
disabled=disabled
|
||||
data-test-toggle-input=name
|
||||
}}
|
||||
<label data-test-toggle-label={{name}} for={{safeId}}>
|
||||
<label data-test-toggle-label={{name}} for={{safeId}} class="toggle-label">
|
||||
{{#if (has-block)}}
|
||||
{{yield}}
|
||||
{{else}}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'core/components/readonly-form-field';
|
|
@ -71,11 +71,11 @@ module('Acceptance | Enterprise | Transform secrets', function(hooks) {
|
|||
);
|
||||
assert.ok(transformationsPage.isEmpty, 'renders empty state');
|
||||
assert
|
||||
.dom('.is-active[data-test-tab="Transformations"]')
|
||||
.dom('.is-active[data-test-secret-list-tab="Transformations"]')
|
||||
.exists('Has Transformations tab which is active');
|
||||
assert.dom('[data-test-tab="Roles"]').exists('Has Roles tab');
|
||||
assert.dom('[data-test-tab="Templates"]').exists('Has Templates tab');
|
||||
assert.dom('[data-test-tab="Alphabets"]').exists('Has Alphabets tab');
|
||||
assert.dom('[data-test-secret-list-tab="Roles"]').exists('Has Roles tab');
|
||||
assert.dom('[data-test-secret-list-tab="Templates"]').exists('Has Templates tab');
|
||||
assert.dom('[data-test-secret-list-tab="Alphabets"]').exists('Has Alphabets tab');
|
||||
});
|
||||
|
||||
test('it can create a transformation and add itself to the role attached', async function(assert) {
|
||||
|
@ -124,7 +124,7 @@ module('Acceptance | Enterprise | Transform secrets', function(hooks) {
|
|||
await newTransformation(backend, 'a-transformation', true);
|
||||
await click(`[data-test-secret-breadcrumb="${backend}"]`);
|
||||
assert.equal(currentURL(), `/vault/secrets/${backend}/list`, 'Links back to list view from breadcrumb');
|
||||
await click('[data-test-tab="Roles"]');
|
||||
await click('[data-test-secret-list-tab="Roles"]');
|
||||
assert.equal(currentURL(), `/vault/secrets/${backend}/list?tab=role`, 'links to role list page');
|
||||
// create role with transformation attached
|
||||
await rolesPage.createLink();
|
||||
|
@ -199,7 +199,7 @@ module('Acceptance | Enterprise | Transform secrets', function(hooks) {
|
|||
test('it allows creation and edit of a template', async function(assert) {
|
||||
const templateName = 'my-template';
|
||||
let backend = await mount();
|
||||
await click('[data-test-tab="Templates"]');
|
||||
await click('[data-test-secret-list-tab="Templates"]');
|
||||
await settled();
|
||||
assert.equal(currentURL(), `/vault/secrets/${backend}/list?tab=template`, 'links to template list page');
|
||||
await settled();
|
||||
|
@ -237,7 +237,7 @@ module('Acceptance | Enterprise | Transform secrets', function(hooks) {
|
|||
test('it allows creation and edit of an alphabet', async function(assert) {
|
||||
const alphabetName = 'vowels-only';
|
||||
let backend = await mount();
|
||||
await click('[data-test-tab="Alphabets"]');
|
||||
await click('[data-test-secret-list-tab="Alphabets"]');
|
||||
await settled();
|
||||
assert.equal(currentURL(), `/vault/secrets/${backend}/list?tab=alphabet`, 'links to alphabet list page');
|
||||
await alphabetsPage.createLink();
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { currentURL, settled, click, visit } from '@ember/test-helpers';
|
||||
import { create } from 'ember-cli-page-object';
|
||||
import apiStub from 'vault/tests/helpers/noop-all-api-requests';
|
||||
import authPage from 'vault/tests/pages/auth';
|
||||
import logout from 'vault/tests/pages/logout';
|
||||
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
|
||||
|
||||
const consoleComponent = create(consoleClass);
|
||||
|
||||
const MODEL = {
|
||||
engineType: 'database',
|
||||
id: 'database-name',
|
||||
};
|
||||
|
||||
// ARG TODO add more to test her once you fill out the flow
|
||||
module('Acceptance | secrets/database/*', function(hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(async function() {
|
||||
this.server = apiStub({ usePassthrough: true });
|
||||
return authPage.login();
|
||||
});
|
||||
hooks.afterEach(function() {
|
||||
this.server.shutdown();
|
||||
});
|
||||
|
||||
test('root and limited access', async function(assert) {
|
||||
this.set('model', MODEL);
|
||||
let backend = 'database';
|
||||
const NO_ROLES_POLICY = `
|
||||
path "database/roles/*" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
path "database/static-roles/*" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
path "database/config/*" {
|
||||
capabilities = ["list", "create", "read", "update"]
|
||||
}
|
||||
path "database/creds/*" {
|
||||
capabilities = ["list", "create", "read", "update"]
|
||||
}
|
||||
`;
|
||||
await consoleComponent.runCommands([
|
||||
`write sys/mounts/${backend} type=database`,
|
||||
`write sys/policies/acl/test-policy policy=${btoa(NO_ROLES_POLICY)}`,
|
||||
'write -field=client_token auth/token/create policies=test-policy ttl=1h',
|
||||
]);
|
||||
let token = consoleComponent.lastTextOutput;
|
||||
|
||||
// test root user flow
|
||||
await settled();
|
||||
|
||||
// await click('[data-test-secret-backend-row="database"]');
|
||||
// skipping the click because occasionally is shows up on the second page and cannot be found
|
||||
await visit(`/vault/secrets/database/overview`);
|
||||
await settled();
|
||||
assert.dom('[data-test-component="empty-state"]').exists('renders empty state');
|
||||
assert.dom('[data-test-secret-list-tab="Connections"]').exists('renders connections tab');
|
||||
assert.dom('[data-test-secret-list-tab="Roles"]').exists('renders connections tab');
|
||||
|
||||
await click('[data-test-secret-create="connections"]');
|
||||
assert.equal(currentURL(), '/vault/secrets/database/create');
|
||||
|
||||
// Login with restricted policy
|
||||
await logout.visit();
|
||||
await authPage.login(token);
|
||||
await settled();
|
||||
// skipping the click because occasionally is shows up on the second page and cannot be found
|
||||
await visit(`/vault/secrets/database/overview`);
|
||||
assert.dom('[data-test-tab="overview"]').exists('renders overview tab');
|
||||
assert.dom('[data-test-secret-list-tab="Connections"]').exists('renders connections tab');
|
||||
assert
|
||||
.dom('[data-test-secret-list-tab="Roles]')
|
||||
.doesNotExist(`does not show the roles tab because it does not have permissions`);
|
||||
assert
|
||||
.dom('[data-test-selectable-card="Connections"]')
|
||||
.exists({ count: 1 }, 'renders only the connection card');
|
||||
|
||||
await click('[data-test-action-text="Configure new"]');
|
||||
assert.equal(currentURL(), '/vault/secrets/database/create?itemType=connection');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,53 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { run } from '@ember/runloop';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import Service from '@ember/service';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
const TITLE = 'Get Credentials';
|
||||
const SEARCH_LABEL = 'Role to use';
|
||||
|
||||
const storeService = Service.extend({
|
||||
query(modelType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
switch (modelType) {
|
||||
case 'database/role':
|
||||
resolve([{ id: 'my-role', backend: 'database' }]);
|
||||
break;
|
||||
default:
|
||||
reject({ httpStatus: 404, message: 'not found' });
|
||||
break;
|
||||
}
|
||||
reject({ httpStatus: 404, message: 'not found' });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
module('Integration | Component | get-credentials-card', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function() {
|
||||
run(() => {
|
||||
this.owner.unregister('service:store');
|
||||
this.owner.register('service:store', storeService);
|
||||
this.set('title', TITLE);
|
||||
this.set('searchLabel', SEARCH_LABEL);
|
||||
});
|
||||
});
|
||||
|
||||
test('it shows a disabled button when no item is selected', async function(assert) {
|
||||
await render(hbs`<GetCredentialsCard @title={{title}} @searchLabel={{searchLabel}}/>`);
|
||||
assert.dom('[data-test-get-credentials]').isDisabled();
|
||||
});
|
||||
|
||||
test('it shows button that can be clicked to credentials route when an item is selected', async function(assert) {
|
||||
const models = ['database/role'];
|
||||
this.set('models', models);
|
||||
await render(hbs`<GetCredentialsCard @title={{title}} @searchLabel={{searchLabel}} @models={{models}}/>`);
|
||||
await clickTrigger();
|
||||
await selectChoose('', 'my-role');
|
||||
assert.dom('[data-test-get-credentials]').isEnabled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
import { module, test } from 'qunit';
|
||||
import EmberObject from '@ember/object';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
const minimumAttr = {
|
||||
name: 'my-input',
|
||||
type: 'text',
|
||||
};
|
||||
const customLabelAttr = {
|
||||
name: 'test-input',
|
||||
type: 'text',
|
||||
options: {
|
||||
subText: 'Subtext here',
|
||||
label: 'Custom-label',
|
||||
},
|
||||
};
|
||||
|
||||
module('Integration | Component | readonly-form-field', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function(assert) {
|
||||
this.set('attr', EmberObject.create(minimumAttr));
|
||||
await render(hbs`<ReadonlyFormField @attr={{attr}} @value="value" />`);
|
||||
assert
|
||||
.dom('[data-test-readonly-label]')
|
||||
.includesText('My input', 'formats the attr name when no label provided');
|
||||
assert.dom(`[data-test-input="${minimumAttr.name}"]`).hasValue('value', 'Uses the value as passed');
|
||||
assert.dom(`[data-test-input="${minimumAttr.name}"]`).hasAttribute('readonly');
|
||||
});
|
||||
|
||||
test('it renders with options', async function(assert) {
|
||||
this.set('attr', customLabelAttr);
|
||||
await render(hbs`<ReadonlyFormField @attr={{attr}} @value="another value" />`);
|
||||
assert
|
||||
.dom('[data-test-readonly-label]')
|
||||
.includesText('Custom-label', 'Uses the provided label as passed');
|
||||
assert.dom('.sub-text').includesText('Subtext here', 'Renders subtext');
|
||||
assert
|
||||
.dom(`[data-test-input="${customLabelAttr.name}"]`)
|
||||
.hasValue('another value', 'Uses the value as passed');
|
||||
assert.dom(`[data-test-input="${customLabelAttr.name}"]`).hasAttribute('readonly');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { addToArray } from '../../../helpers/add-to-array';
|
||||
|
||||
module('Integration | Helper | add-to-array', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it correctly adds a value to an array without mutating the original', function(assert) {
|
||||
const ARRAY = ['horse', 'cow', 'chicken'];
|
||||
const result = addToArray([ARRAY, 'pig']);
|
||||
assert.deepEqual(result, [...ARRAY, 'pig'], 'Result has additional item');
|
||||
assert.deepEqual(ARRAY, ['horse', 'cow', 'chicken'], 'original array is not mutated');
|
||||
});
|
||||
|
||||
test('it fails if the first value is not an array', function(assert) {
|
||||
let result;
|
||||
try {
|
||||
result = addToArray(['not-array', 'string']);
|
||||
} catch (e) {
|
||||
result = e.message;
|
||||
}
|
||||
assert.equal('Assertion Failed: Value provided is not an array', result);
|
||||
});
|
||||
|
||||
test('it works with non-string arrays', function(assert) {
|
||||
const ARRAY = ['five', 6, '7'];
|
||||
const result = addToArray([ARRAY, 10]);
|
||||
assert.deepEqual(result, ['five', 6, '7', 10], 'added number value');
|
||||
});
|
||||
|
||||
test('it de-dupes the result', function(assert) {
|
||||
const ARRAY = ['horse', 'cow', 'chicken'];
|
||||
const result = addToArray([ARRAY, 'horse']);
|
||||
assert.deepEqual(result, ['horse', 'cow', 'chicken']);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
module('Integration | Helper | format-duration', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it supports strings and formats seconds', async function(assert) {
|
||||
await render(hbs`<p data-test-format-duration>Date: {{format-duration '3606'}}</p>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-format-duration]')
|
||||
.includesText('1 hour 6 seconds', 'it renders the duration in hours and seconds');
|
||||
});
|
||||
|
||||
test('it is able to format seconds and days', async function(assert) {
|
||||
await render(hbs`<p data-test-format-duration>Date: {{format-duration '93606000'}}</p>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-format-duration]')
|
||||
.includesText(
|
||||
'2 years 11 months 18 days 9 hours 40 minutes',
|
||||
'it renders with years months and days and hours and minutes'
|
||||
);
|
||||
});
|
||||
|
||||
test('it is able to format numbers', async function(assert) {
|
||||
this.set('number', 60);
|
||||
await render(hbs`<p data-test-format-duration>Date: {{format-duration number}}</p>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-format-duration]')
|
||||
.includesText('1 minute', 'it renders duration when a number is passed in.');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
const emptyObject = {};
|
||||
|
||||
const nonEmptyObject = { thing: 0 };
|
||||
|
||||
module('Integration | Helper | is-empty-object', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it is truthy if the object evaluated is an empty object', async function(assert) {
|
||||
this.set('inputValue', emptyObject);
|
||||
|
||||
await render(hbs`
|
||||
{{#if (is-empty-object inputValue)}}
|
||||
Empty
|
||||
{{else}}
|
||||
Full
|
||||
{{/if}}
|
||||
`);
|
||||
|
||||
assert.equal(this.element.textContent.trim(), 'Empty');
|
||||
});
|
||||
test('it is falsy if the object evaluated is not an empty object', async function(assert) {
|
||||
this.set('inputValue', nonEmptyObject);
|
||||
|
||||
await render(hbs`
|
||||
{{#if (is-empty-object inputValue)}}
|
||||
Empty
|
||||
{{else}}
|
||||
Full
|
||||
{{/if}}
|
||||
`);
|
||||
|
||||
assert.equal(this.element.textContent.trim(), 'Full');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { removeFromArray } from '../../../helpers/remove-from-array';
|
||||
|
||||
module('Integration | Helper | remove-from-array', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it correctly removes a value from an array without mutating the original', function(assert) {
|
||||
const ARRAY = ['horse', 'cow', 'chicken'];
|
||||
const result = removeFromArray([ARRAY, 'horse']);
|
||||
assert.deepEqual(result, ['cow', 'chicken'], 'Result does not have removed item');
|
||||
assert.deepEqual(ARRAY, ['horse', 'cow', 'chicken'], 'original array is not mutated');
|
||||
});
|
||||
|
||||
test('it returns the same value if the item is not found', function(assert) {
|
||||
const ARRAY = ['horse', 'cow', 'chicken'];
|
||||
const result = removeFromArray([ARRAY, 'pig']);
|
||||
assert.deepEqual(result, ARRAY, 'Results are the same as original array');
|
||||
});
|
||||
|
||||
test('it fails if the first value is not an array', function(assert) {
|
||||
let result;
|
||||
try {
|
||||
result = removeFromArray(['not-array', 'string']);
|
||||
} catch (e) {
|
||||
result = e.message;
|
||||
}
|
||||
assert.equal('Assertion Failed: Value provided is not an array', result);
|
||||
});
|
||||
|
||||
test('it works with non-string arrays', function(assert) {
|
||||
const ARRAY = ['five', 6, '7'];
|
||||
const result1 = removeFromArray([ARRAY, 6]);
|
||||
const result2 = removeFromArray([ARRAY, 7]);
|
||||
assert.deepEqual(result1, ['five', '7'], 'removed number value');
|
||||
assert.deepEqual(result2, ARRAY, 'did not match on different types');
|
||||
});
|
||||
|
||||
test('it de-dupes the result', function(assert) {
|
||||
const ARRAY = ['horse', 'cow', 'chicken', 'cow'];
|
||||
const result = removeFromArray([ARRAY, 'horse']);
|
||||
assert.deepEqual(result, ['cow', 'chicken']);
|
||||
});
|
||||
});
|
|
@ -17,7 +17,7 @@ export default create({
|
|||
createIsPresent: isPresent('[data-test-secret-create]'),
|
||||
configure: clickable('[data-test-secret-backend-configure]'),
|
||||
configureIsPresent: isPresent('[data-test-secret-backend-configure]'),
|
||||
tabs: collection('[data-test-tab]'),
|
||||
tabs: collection('[data-test-secret-list-tab]'),
|
||||
filterInput: fillable('[data-test-nav-input] input'),
|
||||
filterInputValue: value('[data-test-nav-input] input'),
|
||||
secrets: collection('[data-test-secret-link]', {
|
||||
|
|
|
@ -524,10 +524,10 @@ module('Unit | Machine | secrets-machine', function() {
|
|||
event: 'CONTINUE',
|
||||
params: 'database',
|
||||
expectedResults: {
|
||||
value: 'list',
|
||||
value: 'details',
|
||||
actions: [
|
||||
{ type: 'render', level: 'step', component: 'wizard/secrets-list' },
|
||||
{ type: 'render', level: 'feature', component: 'wizard/mounts-wizard' },
|
||||
{ type: 'render', level: 'step', component: 'wizard/secrets-details' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue