diff --git a/app/components/Policies/Manage.jsx b/app/components/Policies/Manage.jsx index 2ba526c..89506d1 100644 --- a/app/components/Policies/Manage.jsx +++ b/app/components/Policies/Manage.jsx @@ -9,6 +9,9 @@ import Dialog from 'material-ui/Dialog'; import TextField from 'material-ui/TextField'; import IconButton from 'material-ui/IconButton'; import FontIcon from 'material-ui/FontIcon'; +import JsonEditor from '../shared/JsonEditor.jsx'; +import hcltojson from 'hcl-to-json' +import jsonschema from './vault-policy-schema.json' export default class Manage extends React.Component { constructor(props) { @@ -17,11 +20,13 @@ export default class Manage extends React.Component { openEditModal: false, openNewPolicyModal: false, newPolicyErrorMessage: '', + newPolicyNameErrorMessage: '', openDeleteModal: false, - editingPolicy: -1, + focusPolicy: -1, deletingPolicy: '', policies: [], currentPolicy: '', + disableSubmit: false, errorMessage: '', forbidden: false, buttonColor: 'lightgrey' @@ -31,6 +36,7 @@ export default class Manage extends React.Component { this, 'updatePolicy', 'listPolicies', + 'policyChangeSetState', 'renderEditDialog', 'renderNewPolicyDialog', 'renderDeleteConfirmationDialog', @@ -45,23 +51,27 @@ export default class Manage extends React.Component { this.listPolicies(); } - updatePolicy() { - let policyName = this.state.editingPolicy; + updatePolicy(policyName, isNewPolicy) { axios.put(`/policy?vaultaddr=${encodeURI(window.localStorage.getItem("vaultUrl"))}&policy=${encodeURI(policyName)}&token=${encodeURI(window.localStorage.getItem("vaultAccessToken"))}`, { "Policy": this.state.currentPolicy }) .then((resp) => { - // Custom future logic on success - this.setState({ - errorMessage: '' - }); + if (isNewPolicy) { + let policies = this.state.policies; + policies.push({ name: policyName }); + this.setState({ + policies: policies + }); + } }) .catch((err) => { console.error(err.stack); - this.setState({ - errorMessage: err.response.data - }); + if(err.response.data.errors){ + this.setState({ + errorMessage: err.response.data.errors.join('
') + }); + } }) - + this.setState({ openNewPolicyModal: false }); this.setState({ openEditModal: false }); } @@ -90,89 +100,74 @@ export default class Manage extends React.Component { }); } + policyChangeSetState(v, syntaxCheckOk, schemaCheckOk) { + if (syntaxCheckOk && schemaCheckOk && v) { + this.setState({disableSubmit: false, currentPolicy: v}); + } else { + this.setState({disableSubmit: true}); + } + } + renderEditDialog() { const actions = [ this.setState({ openEditModal: false })} />, - this.updatePolicy()} /> + this.updatePolicy(this.state.focusPolicy, false)} /> ]; - let policyChanged = (e, v, ) => { - this.state.currentPolicy = e.target.value; - }; - return ( this.setState({ openEditModal: false })} autoScrollBodyContent={true} > - policyChanged(e, v)} - name="editingText" multiLine={true} - autoFocus - defaultValue={this.state.currentPolicy} - fullWidth={true} /> + ); } renderNewPolicyDialog() { const MISSING_POLICY_ERROR = "Policy cannot be empty."; - const DUPLICATE_POLICY_ERROR = `Policy ${this.state.newPolicy.name} already exists.`; + const DUPLICATE_POLICY_ERROR = `Policy ${this.state.focusPolicy} already exists.`; let validateAndSubmit = () => { - if (this.state.newPolicy.name === '') { + if (this.state.focusPolicy === '') { this.setState({ newPolicyErrorMessage: MISSING_POLICY_ERROR }); return; } - if (_.filter(this.state.policies, x => x.name === this.state.newPolicy.name).length > 0) { + if (_.filter(this.state.policies, x => x.name === this.state.focusPolicy).length > 0) { this.setState({ newPolicyErrorMessage: DUPLICATE_POLICY_ERROR }); return; } - - axios.put(`/policy?vaultaddr=${encodeURI(window.localStorage.getItem("vaultUrl"))}&policy=${encodeURI(this.state.newPolicy.name)}&token=${encodeURI(window.localStorage.getItem("vaultAccessToken"))}`, { "Policy": this.state.currentPolicy }) - .then((resp) => { - let policies = this.state.policies; - policies.push({ name: this.state.newPolicy.name }); - this.setState({ - policies: policies, - errorMessage: '' - }); - }) - .catch((err) => { - console.error(err.stack); - this.setState({ - errorMessage: err.response.data - }); - }) - - this.setState({ openNewPolicyModal: false }); + this.updatePolicy(this.state.focusPolicy, true); } const actions = [ this.setState({ openNewPolicyModal: false, newPolicyErrorMessage: '' })} />, - + ]; - let setNewPolicy = (e, v) => { - let currentPolicy = this.state.newPolicy; - if (e.target.name === "newName") { - currentPolicy.name = v; - } else if (e.target.name === "newRules") { - currentPolicy.rules = v; + let validatePolicyName = (event, v) => { + var pattern = /^[^\/&]+$/; + v = v.toLowerCase(); + if (v.match(pattern)) { + this.setState({newPolicyNameErrorMessage: '', focusPolicy: v}); + } else { + this.setState({newPolicyNameErrorMessage: 'Policy name contains illegal characters'}); } - this.setState({ - newPolicy: currentPolicy - }); } @@ -186,14 +181,21 @@ export default class Manage extends React.Component { autoScrollBodyContent={true} autoDetectWindowHeight={true} > - setNewPolicy(e, v)} /> setNewPolicy(e, v)} /> + hintText="Name" + errorText={this.state.newPolicyNameErrorMessage} + onChange={validatePolicyName} + /> +
{this.state.newPolicyErrorMessage}
); @@ -224,18 +226,26 @@ export default class Manage extends React.Component { axios.get(`/policy?vaultaddr=${encodeURI(window.localStorage.getItem("vaultUrl"))}&policy=${encodeURI(policyName)}&token=${encodeURI(window.localStorage.getItem("vaultAccessToken"))}`) .then((resp) => { let rules = resp.data.data.rules; + let rules_obj; // Attempt to parse into JSON incase a stringified JSON was sent try { - rules = JSON.parse(rules) + rules_obj = JSON.parse(rules); } catch (e) { } - let val = typeof rules == 'object' ? JSON.stringify(rules, null, 4) : rules; + if (!rules_obj) { + // Previous parse failed, attempt HCL to JSON conversion + rules_obj = hcltojson(rules); + } - this.setState({ - openEditModal: true, - editingPolicy: policyName, - currentPolicy: val - }); + if(rules_obj) { + this.setState({ + openEditModal: true, + focusPolicy: policyName, + currentPolicy: rules_obj, + disableSubmit: true, + errorMessage: '', + }); + } }) .catch((err) => { console.error(err.stack); @@ -252,10 +262,10 @@ export default class Manage extends React.Component { }); } else { let policies = this.state.policies; - let policyToDelete = _.find(policies, (policyToDelete) => { return policyToDelete.name === policyName }); å + let policyToDelete = _.find(policies, (policyToDelete) => { return policyToDelete.name === policyName }); policies = _.pull(policies, policyToDelete); this.setState({ - secrets: policies, + policies: policies, errorMessage: '' }); } @@ -317,7 +327,15 @@ export default class Manage extends React.Component { backgroundColor={this.state.buttonColor} hoverColor={green400} labelStyle={{ color: white }} - onTouchTap={() => this.setState({ openNewPolicyModal: true, newPolicy: { name: '', value: '' } })} />} + onTouchTap={() => this.setState({ + openNewPolicyModal: true, + errorMessage: '', + newPolicyErrorMessage: '', + newPolicyNameErrorMessage: '', + disableSubmit: true, + focusPolicy: '', + currentPolicy: { path: { 'sample/path' : { capabilities: ['read'] }} } + })} />} {this.state.errorMessage &&
diff --git a/app/components/Policies/vault-policy-schema.json b/app/components/Policies/vault-policy-schema.json new file mode 100644 index 0000000..068142a --- /dev/null +++ b/app/components/Policies/vault-policy-schema.json @@ -0,0 +1,50 @@ +{ + "type": "object", + "required": [ "path" ], + "properties": { + "path": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^[^\/].*$": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + {"required": ["capabilities"]}, + {"required": ["policy"]} + ], + "properties": { + "capabilities" : { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "list", + "sudo", + "deny" + ] + } + }, + "policy" : { + "type": "string", + "enum": [ + "read", + "write", + "sudo", + "deny" + ] + } + } + + } + } + } + } +} diff --git a/app/components/Secrets/Secrets.jsx b/app/components/Secrets/Secrets.jsx index a0562fb..0c91cfb 100644 --- a/app/components/Secrets/Secrets.jsx +++ b/app/components/Secrets/Secrets.jsx @@ -13,6 +13,7 @@ import FlatButton from 'material-ui/FlatButton'; import TextField from 'material-ui/TextField'; import { green500, green400, red500, red300, yellow500, white } from 'material-ui/styles/colors.js' import axios from 'axios'; +import JsonEditor from '../shared/JsonEditor.jsx'; const copyEvent = new CustomEvent("snackbar", { detail: { @@ -49,7 +50,8 @@ class Secrets extends React.Component { 'renderList', 'renderNamespace', 'clickSecret', - 'secretChanged', + 'secretChangedTextEditor', + 'secretChangedJsonEditor', 'updateSecret', 'renderEditDialog', 'renderNewKeyDialog', @@ -115,35 +117,19 @@ class Secrets extends React.Component { }) } - secretChanged(e, v) { - if (this.state.useRootKey) { - //If root key is in place, set as json object to properly escape special characters - let tmp = {}; - tmp = _.set(tmp, `${this.state.rootKey}`, v); - this.state.focusSecret = tmp; + secretChangedJsonEditor(v, syntaxCheckOk) { + if (syntaxCheckOk && v) { + this.setState({disableSubmit: false, focusSecret: v}); } else { - this.state.focusSecret = v; - + this.setState({disableSubmit: true}); } } - checkValidJson() { - try { - if (this.state.useRootKey) { - JSON.parse(JSON.stringify(this.state.focusSecret)); - } else { - JSON.parse(this.state.focusSecret); - } - this.setState({ - errorMessage: '' - }) - return true; - } catch (e) { - this.setState({ - errorMessage: `Invalid JSON` - }) - return false; - } + secretChangedTextEditor(e, v) { + this.setState({disableSubmit: false}); + let tmp = {}; + _.set(tmp, `${this.state.rootKey}`, v); + this.state.focusSecret = tmp; } renderEditDialog() { @@ -153,28 +139,47 @@ class Secrets extends React.Component { ]; let submitUpdate = () => { - if (!this.checkValidJson()) return; this.updateSecret(false); this.setState({ openEditModal: false }); } + var objectIsBasicRootKey = _.size(this.state.focusSecret) == 1 && this.state.focusSecret.hasOwnProperty(this.state.rootKey); + var content; + + if (objectIsBasicRootKey && this.state.useRootKey) { + var title = `Editing ${this.state.namespace}${this.state.focusKey} with specified root key`; + content = ( + + ); + } else { + var title = `Editing ${this.state.namespace}${this.state.focusKey}`; + content = ( + + ); + } return ( this.setState({ openEditModal: false })} autoScrollBodyContent={true} > - + {content}
{this.state.errorMessage}
); @@ -219,40 +224,52 @@ class Secrets extends React.Component { }); return; } - if (!this.checkValidJson()) return; + console.log(this.state.focusSecret); this.updateSecret(true); this.setState({ openNewKeyModal: false, errorMessage: '' }); } const actions = [ this.setState({ openNewKeyModal: false, errorMessage: '' })} />, - + ]; var rootKeyInfo; + var content; if (this.state.useRootKey) { rootKeyInfo = "Current Root Key: " + this.state.rootKey; + var content = ( + + ); } else { - rootKeyInfo = "No Root Key set. Value must be JSON."; + content = ( + + ); } return ( this.setState({ openNewKeyModal: false, errorMessage: '' })} autoScrollBodyContent={true} > - this.setState({ focusKey: v })} /> - + this.setState({ focusKey: v })} /> + {content}
{this.state.errorMessage}
{rootKeyInfo}
@@ -322,28 +339,15 @@ class Secrets extends React.Component { let fullKey = `${this.state.namespace}${key}`; axios.get(`/secret?vaultaddr=${encodeURI(window.localStorage.getItem("vaultUrl"))}&secret=${encodeURI(fullKey)}&token=${encodeURI(window.localStorage.getItem("vaultAccessToken"))}`) .then((resp) => { - let val = this.state.useRootKey ? _.get(resp, `data.${this.state.rootKey}`) : resp.data; - if (val === undefined) { - this.setState({ - errorMessage: `No value exists under the root key '${this.state.rootKey}'.`, - focusSecret: '', - disableSubmit: true, - openEditModal: true, - disableTextField: true, - listBackends: false - }); - } else { - val = typeof val == 'object' ? JSON.stringify(val) : val; - this.setState({ - errorMessage: '', - disableSubmit: false, - disableTextField: false, - openEditModal: true, - focusKey: key, - focusSecret: val, - listBackends: false - }); - } + this.setState({ + errorMessage: '', + disableSubmit: false, + disableTextField: false, + openEditModal: true, + focusKey: key, + focusSecret: resp.data, + listBackends: false + }); }) .catch((err) => { console.error(err.stack); @@ -449,12 +453,18 @@ class Secrets extends React.Component {

Secrets

Here you can view, update, and delete keys stored in your Vault. Just remember, deleting keys cannot be undone!

this.setState({ openNewKeyModal: true, focusKey: '', focusSecret: '', errorMessage: '' })} /> + onTouchTap={() => this.setState({ + disableSubmit: true, + openNewKeyModal: true, + focusKey: '', + focusSecret: '', + errorMessage: '' + })} />
{this.renderNamespace()}
{this.renderList()} diff --git a/app/components/shared/JsonEditor.jsx b/app/components/shared/JsonEditor.jsx new file mode 100644 index 0000000..f141d45 --- /dev/null +++ b/app/components/shared/JsonEditor.jsx @@ -0,0 +1,89 @@ +import React, { PropTypes } from 'react'; +import { browserHistory } from 'react-router'; +import JSONEditor from 'jsoneditor'; +import 'jsoneditor/src/css/reset.css'; +import 'jsoneditor/src/css/jsoneditor.css'; +import 'jsoneditor/src/css/menu.css'; +import 'jsoneditor/src/css/searchbox.css'; +import 'jsoneditor/src/css/contextmenu.css'; + +function isValid(value) { + return value !== '' && value !== undefined && value !== null; +}; + +class JsonEditor extends React.Component { + static propTypes = { + rootName: PropTypes.string, + value: PropTypes.any, + mode: PropTypes.oneOf(['tree', 'code']), + schema: PropTypes.object, + onChange: PropTypes.func, + }; + + static defaultProps = { + rootName: '', + value: '', + mode: 'tree', + schema: null, + onChange: () => {} + }; + + state = { + hasValue: false, + }; + + constructor(props) { + super(props); + if (typeof JSONEditor === undefined) { + throw new Error('JSONEditor is undefined!'); + } + } + + handleInputChange = () => { + try { + this.setState({hasValue: isValid(this._jsoneditor.get())}); + if (this.props.onChange) { + let schemaCheck = true; + if (this.props.schema) { + schemaCheck = this._jsoneditor.validateSchema(this._jsoneditor.get()); + } + this.props.onChange(this._jsoneditor.get(), this.state.hasValue, schemaCheck); + } + } catch (e) { + this.props.onChange(null, false, false); + } + } + + componentDidMount() { + var container = this.editorEl;//ReactDOM.findDOMNode(this); + var options = { + name: this.props.rootName, + mode: this.props.mode, + modes: ['tree', 'code'], + schema: this.props.schema, + onChange: this.handleInputChange, + }; + + this._jsoneditor = new JSONEditor(container, options, this.props.value); + this.setState({hasValue: true}); + this._jsoneditor.focus(); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.rootName !== this.props.rootName) { + this._jsoneditor.setName(nextProps.rootName); + } + } + + componentWillUnmount() { + this._jsoneditor.destroy(); + } + + render() { + return ( +
{ this.editorEl = c; }} /> + ); + } +} + +export default JsonEditor; diff --git a/docker-compose.yaml b/docker-compose.yaml index d40f432..0671c6f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,6 +19,9 @@ services: volumes: - .:/app - /app/node_modules + environment: + VAULT_URL_DEFAULT: http://vault:8200 + VAULT_AUTH_DEFAULT: USERNAMEPASSWORD webpack: build: . diff --git a/package.json b/package.json index 3619de0..e10339c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "babel-preset-react": "^6.16.0", "babel-preset-stage-2": "^6.18.0", "css-loader": "^0.25.0", + "url-loader": "^0.5.7", + "json-loader": "^0.5.4", "extract-text-webpack-plugin": "^1.0.1", "material-ui": "^0.16.4", "postcss-loader": "^1.1.0", @@ -53,6 +55,7 @@ "hcl-to-json": "0.0.4", "lodash": "^4.16.6", "material-ui": "^0.16.1", + "jsoneditor": "^5.5.11", "react": "^15.4.0", "react-dom": "^15.4.0", "react-router": "^3.0.0", diff --git a/run-docker-compose-dev b/run-docker-compose-dev index f97af15..1b74f02 100755 --- a/run-docker-compose-dev +++ b/run-docker-compose-dev @@ -19,6 +19,7 @@ exec_in_vault vault status exec_in_vault vault auth-enable userpass exec_in_vault vault policy-write admin /app/admin.hcl exec_in_vault vault write auth/userpass/users/test password=test policies=admin +exec_in_vault vault write secret/test somekey=somedata echo "------------- Vault Root Token -------------" docker-compose logs vault | grep 'Root Token:' | tail -n 1 diff --git a/src/policies.js b/src/policies.js index c0e14d1..418b155 100644 --- a/src/policies.js +++ b/src/policies.js @@ -48,16 +48,8 @@ exports.updatePolicy = function (req, res) { //API requires an escaped JSON let policy = _.get(req, "body.Policy"); - // Attempt to parse into JSON incase a stringified JSON was sent - try { - policy = JSON.parse(policy) - } catch (e) { } - - //If the user passed in an HCL document, convert to stringified JSON as required by the API - let rules = typeof policy == 'object' ? JSON.stringify(policy) : JSON.stringify(hcltojson(policy)); - let body = { - rules: rules + rules: JSON.stringify(policy) }; axios.put(`${vaultAddr}${endpoint}`, body, config) @@ -65,8 +57,7 @@ exports.updatePolicy = function (req, res) { res.json(resp.data); }) .catch((err) => { - console.error(err.stack); - res.status(err.response.status).send(err.response); + res.status(err.response.status).send(err.response.data); }); } diff --git a/webpack.config.js b/webpack.config.js index 2508c48..edc7b30 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,11 +21,19 @@ module.exports = { exclude: 'node_modules' }, { test: /\.json$/i, - loader: 'json', + loader: 'json-loader', exclude: 'node_modules' + }, { + test: /\.svg$/, + loader: 'url' + }, { + test: /\.css$/, + loader: ExtractTextPlugin.extract('css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader'), + exclude: /node_modules/ }, { test: /\.css$/, - loader: ExtractTextPlugin.extract('css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader') + loader: ExtractTextPlugin.extract('css!postcss-loader'), + include: /node_modules/ }] }, postcss: [ autoprefixer({ browsers: ['last 2 versions'] }) ], @@ -43,6 +51,7 @@ module.exports = { // comments: false, // sourceMap: false // }), - new ExtractTextPlugin("styles.css") + new ExtractTextPlugin("styles.css"), + new webpack.IgnorePlugin(/regenerator|nodent|js-beautify/, /ajv/) ] };