diff --git a/ui/app/adapters/permissions.js b/ui/app/adapters/permissions.js new file mode 100644 index 0000000000..c79a207371 --- /dev/null +++ b/ui/app/adapters/permissions.js @@ -0,0 +1,11 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + query() { + return this.ajax(this.urlForQuery(), 'GET'); + }, + + urlForQuery() { + return this.buildURL() + '/internal/ui/resultant-acl'; + }, +}); diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index 6658546158..7a8a7bee4c 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -158,6 +158,9 @@ export default Component.extend(DEFAULTS, { handleError(e) { this.set('loading', false); + if (!e.errors) { + return e; + } let errors = e.errors.map(error => { if (error.detail) { return error.detail; diff --git a/ui/app/components/nav-header.js b/ui/app/components/nav-header.js index d295eb60a0..509b88ed4b 100644 --- a/ui/app/components/nav-header.js +++ b/ui/app/components/nav-header.js @@ -1,5 +1,4 @@ import Component from '@ember/component'; - export default Component.extend({ 'data-test-navheader': true, classNameBindings: 'consoleFullscreen:panel-fullscreen', diff --git a/ui/app/components/wizard/features-selection.js b/ui/app/components/wizard/features-selection.js index f43bd4b456..02c7880853 100644 --- a/ui/app/components/wizard/features-selection.js +++ b/ui/app/components/wizard/features-selection.js @@ -44,6 +44,7 @@ export default Component.extend({ steps: ['Enabling a secrets engine', 'Adding a secret'], selected: false, show: true, + permission: 'secrets', }, { key: 'authentication', @@ -51,6 +52,7 @@ export default Component.extend({ steps: ['Enabling an auth method', 'Managing your auth method'], selected: false, show: true, + permission: 'access', }, { key: 'policies', @@ -63,6 +65,7 @@ export default Component.extend({ ], selected: false, show: true, + permission: 'policies', }, { key: 'replication', @@ -70,6 +73,7 @@ export default Component.extend({ steps: ['Setting up replication', 'Your cluster information'], selected: false, show: true, + permission: 'status', }, { key: 'tools', @@ -77,6 +81,7 @@ export default Component.extend({ steps: ['Wrapping data', 'Lookup wrapped data', 'Rewrapping your data', 'Unwrapping your data'], selected: false, show: true, + permission: 'tools', }, ]; }), diff --git a/ui/app/controllers/vault/cluster.js b/ui/app/controllers/vault/cluster.js index ed9327fa3f..aaa1801782 100644 --- a/ui/app/controllers/vault/cluster.js +++ b/ui/app/controllers/vault/cluster.js @@ -7,6 +7,7 @@ export default Controller.extend({ store: service(), media: service(), router: service(), + permissions: service(), namespaceService: service('namespace'), vaultVersion: service('version'), diff --git a/ui/app/helpers/has-permission.js b/ui/app/helpers/has-permission.js new file mode 100644 index 0000000000..f9869a24e8 --- /dev/null +++ b/ui/app/helpers/has-permission.js @@ -0,0 +1,10 @@ +import Helper from '@ember/component/helper'; +import { inject as service } from '@ember/service'; + +export default Helper.extend({ + permissions: service(), + compute([navItem], { routeParams }) { + let permissions = this.permissions; + return permissions.hasNavPermission(navItem, routeParams); + }, +}); diff --git a/ui/app/helpers/route-params-for.js b/ui/app/helpers/route-params-for.js new file mode 100644 index 0000000000..652e047272 --- /dev/null +++ b/ui/app/helpers/route-params-for.js @@ -0,0 +1,10 @@ +import Helper from '@ember/component/helper'; +import { inject as service } from '@ember/service'; + +export default Helper.extend({ + permissions: service(), + compute([navItem]) { + let permissions = this.permissions; + return permissions.navPathParams(navItem); + }, +}); diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js index 6ecf365221..43700e0fcf 100644 --- a/ui/app/routes/vault/cluster.js +++ b/ui/app/routes/vault/cluster.js @@ -13,6 +13,7 @@ const POLL_INTERVAL_MS = 10000; export default Route.extend(ModelBoundaryRoute, ClusterRoute, { namespaceService: service('namespace'), version: service(), + permissions: service(), store: service(), auth: service(), currentCluster: service(), @@ -58,6 +59,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { const id = this.getClusterId(params); if (id) { this.get('auth').setCluster(id); + this.get('permissions').getPaths.perform(); return this.get('version').fetchFeatures(); } else { return reject({ httpStatus: 404, message: 'not found', path: params.cluster_name }); diff --git a/ui/app/routes/vault/cluster/logout.js b/ui/app/routes/vault/cluster/logout.js index b7f41a3929..81df5af429 100644 --- a/ui/app/routes/vault/cluster/logout.js +++ b/ui/app/routes/vault/cluster/logout.js @@ -8,6 +8,7 @@ export default Route.extend(ModelBoundaryRoute, { controlGroup: service(), flashMessages: service(), console: service(), + permissions: service(), modelTypes: computed(function() { return ['secret', 'secret-engine']; @@ -21,5 +22,6 @@ export default Route.extend(ModelBoundaryRoute, { this.clearModelCache(); this.replaceWith('vault.cluster'); this.get('flashMessages').clearMessages(); + this.get('permissions').reset(); }, }); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index e86adb6c59..87a4286e50 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -18,6 +18,7 @@ const BACKENDS = supportedAuthBackends(); export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; export default Service.extend({ + permissions: service(), namespace: service(), IDLE_TIMEOUT: 3 * 60e3, expirationCalcTS: null, @@ -308,7 +309,15 @@ export default Service.extend({ const adapter = this.clusterAdapter(); return adapter.authenticate(options).then(resp => { - return this.persistAuthData(options, resp.auth || resp.data, this.get('namespace.path')); + return this.persistAuthData(options, resp.auth || resp.data, this.get('namespace.path')).then( + authData => { + return this.get('permissions') + .getPaths.perform() + .then(() => { + return authData; + }); + } + ); }); }, diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js new file mode 100644 index 0000000000..5f7e4721bc --- /dev/null +++ b/ui/app/services/permissions.js @@ -0,0 +1,141 @@ +import Service, { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +const API_PATHS = { + secrets: { engine: 'cubbyhole/' }, + access: { + methods: 'sys/auth', + entities: 'identity/entities', + groups: 'identity/groups', + leases: 'sys/leases/lookup', + namespaces: 'sys/namespaces', + 'control-groups': 'sys/control-group/', + }, + policies: { + acl: 'sys/policies/acl', + rgp: 'sys/policies/rgp', + egp: 'sys/policies/egp', + }, + tools: { + wrap: 'sys/wrapping/wrap', + lookup: 'sys/wrapping/lookup', + unwrap: 'sys/wrapping/unwrap', + rewrap: 'sys/wrapping/rewrap', + random: 'sys/tools/random', + hash: 'sys/tools/hash', + }, + status: { + replication: 'sys/replication', + license: 'sys/license', + seal: 'sys/seal', + }, +}; + +const API_PATHS_TO_ROUTE_PARAMS = { + 'sys/auth': ['vault.cluster.access.methods'], + 'identity/entities': ['vault.cluster.access.identity', 'entities'], + 'identity/groups': ['vault.cluster.access.identity', 'groups'], + 'sys/leases/lookup': ['vault.cluster.access.leases'], + 'sys/namespaces': ['vault.cluster.access.namespaces'], + 'sys/control-group/': ['vault.cluster.access.control-groups'], +}; + +/* + The Permissions service is used to gate top navigation and sidebar items. It fetches + a users' policy from the resultant-acl endpoint and stores their allowed exact and glob + paths as state. It also has methods for checking whether a user has permission for a given + path. +*/ +export default Service.extend({ + exactPaths: null, + globPaths: null, + canViewAll: null, + store: service(), + auth: service(), + namespace: service(), + + getPaths: task(function*() { + if (this.paths) { + return; + } + + try { + let resp = yield this.get('store') + .adapterFor('permissions') + .query(); + this.setPaths(resp); + return; + } catch (err) { + // If no policy can be found, default to showing all nav items. + this.set('canViewAll', true); + } + }), + + setPaths(resp) { + this.set('exactPaths', resp.data.exact_paths); + this.set('globPaths', resp.data.glob_paths); + this.set('canViewAll', resp.data.root); + }, + + reset() { + this.set('exactPaths', null); + this.set('globPaths', null); + this.set('canViewAll', null); + }, + + hasNavPermission(navItem, routeParams) { + if (routeParams) { + return this.hasPermission(API_PATHS[navItem][routeParams]); + } + return Object.values(API_PATHS[navItem]).some(path => this.hasPermission(path)); + }, + + navPathParams(navItem) { + const path = Object.values(API_PATHS[navItem]).find(path => this.hasPermission(path)); + if (['policies', 'tools'].includes(navItem)) { + return path.split('/').lastObject; + } + + return API_PATHS_TO_ROUTE_PARAMS[path]; + }, + + pathNameWithNamespace(pathName) { + const namespace = this.get('namespace').path; + if (namespace) { + return `${namespace}/${pathName}`; + } else { + return pathName; + } + }, + + hasPermission(pathName) { + const path = this.pathNameWithNamespace(pathName); + + if (this.canViewAll || this.hasMatchingExactPath(path) || this.hasMatchingGlobPath(path)) { + return true; + } + return false; + }, + + hasMatchingExactPath(pathName) { + const exactPaths = this.get('exactPaths'); + if (exactPaths) { + const prefix = Object.keys(exactPaths).find(path => path.startsWith(pathName)); + return prefix && !this.isDenied(exactPaths[prefix]); + } + return false; + }, + + hasMatchingGlobPath(pathName) { + const globPaths = this.get('globPaths'); + if (globPaths) { + const matchingPath = Object.keys(globPaths).find(k => pathName.includes(k)); + return (matchingPath && !this.isDenied(globPaths[matchingPath])) || globPaths.hasOwnProperty(''); + } + return false; + }, + + isDenied(path) { + return path.capabilities.includes('deny'); + }, +}); diff --git a/ui/app/styles/components/features-selection.scss b/ui/app/styles/components/features-selection.scss index dd4bb3195e..8c931e6ee2 100644 --- a/ui/app/styles/components/features-selection.scss +++ b/ui/app/styles/components/features-selection.scss @@ -4,6 +4,12 @@ color: $grey; } +.access-information { + display: flex; + padding: $size-8 0px; + font-size: $size-8; +} + .feature-box { box-shadow: $box-shadow; border-radius: $radius; diff --git a/ui/app/templates/components/wizard/features-selection.hbs b/ui/app/templates/components/wizard/features-selection.hbs index c3b4356422..c50d084347 100644 --- a/ui/app/templates/components/wizard/features-selection.hbs +++ b/ui/app/templates/components/wizard/features-selection.hbs @@ -2,13 +2,17 @@
You did it! You now have access to your Vault and can start entering your data. We can help you get started with any of the options below
+You did it! You now have access to your Vault and can start entering your data. We can help you get started with any of the options below.
+Vault only shows links to pages that you have access to based on your policies. Contact your administrator if you need access changes.
+