Skip to content

Commit

Permalink
UI/gate wizard (#6094)
Browse files Browse the repository at this point in the history
* check for capabilities when finding matching paths

* disable wizard items that user does not have access to

* make hasPermissions accept an array of capabilities

* refactor features-selection

* fix tests

* implement feedback
  • Loading branch information
Noelle Daley authored Jan 28, 2019
1 parent 8a1ef90 commit 679c09e
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 18 deletions.
53 changes: 48 additions & 5 deletions ui/app/components/wizard/features-selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,37 @@ import { FEATURE_MACHINE_TIME } from 'vault/helpers/wizard-constants';
export default Component.extend({
wizard: service(),
version: service(),
permissions: service(),

init() {
this._super(...arguments);
this.maybeHideFeatures();
},

maybeHideFeatures() {
let features = this.get('allFeatures');
features.forEach(feat => {
feat.disabled = this.doesNotHavePermission(feat.requiredPermissions);
});

if (this.get('showReplication') === false) {
let feature = this.get('allFeatures').findBy('key', 'replication');
feature.show = false;
}
},

doesNotHavePermission(requiredPermissions) {
// requiredPermissions is an object of paths and capabilities defined within allFeatures.
// the expected shape is:
// {
// 'example/path': ['capability'],
// 'second/example/path': ['update', 'sudo'],
// }
return !Object.keys(requiredPermissions).every(path => {
return this.permissions.hasPermission(path, requiredPermissions[path]);
});
},

estimatedTime: computed('selectedFeatures', function() {
let time = 0;
for (let feature of Object.keys(FEATURE_MACHINE_TIME)) {
Expand All @@ -44,15 +63,22 @@ export default Component.extend({
steps: ['Enabling a secrets engine', 'Adding a secret'],
selected: false,
show: true,
permission: 'secrets',
disabled: false,
requiredPermissions: {
'sys/mounts/example': ['update'],
},
},
{
key: 'authentication',
name: 'Authentication',
steps: ['Enabling an auth method', 'Managing your auth method'],
selected: false,
show: true,
permission: 'access',
disabled: false,
requiredPermissions: {
'sys/auth': ['read'],
'sys/auth/foo': ['update', 'sudo'],
},
},
{
key: 'policies',
Expand All @@ -65,23 +91,36 @@ export default Component.extend({
],
selected: false,
show: true,
permission: 'policies',
disabled: false,
requiredPermissions: {
'sys/policies/acl': ['list'],
},
},
{
key: 'replication',
name: 'Replication',
steps: ['Setting up replication', 'Your cluster information'],
selected: false,
show: true,
permission: 'status',
disabled: false,
requiredPermissions: {
'sys/replication/performance/primary/enable': ['update'],
'sys/replication/dr/primary/enable': ['update'],
},
},
{
key: 'tools',
name: 'Tools',
steps: ['Wrapping data', 'Lookup wrapped data', 'Rewrapping your data', 'Unwrapping your data'],
selected: false,
show: true,
permission: 'tools',
disabled: false,
requiredPermissions: {
'sys/wrapping/wrap': ['update'],
'sys/wrapping/lookup': ['update'],
'sys/wrapping/unwrap': ['update'],
'sys/wrapping/rewrap': ['update'],
},
},
];
}),
Expand All @@ -96,6 +135,10 @@ export default Component.extend({
.mapBy('key');
}),

cannotStartWizard: computed('selectedFeatures', function() {
return !this.get('selectedFeatures').length;
}),

actions: {
saveFeatures() {
let wizard = this.get('wizard');
Expand Down
4 changes: 2 additions & 2 deletions ui/app/helpers/has-permission.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { inject as service } from '@ember/service';

export default Helper.extend({
permissions: service(),
compute([navItem], { routeParams }) {
compute([route], { routeParams, capability }) {
let permissions = this.permissions;
return permissions.hasNavPermission(navItem, routeParams);
return permissions.hasNavPermission(route, routeParams, capability);
},
});
34 changes: 27 additions & 7 deletions ui/app/services/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,33 +108,53 @@ export default Service.extend({
}
},

hasPermission(pathName) {
hasPermission(pathName, capabilities = [null]) {
const path = this.pathNameWithNamespace(pathName);

if (this.canViewAll || this.hasMatchingExactPath(path) || this.hasMatchingGlobPath(path)) {
if (this.canViewAll) {
return true;
}
return false;

return capabilities.every(
capability => this.hasMatchingExactPath(path, capability) || this.hasMatchingGlobPath(path, capability)
);
},

hasMatchingExactPath(pathName) {
hasMatchingExactPath(pathName, capability) {
const exactPaths = this.get('exactPaths');
if (exactPaths) {
const prefix = Object.keys(exactPaths).find(path => path.startsWith(pathName));
return prefix && !this.isDenied(exactPaths[prefix]);
const hasMatchingPath = prefix && !this.isDenied(exactPaths[prefix]);

if (prefix && capability) {
return this.hasCapability(exactPaths[prefix], capability) && hasMatchingPath;
}

return hasMatchingPath;
}
return false;
},

hasMatchingGlobPath(pathName) {
hasMatchingGlobPath(pathName, capability) {
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('');
const hasMatchingPath =
(matchingPath && !this.isDenied(globPaths[matchingPath])) || globPaths.hasOwnProperty('');

if (matchingPath && capability) {
return this.hasCapability(globPaths[matchingPath], capability) && hasMatchingPath;
}

return hasMatchingPath;
}
return false;
},

hasCapability(path, capability) {
return path.capabilities.includes(capability);
},

isDenied(path) {
return path.capabilities.includes('deny');
},
Expand Down
5 changes: 5 additions & 0 deletions ui/app/styles/components/features-selection.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
&.is-active {
box-shadow: 0 0 0 1px $grey-light;
}

&.is-disabled {
background-color: $ui-gray-010;
color: $ui-gray-300;
}
}

.feature-box label {
Expand Down
21 changes: 18 additions & 3 deletions ui/app/templates/components/wizard/features-selection.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
<h3 class="feature-header">Walk me through setting up:</h3>
<form id="features-form" class="feature-selection" {{action "saveFeatures" on="submit"}}>
{{#each allFeatures as |feature|}}
{{#if (and feature.show (has-permission feature.permission))}}
<div class="feature-box {{if feature.selected 'is-active'}}">
{{#if feature.show}}
<div class="feature-box {{if feature.selected 'is-active'}} {{if feature.disabled 'is-disabled'}}"
data-test-select-input={{true}}>
<div class="b-checkbox">
<input
id="feature-{{feature.key}}"
type="checkbox"
class="styled"
checked={{feature.selected}}
onchange={{action (mut feature.selected) value="target.checked"}}
disabled={{feature.disabled}}
data-test-checkbox={{feature.name}}
/>
<label for="feature-{{feature.key}}">{{feature.name}}</label>
<button type="button" class="button is-ghost icon is-pulled-right" onclick={{action (toggle (concat feature.key "-isOpen") this)}}>
Expand All @@ -29,6 +32,11 @@
@class="has-text-grey auto-width is-paddingless is-flex-column"
/>
</button>
{{#if feature.disabled}}
<Info-Tooltip data-test-tooltip>
You do not have permissions to tour some parts of this feature
</Info-Tooltip>
{{/if}}
</div>
{{#if (get this (concat feature.key "-isOpen"))}}
<ul class="feature-steps">
Expand All @@ -41,7 +49,14 @@
{{/if}}
{{/each}}
<span class="selection-summary">
<button type="submit" class="button is-primary">Start</button>
<button
type="submit"
class="button is-primary"
disabled={{cannotStartWizard}}
data-test-start-button
>
Start
</button>
{{#if selectedFeatures}}
<span class="time-estimate"><ICon @glyph="stopwatch" @class="has-text-grey auto-width is-paddingless is-flex-column"/>About {{estimatedTime}} minutes</span>
{{/if}}
Expand Down
53 changes: 53 additions & 0 deletions ui/tests/integration/components/features-selection-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { create } from 'ember-cli-page-object';
import featuresSelection from 'vault/tests/pages/components/wizard/features-selection';
import hbs from 'htmlbars-inline-precompile';
import Service from '@ember/service';

const component = create(featuresSelection);

const permissionsService = Service.extend({
hasPermission(path) {
// This enables the Secrets and Authentication wizard items and disables the others.
const allowedPaths = ['sys/mounts/example', 'sys/auth', 'sys/auth/foo', 'sys/wrapping/wrap'];
if (allowedPaths.includes(path)) {
return true;
}
return false;
},
});

module('Integration | Component | features-selection', function(hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function() {
this.owner.register('service:permissions', permissionsService);
});

test('it disables and enables wizard items according to user permissions', async function(assert) {
const enabled = { Secrets: true, Authentication: true, Policies: false, Tools: false };
await render(hbs`{{wizard/features-selection}}`);

component.wizardItems.forEach(i => {
assert.equal(
i.hasDisabledTooltip,
!enabled[i.text],
'shows a tooltip only when the wizard item is not enabled'
);
});
});

test('it disables the start button if no wizard items are checked', async function(assert) {
await render(hbs`{{wizard/features-selection}}`);
assert.equal(component.hasDisabledStartButton, true);
});

test('it enables the start button when user has permission and wizard items are checked', async function(assert) {
await render(hbs`{{wizard/features-selection}}`);
await component.selectSecrets();

assert.equal(component.hasDisabledStartButton, false);
});
});
9 changes: 9 additions & 0 deletions ui/tests/pages/components/wizard/features-selection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { collection, isPresent, property, clickable } from 'ember-cli-page-object';

export default {
wizardItems: collection('[data-test-select-input]', {
hasDisabledTooltip: isPresent('[data-test-tooltip]'),
}),
hasDisabledStartButton: property('disabled', '[data-test-start-button]'),
selectSecrets: clickable('[data-test-checkbox=Secrets]'),
};
18 changes: 17 additions & 1 deletion ui/tests/unit/services/permissions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const PERMISSIONS_RESPONSE = {
capabilities: ['read'],
},
'bar/bee': {
capabilities: ['create'],
capabilities: ['create', 'list'],
},
boo: {
capabilities: ['deny'],
Expand Down Expand Up @@ -93,6 +93,22 @@ module('Unit | Service | permissions', function(hooks) {
assert.equal(service.hasPermission('hi'), true);
});

test('it returns true if a policy has the specified capabilities on a path', function(assert) {
let service = this.owner.lookup('service:permissions');
service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths);
service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths);
assert.equal(service.hasPermission('bar/bee', ['create', 'list']), true);
assert.equal(service.hasPermission('baz/biz', ['read']), true);
});

test('it returns false if a policy does not have the specified capabilities on a path', function(assert) {
let service = this.owner.lookup('service:permissions');
service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths);
service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths);
assert.equal(service.hasPermission('bar/bee', ['create', 'delete']), false);
assert.equal(service.hasPermission('foo', ['create']), false);
});

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', () => {
Expand Down

0 comments on commit 679c09e

Please sign in to comment.