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:
Angel Garbarino 2021-02-18 09:36:31 -07:00 committed by GitHub
parent 52845525e9
commit 59e83e2e6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 3082 additions and 151 deletions

3
changelog/10655.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Database secrets engine, supporting MongoDB only
```

View File

@ -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');
},
});

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -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;

View File

@ -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);
});
}
}

View File

@ -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);
});
}
}

View File

@ -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 dont 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}`);
});
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}
}

View File

@ -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;
}

View File

@ -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() {

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
export default helper(function isEmptyObject([object] /*, hash*/) {
return Object.keys(object).length === 0;
});

View File

@ -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,

View File

@ -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);

View File

@ -2,6 +2,7 @@ import { helper as buildHelper } from '@ember/component/helper';
const SUPPORTED_SECRET_BACKENDS = [
'aws',
'database',
'cubbyhole',
'generic',
'kv',

View File

@ -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

View File

@ -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'),
});

View File

@ -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'),
});

View File

@ -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 credentialss 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 credentialss 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'),
});

View File

@ -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,
{

View File

@ -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() {

View File

@ -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');

View File

@ -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');
},
},
});

View File

@ -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,
});
}
},

View File

@ -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());
},
});

View File

@ -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);

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -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;
},
});

View File

@ -198,3 +198,7 @@ $gutter-grey: #2a2f36;
.cm-s-auto-height.CodeMirror {
height: auto;
}
.cm-s-short.CodeMirror {
height: 100px;
}

View File

@ -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;

View File

@ -1,10 +1,5 @@
.replication-page {
.empty-state {
background: none;
.empty-state-message {
padding-bottom: $spacing-s;
border-bottom: 1px solid $grey-light;
}
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -100,3 +100,7 @@
.toggle[type='checkbox'].is-success:checked + label::before {
background: $blue;
}
.toggle-label {
width: 100%;
}

View File

@ -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">Its 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>

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -0,0 +1,102 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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)}}

View File

@ -9,12 +9,29 @@
{{yield}}
</div>
{{else}}
<div class="selectable-card is-rounded">
<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}}

View File

@ -0,0 +1,4 @@
<DatabaseListItem
@item={{item}}
/>

View File

@ -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}}

View File

@ -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>

View File

@ -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}}

View File

@ -18,11 +18,14 @@
</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"
)
)) as |backendLink|}}
{{#linked-block
backendLink
backend.id
class="list-item-row"
data-test-secret-backend-row=backend.id
@ -44,7 +47,7 @@
</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}}>
<LinkTo @route={{backendLink}} @model={{backend.id}} class="has-text-black has-text-weight-semibold" data-test-secret-path={{true}}>
{{backend.path}}
</LinkTo>
<br />
@ -97,6 +100,7 @@
</div>
</div>
{{/linked-block}}
{{/let}}
{{/each}}
{{#each unsupportedBackends as |backend|}}
<div class="list-item-row" data-test-secret-backend-row={{backend.id}}>

View File

@ -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,
});

View File

@ -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);
}
},
},
});

View File

@ -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';

View File

@ -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);

View File

@ -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);
}

View File

@ -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}}

View File

@ -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}}

View File

@ -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,6 +118,8 @@
label=labelString
}}
{{else if (eq attr.options.editType "ttl")}}
{{!-- TTL Picker --}}
<div class="field">
<TtlPicker2
@onChange={{action (action "setAndBroadcastTtl" valuePath)}}
@label={{labelString}}
@ -118,6 +128,37 @@
@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)

View File

@ -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}}
{{#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>

View File

@ -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}}

View File

@ -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}}

View File

@ -0,0 +1 @@
export { default } from 'core/components/readonly-form-field';

View File

@ -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();

View File

@ -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');
});
});

View File

@ -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();
});
});

View File

@ -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');
});
});

View File

@ -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']);
});
});

View File

@ -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.');
});
});

View File

@ -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');
});
});

View File

@ -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']);
});
});

View File

@ -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]', {

View File

@ -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' },
],
},
},