From 20deed3a3dc06acb78ac37992394bd445763d2ea Mon Sep 17 00:00:00 2001 From: Noelle Daley Date: Fri, 18 Jan 2019 14:04:40 -0800 Subject: [PATCH] Add Policy-based Navigation (#5967) * add permissions service * start template helper * match prefixed paths * gate sidebar links * land on first page user has access to * show nav when user first logs in * clear paths when user logs out * add tests * implement feedback * show all nav items if no policy is found * update onboarding wizard * fix some unrelated tests * add support for namespaces * gate wizard * unstage package and lockfile --- ui/app/adapters/permissions.js | 11 + ui/app/components/auth-form.js | 3 + ui/app/components/nav-header.js | 1 - .../components/wizard/features-selection.js | 5 + ui/app/controllers/vault/cluster.js | 1 + ui/app/helpers/has-permission.js | 10 + ui/app/helpers/route-params-for.js | 10 + ui/app/routes/vault/cluster.js | 2 + ui/app/routes/vault/cluster/logout.js | 2 + ui/app/services/auth.js | 11 +- ui/app/services/permissions.js | 141 +++++++++++++ .../styles/components/features-selection.scss | 6 + .../components/wizard/features-selection.hbs | 8 +- ui/app/templates/partials/status/cluster.hbs | 193 ++++++++++-------- ui/app/templates/vault/cluster.hbs | 79 ++++--- ui/app/templates/vault/cluster/access.hbs | 96 +++++---- ui/app/templates/vault/cluster/policies.hbs | 64 +++--- ui/app/templates/vault/cluster/policy.hbs | 64 +++--- ui/app/templates/vault/cluster/tools/tool.hbs | 18 +- ui/config/environment.js | 2 +- ui/tests/acceptance/cluster-test.js | 71 +++++++ .../integration/components/auth-form-test.js | 4 +- ui/tests/unit/services/permissions-test.js | 162 +++++++++++++++ 23 files changed, 725 insertions(+), 239 deletions(-) create mode 100644 ui/app/adapters/permissions.js create mode 100644 ui/app/helpers/has-permission.js create mode 100644 ui/app/helpers/route-params-for.js create mode 100644 ui/app/services/permissions.js create mode 100644 ui/tests/acceptance/cluster-test.js create mode 100644 ui/tests/unit/services/permissions-test.js 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 @@

Choosing where to go

-

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.

+
{{#if (or (has-feature "Performance Replication") (has-feature "DR Replication")) }} {{/if}}

Walk me through setting up:

{{#each allFeatures as |feature|}} - {{#if feature.show}} + {{#if (and feature.show (has-permission feature.permission))}}
{{#if (and activeCluster.unsealed auth.currentToken)}} - +
+ {{/if}} {{/if}} {{#unless version.isOSS}} + {{#if (has-permission 'status' routeParams='license')}} + +
+ {{/if}} + {{/unless}} -
- {{/unless}} -
diff --git a/ui/app/templates/vault/cluster.hbs b/ui/app/templates/vault/cluster.hbs index a0ad8b8ba0..023de0acd4 100644 --- a/ui/app/templates/vault/cluster.hbs +++ b/ui/app/templates/vault/cluster.hbs @@ -17,43 +17,54 @@ {{/if}} -
  • - {{#link-to - "vault.cluster.secrets" - current-when="vault.cluster.secrets vault.cluster.settings.mount-secret-backend vault.cluster.settings.configure-secret-backend" - invokeAction=(action Nav.closeDrawer) - }} - Secrets - {{/link-to}} -
  • -
  • - {{#link-to - "vault.cluster.access" + {{#if (has-permission 'secrets')}} +
  • + {{#link-to + "vault.cluster.secrets" + current-when="vault.cluster.secrets vault.cluster.settings.mount-secret-backend vault.cluster.settings.configure-secret-backend" + invokeAction=(action Nav.closeDrawer) + data-test-navbar-item='secrets' + }} + Secrets + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission 'access')}} +
  • + {{#link-to + params=(route-params-for 'access') current-when="vault.cluster.access vault.cluster.settings.auth" invokeAction=(action Nav.closeDrawer) + data-test-navbar-item='access' + }} + Access + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission 'policies')}} +
  • + {{#link-to + "vault.cluster.policies" + (route-params-for 'policies') + current-when="vault.cluster.policies vault.cluster.policy" + invokeAction=(action Nav.closeDrawer) + data-test-navbar-item='policies' }} - Access - {{/link-to}} -
  • -
  • - {{#link-to - "vault.cluster.policies" - "acl" - current-when="vault.cluster.policies vault.cluster.policy" - invokeAction=(action Nav.closeDrawer) - }} - Policies - {{/link-to}} -
  • -
  • - {{#link-to - "vault.cluster.tools.tool" - "wrap" - invokeAction=(action Nav.closeDrawer) - }} - Tools - {{/link-to}} -
  • + Policies + {{/link-to}} + + {{/if}} + {{#if (has-permission 'tools')}} +
  • + {{#link-to + "vault.cluster.tools.tool" + (route-params-for 'tools') + invokeAction=(action Nav.closeDrawer) + }} + Tools + {{/link-to}} +
  • + {{/if}} diff --git a/ui/app/templates/vault/cluster/access.hbs b/ui/app/templates/vault/cluster/access.hbs index dce9fc4dd0..d6498ed54d 100644 --- a/ui/app/templates/vault/cluster/access.hbs +++ b/ui/app/templates/vault/cluster/access.hbs @@ -1,47 +1,59 @@
    {{#menu-sidebar title="Access" class="is-3" data-test-sidebar=true}} -
  • - {{#link-to "vault.cluster.access.methods" data-test-link=true current-when="vault.cluster.access.methods vault.cluster.access.method"}} - Auth Methods - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.access.identity" "entities" data-test-link=true }} - Entities - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.access.identity" "groups" data-test-link=true }} - Groups - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.access.leases" data-test-link=true}} - Leases - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.access.namespaces" data-test-link=true }} - Namespaces - {{#unless (has-feature "Namespaces")}} - {{#if (is-version "OSS")}} - {{edition-badge edition="Enterprise"}} - {{/if}} - {{/unless}} - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.access.control-groups" data-test-link=true current-when="vault.cluster.access.control-groups vault.cluster.access.control-group-accessor vault.cluster.access.control-groups-configure"}} - Control Groups - {{#unless (has-feature "Control Groups")}} - {{#if (is-version "OSS")}} - {{edition-badge edition="Enterprise"}} - {{else}} - {{edition-badge edition="Premium"}} - {{/if}} - {{/unless}} - {{/link-to}} -
  • + {{#if (has-permission "access" routeParams="methods")}} +
  • + {{#link-to "vault.cluster.access.methods" data-test-link=true current-when="vault.cluster.access.methods vault.cluster.access.method"}} + Auth Methods + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "access" routeParams="entities")}} +
  • + {{#link-to "vault.cluster.access.identity" "entities" data-test-link=true }} + Entities + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "access" routeParams="groups")}} +
  • + {{#link-to "vault.cluster.access.identity" "groups" data-test-link=true }} + Groups + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "access" routeParams="leases")}} +
  • + {{#link-to "vault.cluster.access.leases" data-test-link=true}} + Leases + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "access" routeParams="namespaces")}} +
  • + {{#link-to "vault.cluster.access.namespaces" data-test-link=true }} + Namespaces + {{#unless (has-feature "Namespaces")}} + {{#if (is-version "OSS")}} + {{edition-badge edition="Enterprise"}} + {{/if}} + {{/unless}} + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "access" routeParams="control-groups")}} +
  • + {{#link-to "vault.cluster.access.control-groups" data-test-link=true current-when="vault.cluster.access.control-groups vault.cluster.access.control-group-accessor vault.cluster.access.control-groups-configure"}} + Control Groups + {{#unless (has-feature "Control Groups")}} + {{#if (is-version "OSS")}} + {{edition-badge edition="Enterprise"}} + {{else}} + {{edition-badge edition="Premium"}} + {{/if}} + {{/unless}} + {{/link-to}} +
  • + {{/if}} {{/menu-sidebar}}
    {{outlet}} diff --git a/ui/app/templates/vault/cluster/policies.hbs b/ui/app/templates/vault/cluster/policies.hbs index baaf6ae873..861b7b8dd2 100644 --- a/ui/app/templates/vault/cluster/policies.hbs +++ b/ui/app/templates/vault/cluster/policies.hbs @@ -1,36 +1,42 @@
    {{#menu-sidebar title="Policies" class="is-3" data-test-sidebar=true}} -
  • - {{#link-to "vault.cluster.policies" "acl" data-test-link=true class=(if (is-active-route "vault.cluster.policies" "acl") "is-active")}} - ACL Policies - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.policies" "rgp" data-test-link=true class=(if (is-active-route "vault.cluster.policies" "rgp") "is-active")}} - Role Governing Policies + {{#if (has-permission "policies" routeParams="acl")}} +
  • + {{#link-to "vault.cluster.policies" "acl" data-test-link=true class=(if (is-active-route "vault.cluster.policies" "acl") "is-active")}} + ACL Policies + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "policies" routeParams="rgp")}} +
  • + {{#link-to "vault.cluster.policies" "rgp" data-test-link=true class=(if (is-active-route "vault.cluster.policies" "rgp") "is-active")}} + Role Governing Policies - {{#unless (has-feature "Sentinel")}} - {{#if (is-version "OSS")}} - {{edition-badge edition="Enterprise"}} - {{else}} - {{edition-badge edition="Premium"}} - {{/if}} - {{/unless}} - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.policies" "egp" data-test-link=true class=(if (is-active-route "vault.cluster.policies" "egp") "is-active")}} - Endpoint Governing Policies + {{#unless (has-feature "Sentinel")}} + {{#if (is-version "OSS")}} + {{edition-badge edition="Enterprise"}} + {{else}} + {{edition-badge edition="Premium"}} + {{/if}} + {{/unless}} + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "policies" routeParams="egp")}} +
  • + {{#link-to "vault.cluster.policies" "egp" data-test-link=true class=(if (is-active-route "vault.cluster.policies" "egp") "is-active")}} + Endpoint Governing Policies - {{#unless (has-feature "Sentinel")}} - {{#if (is-version "OSS")}} - {{edition-badge edition="Enterprise"}} - {{else}} - {{edition-badge edition="Premium"}} - {{/if}} - {{/unless}} - {{/link-to}} -
  • + {{#unless (has-feature "Sentinel")}} + {{#if (is-version "OSS")}} + {{edition-badge edition="Enterprise"}} + {{else}} + {{edition-badge edition="Premium"}} + {{/if}} + {{/unless}} + {{/link-to}} + + {{/if}} {{/menu-sidebar}}
    {{outlet}} diff --git a/ui/app/templates/vault/cluster/policy.hbs b/ui/app/templates/vault/cluster/policy.hbs index e9fb3b4097..8707f25ba8 100644 --- a/ui/app/templates/vault/cluster/policy.hbs +++ b/ui/app/templates/vault/cluster/policy.hbs @@ -1,34 +1,40 @@
    {{#menu-sidebar title="Policies" class="is-3" data-test-sidebar=true}} -
  • - {{#link-to "vault.cluster.policies" "acl" data-test-link=true class=(if (is-active-route "vault.cluster.policy" "acl") "is-active")}} - ACL Policies - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.policies" "rgp" data-test-link=true class=(if (is-active-route "vault.cluster.policy" "rgp") "is-active")}} - Role Governing Policies - {{#unless (has-feature "Sentinel")}} - {{#if (is-version "OSS")}} - {{edition-badge edition="Enterprise"}} - {{else}} - {{edition-badge edition="Premium"}} - {{/if}} - {{/unless}} - {{/link-to}} -
  • -
  • - {{#link-to "vault.cluster.policies" "egp" data-test-link=true class=(if (is-active-route "vault.cluster.policy" "egp") "is-active")}} - Endpoint Governing Policies - {{#unless (has-feature "Sentinel")}} - {{#if (is-version "OSS")}} - {{edition-badge edition="Enterprise"}} - {{else}} - {{edition-badge edition="Premium"}} - {{/if}} - {{/unless}} - {{/link-to}} -
  • + {{#if (has-permission "policies" routeParams="acl")}} +
  • + {{#link-to "vault.cluster.policies" "acl" data-test-link=true class=(if (is-active-route "vault.cluster.policy" "acl") "is-active")}} + ACL Policies + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "policies" routeParams="rgp")}} +
  • + {{#link-to "vault.cluster.policies" "rgp" data-test-link=true class=(if (is-active-route "vault.cluster.policy" "rgp") "is-active")}} + Role Governing Policies + {{#unless (has-feature "Sentinel")}} + {{#if (is-version "OSS")}} + {{edition-badge edition="Enterprise"}} + {{else}} + {{edition-badge edition="Premium"}} + {{/if}} + {{/unless}} + {{/link-to}} +
  • + {{/if}} + {{#if (has-permission "policies" routeParams="egp")}} +
  • + {{#link-to "vault.cluster.policies" "egp" data-test-link=true class=(if (is-active-route "vault.cluster.policy" "egp") "is-active")}} + Endpoint Governing Policies + {{#unless (has-feature "Sentinel")}} + {{#if (is-version "OSS")}} + {{edition-badge edition="Enterprise"}} + {{else}} + {{edition-badge edition="Premium"}} + {{/if}} + {{/unless}} + {{/link-to}} +
  • + {{/if}} {{/menu-sidebar}}
    {{outlet}} diff --git a/ui/app/templates/vault/cluster/tools/tool.hbs b/ui/app/templates/vault/cluster/tools/tool.hbs index 3104b83c66..ca96eafdea 100644 --- a/ui/app/templates/vault/cluster/tools/tool.hbs +++ b/ui/app/templates/vault/cluster/tools/tool.hbs @@ -1,14 +1,16 @@
    {{#menu-sidebar title="Tools" class="is-3"}} {{#each (tools-actions) as |supportedAction|}} -
  • - {{#link-to 'vault.cluster.tools.tool' supportedAction refreshModel=true - class="(if (eq supportedAction selectedAction) 'is-active')" - data-test-tools-action-link=supportedAction - }} - {{capitalize supportedAction}} - {{/link-to}} -
  • + {{#if (has-permission "tools" routeParams=supportedAction)}} +
  • + {{#link-to 'vault.cluster.tools.tool' supportedAction refreshModel=true + class="(if (eq supportedAction selectedAction) 'is-active')" + data-test-tools-action-link=supportedAction + }} + {{capitalize supportedAction}} + {{/link-to}} +
  • + {{/if}} {{/each}} {{/menu-sidebar}}
    diff --git a/ui/config/environment.js b/ui/config/environment.js index 43558df1ad..25767b89d4 100644 --- a/ui/config/environment.js +++ b/ui/config/environment.js @@ -62,7 +62,7 @@ module.exports = function(environment) { ENV.flashMessageDefaults.timeout = 50; } if (environment !== 'production') { - ENV.APP.DEFAULT_PAGE_SIZE = 5; + ENV.APP.DEFAULT_PAGE_SIZE = 15; ENV.contentSecurityPolicyHeader = 'Content-Security-Policy'; ENV.contentSecurityPolicyMeta = true; ENV.contentSecurityPolicy = { diff --git a/ui/tests/acceptance/cluster-test.js b/ui/tests/acceptance/cluster-test.js new file mode 100644 index 0000000000..f1c633de44 --- /dev/null +++ b/ui/tests/acceptance/cluster-test.js @@ -0,0 +1,71 @@ +import { create } from 'ember-cli-page-object'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +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 tokenWithPolicy = async function(name, policy) { + await consoleComponent.runCommands([ + `write sys/policies/acl/${name} policy=${policy}`, + `write -field=client_token auth/token/create policies=${name}`, + ]); + + return consoleComponent.lastLogOutput; +}; + +module('Acceptance | cluster', function(hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(async function() { + await logout.visit(); + return authPage.login(); + }); + + test('hides nav item if user does not have permission', async function(assert) { + const deny_policies_policy = `' + path "sys/policies/*" { + capabilities = ["deny"] + }, + '`; + + const userToken = await tokenWithPolicy('hide-policies-nav', deny_policies_policy); + await logout.visit(); + await authPage.login(userToken); + + assert.dom('[data-test-navbar-item=policies]').doesNotExist(); + await logout.visit(); + }); + + test('shows nav item if user does have permission', async function(assert) { + const read_secrets_policy = `' + path "cubbyhole/" { + capabilities = ["read"] + }, + '`; + + const userToken = await tokenWithPolicy('show-secrets-nav', read_secrets_policy); + await logout.visit(); + await authPage.login(userToken); + + assert.dom('[data-test-navbar-item=secrets]').exists(); + await logout.visit(); + }); + + test('nav item links to first route that user has access to', async function(assert) { + const read_rgp_policy = `' + path "sys/policies/rgp" { + capabilities = ["read"] + }, + '`; + + const userToken = await tokenWithPolicy('show-policies-nav', read_rgp_policy); + await logout.visit(); + await authPage.login(userToken); + + assert.dom('[data-test-navbar-item=policies]').hasAttribute('href', '/ui/vault/policies/rgp'); + await logout.visit(); + }); +}); diff --git a/ui/tests/integration/components/auth-form-test.js b/ui/tests/integration/components/auth-form-test.js index cfc04db5e6..5515026bff 100644 --- a/ui/tests/integration/components/auth-form-test.js +++ b/ui/tests/integration/components/auth-form-test.js @@ -48,18 +48,17 @@ module('Integration | Component | auth form', function(hooks) { hooks.beforeEach(function() { this.owner.lookup('service:csp-event').attach(); - component.setContext(this); this.owner.register('service:router', routerService); this.router = this.owner.lookup('service:router'); }); hooks.afterEach(function() { this.owner.lookup('service:csp-event').remove(); - component.removeContext(); }); const CSP_ERR_TEXT = `Error This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`; test('it renders error on CSP violation', async function(assert) { + this.owner.unregister('service:auth'); this.owner.register('service:auth', authService); this.auth = this.owner.lookup('service:auth'); this.set('cluster', EmberObject.create({ standby: true })); @@ -156,6 +155,7 @@ module('Integration | Component | auth form', function(hooks) { }); test('it calls authenticate with the correct path', async function(assert) { + this.owner.unregister('service:auth'); this.owner.register('service:auth', workingAuthService); this.auth = this.owner.lookup('service:auth'); let authSpy = sinon.spy(this.get('auth'), 'authenticate'); diff --git a/ui/tests/unit/services/permissions-test.js b/ui/tests/unit/services/permissions-test.js new file mode 100644 index 0000000000..5f14c59dbe --- /dev/null +++ b/ui/tests/unit/services/permissions-test.js @@ -0,0 +1,162 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import Pretender from 'pretender'; +import Service from '@ember/service'; + +const PERMISSIONS_RESPONSE = { + data: { + exact_paths: { + foo: { + capabilities: ['read'], + }, + 'bar/bee': { + capabilities: ['create'], + }, + boo: { + capabilities: ['deny'], + }, + }, + glob_paths: { + 'baz/biz': { + capabilities: ['read'], + }, + }, + }, +}; + +module('Unit | Service | permissions', function(hooks) { + setupTest(hooks); + + hooks.beforeEach(function() { + this.server = new Pretender(); + this.server.get('/v1/sys/internal/ui/resultant-acl', () => { + return [200, { 'Content-Type': 'application/json' }, JSON.stringify(PERMISSIONS_RESPONSE)]; + }); + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + test('sets paths properly', async function(assert) { + let service = this.owner.lookup('service:permissions'); + await service.getPaths.perform(); + assert.deepEqual(service.get('exactPaths'), PERMISSIONS_RESPONSE.data.exact_paths); + assert.deepEqual(service.get('globPaths'), PERMISSIONS_RESPONSE.data.glob_paths); + }); + + test('returns true if a policy includes access to an exact path', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); + assert.equal(service.hasPermission('foo'), true); + }); + + test('returns true if a paths prefix is included in the policys exact paths', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); + assert.equal(service.hasPermission('bar'), true); + }); + + test('it returns true if a policy includes access to a glob path', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.equal(service.hasPermission('baz/biz/hi'), true); + }); + + test('it returns true if a policy includes access to the * glob path', function(assert) { + let service = this.owner.lookup('service:permissions'); + const splatPath = { '': {} }; + service.set('globPaths', splatPath); + assert.equal(service.hasPermission('hi'), true); + }); + + test('it returns false if the matched path includes the deny capability', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.equal(service.hasPermission('boo'), false); + }); + + test('it returns false if a policy does not includes access to a path', function(assert) { + let service = this.owner.lookup('service:permissions'); + assert.equal(service.hasPermission('danger'), false); + }); + + test('sets the root token', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.setPaths({ data: { root: true } }); + assert.equal(service.canViewAll, true); + }); + + test('returns true with the root token', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.set('canViewAll', true); + assert.equal(service.hasPermission('hi'), true); + }); + + test('defaults to show all items when policy cannot be found', async function(assert) { + let service = this.owner.lookup('service:permissions'); + this.server.get('/v1/sys/internal/ui/resultant-acl', () => { + return [403, { 'Content-Type': 'application/json' }]; + }); + await service.getPaths.perform(); + assert.equal(service.canViewAll, true); + }); + + test('returns the first allowed nav route for policies', function(assert) { + let service = this.owner.lookup('service:permissions'); + const policyPaths = { + 'sys/policies/acl': { + capabilities: ['deny'], + }, + 'sys/policies/rgp': { + capabilities: ['read'], + }, + }; + service.set('exactPaths', policyPaths); + assert.equal(service.navPathParams('policies'), 'rgp'); + }); + + test('returns the first allowed nav route for access', function(assert) { + let service = this.owner.lookup('service:permissions'); + const accessPaths = { + 'sys/auth': { + capabilities: ['deny'], + }, + 'identity/entities': { + capabilities: ['read'], + }, + }; + const expected = ['vault.cluster.access.identity', 'entities']; + service.set('exactPaths', accessPaths); + assert.deepEqual(service.navPathParams('access'), expected); + }); + + test('hasNavPermission returns true if a policy includes access to at least one path', function(assert) { + let service = this.owner.lookup('service:permissions'); + const accessPaths = { + 'sys/auth': { + capabilities: ['deny'], + }, + 'sys/leases/lookup': { + capabilities: ['read'], + }, + }; + service.set('exactPaths', accessPaths); + assert.equal(service.hasNavPermission('access', 'leases'), true); + }); + + test('hasNavPermission returns false if a policy does not include access to any paths', function(assert) { + let service = this.owner.lookup('service:permissions'); + service.set('exactPaths', {}); + assert.equal(service.hasNavPermission('access'), false); + }); + + test('appends the namespace to the path if there is one', function(assert) { + const namespaceService = Service.extend({ + path: 'marketing', + }); + this.owner.register('service:namespace', namespaceService); + let service = this.owner.lookup('service:permissions'); + assert.equal(service.pathNameWithNamespace('sys/auth'), 'marketing/sys/auth'); + }); +});