Merge pull request #7028 from hashicorp/f-ui/node-drain-disable

UI: Disable client write actions when ACL token only allows client read
This commit is contained in:
Michael Lange 2020-01-31 10:20:06 -08:00 committed by GitHub
commit ef33a47553
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 321 additions and 39 deletions

View File

@ -0,0 +1,26 @@
import { Ability } from 'ember-can';
import { inject as service } from '@ember/service';
import { computed, get } from '@ember/object';
import { equal, or, not } from '@ember/object/computed';
export default Ability.extend({
token: service(),
// Map abilities to policy options (which are coarse for nodes)
// instead of specific behaviors.
canWrite: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeWrite'),
bypassAuthorization: not('token.aclEnabled'),
selfTokenIsManagement: equal('token.selfToken.type', 'management'),
policiesIncludeNodeWrite: computed('token.selfTokenPolicies.[]', function() {
// For each policy record, extract the Node policy
const policies = (this.get('token.selfTokenPolicies') || [])
.toArray()
.map(policy => get(policy, 'rulesJSON.Node.Policy'))
.compact();
// Node write is allowed if any policy allows it
return policies.some(policy => policy === 'write');
}),
});

View File

@ -1,14 +1,15 @@
import { Ability } from 'ember-can';
import { inject as service } from '@ember/service';
import { computed, get } from '@ember/object';
import { equal, or } from '@ember/object/computed';
import { equal, or, not } from '@ember/object/computed';
export default Ability.extend({
system: service(),
token: service(),
canRun: or('selfTokenIsManagement', 'policiesSupportRunning'),
canRun: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportRunning'),
bypassAuthorization: not('token.aclEnabled'),
selfTokenIsManagement: equal('token.selfToken.type', 'management'),
activeNamespace: computed('system.activeNamespace.name', function() {

View File

@ -9,6 +9,7 @@ export default Component.extend({
tagName: '',
client: null,
isDisabled: false,
onError() {},
onDrain() {},

View File

@ -16,6 +16,7 @@ export default Component.extend({
triggerClass: '',
isOpen: false,
isDisabled: false,
label: '',
dropdown: null,

View File

@ -2,6 +2,7 @@ import { inject as service } from '@ember/service';
import { reads } from '@ember/object/computed';
import Controller from '@ember/controller';
import { getOwner } from '@ember/application';
import { alias } from '@ember/object/computed';
export default Controller.extend({
token: service(),
@ -12,7 +13,7 @@ export default Controller.extend({
tokenIsValid: false,
tokenIsInvalid: false,
tokenRecord: null,
tokenRecord: alias('token.selfToken'),
resetStore() {
this.store.unloadAll();
@ -26,9 +27,9 @@ export default Controller.extend({
this.setProperties({
tokenIsValid: false,
tokenIsInvalid: false,
tokenRecord: null,
});
this.resetStore();
this.token.reset();
},
verifyToken() {
@ -38,22 +39,20 @@ export default Controller.extend({
this.set('token.secret', secret);
TokenAdapter.findSelf().then(
token => {
// Capture the token ID before clearing the store
const tokenId = token.get('id');
() => {
// Clear out all data to ensure only data the new token is privileged to
// see is shown
this.system.reset();
this.resetStore();
// Immediately refetch the token now that the store is empty
const newToken = this.store.findRecord('token', tokenId);
// Refetch the token and associated policies
this.get('token.fetchSelfTokenAndPolicies')
.perform()
.catch();
this.setProperties({
tokenIsValid: true,
tokenIsInvalid: false,
tokenRecord: newToken,
});
},
() => {
@ -61,7 +60,6 @@ export default Controller.extend({
this.setProperties({
tokenIsValid: false,
tokenIsInvalid: true,
tokenRecord: null,
});
}
);

View File

@ -78,16 +78,13 @@ export default Service.extend({
'defaultRegion.region',
'shouldShowRegions',
function() {
return this.shouldShowRegions &&
this.activeRegion !== this.get('defaultRegion.region');
return this.shouldShowRegions && this.activeRegion !== this.get('defaultRegion.region');
}
),
namespaces: computed('activeRegion', function() {
return PromiseArray.create({
promise: this.store
.findAll('namespace')
.then(namespaces => namespaces.compact()),
promise: this.store.findAll('namespace').then(namespaces => namespaces.compact()),
});
}),

View File

@ -11,6 +11,8 @@ export default Service.extend({
store: service(),
system: service(),
aclEnabled: true,
secret: computed({
get() {
return window.localStorage.nomadTokenSecret;
@ -31,11 +33,17 @@ export default Service.extend({
try {
return yield TokenAdapter.findSelf();
} catch (e) {
const errors = e.errors ? e.errors.mapBy('detail') : [];
if (errors.find(error => error === 'ACL support disabled')) {
this.set('aclEnabled', false);
}
return null;
}
}),
selfToken: alias('fetchSelfToken.lastSuccessful.value'),
selfToken: computed('secret', 'fetchSelfToken.lastSuccessful.value', function() {
if (this.secret) return this.get('fetchSelfToken.lastSuccessful.value');
}),
fetchSelfTokenPolicies: task(function*() {
try {
@ -54,7 +62,9 @@ export default Service.extend({
fetchSelfTokenAndPolicies: task(function*() {
yield this.fetchSelfToken.perform();
yield this.fetchSelfTokenPolicies.perform();
if (this.aclEnabled) {
yield this.fetchSelfTokenPolicies.perform();
}
}),
// All non Ember Data requests should go through authorizedRequest.
@ -83,6 +93,12 @@ export default Service.extend({
return this.authorizedRawRequest(url, options);
},
reset() {
this.fetchSelfToken.cancelAll({ resetState: true });
this.fetchSelfTokenPolicies.cancelAll({ resetState: true });
this.fetchSelfTokenAndPolicies.cancelAll({ resetState: true });
},
});
function addParams(url, params) {

View File

@ -43,6 +43,17 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
box-shadow: none;
}
&.is-disabled {
opacity: 0.7;
box-shadow: none;
cursor: not-allowed;
border-color: transparent;
&:hover {
border-color: transparent;
}
}
@each $name, $pair in $colors {
$color: nth($pair, 1);
$color-invert: nth($pair, 2);
@ -116,6 +127,24 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
box-shadow: none;
}
}
// The is-disabled styles MUST trump other modifier specificites
&.is-disabled {
background-color: $color;
border-color: darken($color, 5%);
box-shadow: none;
&:hover,
&:active,
&:focus,
&.is-hovered,
&.is-active,
&.is-focused {
background-color: $color;
border-color: darken($color, 5%);
box-shadow: none;
}
}
}
}

View File

@ -93,7 +93,7 @@
{{#toggle
data-test-eligibility-toggle
isActive=model.isEligible
isDisabled=(or setEligibility.isRunning model.isDraining)
isDisabled=(or setEligibility.isRunning model.isDraining (cannot "write client"))
onToggle=(perform setEligibility (not model.isEligible))}}
Eligible
{{/toggle}}
@ -122,6 +122,7 @@
<div class="toolbar-item is-right-aligned is-top-aligned">
{{drain-popover
client=model
isDisabled=(cannot "write client")
onDrain=(action "drainNotify")
onError=(action "drainError")}}
</div>

View File

@ -1,7 +1,13 @@
{{#popover-menu
data-test-drain-popover
isDisabled=isDisabled
label=(if client.isDraining "Update Drain" "Drain")
triggerClass=(concat "is-small " (if drain.isRunning "is-loading")) as |m|}}
tooltip=(if isDisabled "Not allowed to drain clients")
triggerClass=(concat
"is-small "
(if drain.isRunning "is-loading ")
(if isDisabled "tooltip is-right-aligned")
) as |m|}}
<form data-test-drain-popover-form onsubmit={{action (queue (action preventDefault) (perform drain m.actions.close))}} class="form is-small">
<h4 class="group-heading">Drain Options</h4>
<div class="field">

View File

@ -1,9 +1,15 @@
<BasicDropdown
@horizontalPosition="right"
@disabled={{isDisabled}}
@onOpen={{action (queue (action (mut isOpen) true) (action capture))}}
@onClose={{action (mut isOpen) false}} as |dd|
>
<dd.Trigger data-test-popover-trigger class={{concat "popover-trigger button is-primary " triggerClass}} {{on "keydown" (action "openOnArrowDown" dd)}}>
<dd.Trigger
data-test-popover-trigger
class={{concat "popover-trigger button is-primary " triggerClass (if isDisabled " is-disabled")}}
aria-label={{tooltip}}
{{on "keydown" (action "openOnArrowDown" dd)}}
>
{{label}} {{x-icon "chevron-down" class="is-text"}}
</dd.Trigger>
<dd.Content data-test-popover-menu class="popover-content">

View File

@ -18,7 +18,12 @@
{{#if (can "run job")}}
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
{{else}}
<button data-test-run-job class="button tooltip is-right-aligned" aria-label="You dont have permission to run jobs" disabled>Run Job</button>
<button
data-test-run-job
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have permission to run jobs"
disabled
>Run Job</button>
{{/if}}
</div>
{{/if}}
@ -55,7 +60,12 @@
{{#if (can "run job")}}
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
{{else}}
<button data-test-run-job class="button tooltip is-right-aligned" aria-label="You dont have permission to run jobs" disabled>Run Job</button>
<button
data-test-run-job
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have permission to run jobs"
disabled
>Run Job</button>
{{/if}}
</div>
{{/if}}

View File

@ -71,3 +71,60 @@ export let Sizes = () => {
`,
};
};
export let Disabled = () => {
return {
template: hbs`
<h5 class="title is-5">Anchor elements as buttons</h5>
<div class="block">
<a class="button is-disabled">Button</a>
<a class="button is-white is-disabled">White</a>
<a class="button is-light is-disabled">Light</a>
<a class="button is-dark is-disabled">Dark</a>
<a class="button is-black is-disabled">Black</a>
<a class="button is-link is-disabled">Link</a>
</div>
<div class="block">
<a class="button is-primary is-disabled">Primary</a>
<a class="button is-info is-disabled">Info</a>
<a class="button is-success is-disabled">Success</a>
<a class="button is-warning is-disabled">Warning</a>
<a class="button is-danger is-disabled">Danger</a>
</div>
<h5 class="title is-5">Button elements with <code>disabled</code> attribute</h5>
<div class="block">
<button class="button is-disabled" disabled>Button</button>
<button class="button is-white is-disabled" disabled>White</button>
<button class="button is-light is-disabled" disabled>Light</button>
<button class="button is-dark is-disabled" disabled>Dark</button>
<button class="button is-black is-disabled" disabled>Black</button>
<button class="button is-link is-disabled" disabled>Link</button>
</div>
<div class="block">
<button class="button is-primary is-disabled" disabled>Primary</button>
<button class="button is-info is-disabled" disabled>Info</button>
<button class="button is-success is-disabled" disabled>Success</button>
<button class="button is-warning is-disabled" disabled>Warning</button>
<button class="button is-danger is-disabled" disabled>Danger</button>
</div>
<h5 class="title is-5">Button elements with <code>aria-disabled="true"</code></h5>
<div class="block">
<button class="button is-disabled" aria-disabled="true">Button</button>
<button class="button is-white is-disabled" aria-disabled="true">White</button>
<button class="button is-light is-disabled" aria-disabled="true">Light</button>
<button class="button is-dark is-disabled" aria-disabled="true">Dark</button>
<button class="button is-black is-disabled" aria-disabled="true">Black</button>
<button class="button is-link is-disabled" aria-disabled="true">Link</button>
</div>
<div class="block">
<button class="button is-primary is-disabled" aria-disabled="true">Primary</button>
<button class="button is-info is-disabled" aria-disabled="true">Info</button>
<button class="button is-success is-disabled" aria-disabled="true">Success</button>
<button class="button is-warning is-disabled" aria-disabled="true">Warning</button>
<button class="button is-danger is-disabled" aria-disabled="true">Danger</button>
</div>
`,
};
};

View File

@ -10,6 +10,8 @@ import Clients from 'nomad-ui/tests/pages/clients/list';
import Jobs from 'nomad-ui/tests/pages/jobs/list';
let node;
let managementToken;
let clientToken;
const wasPreemptedFilter = allocation => !!allocation.preemptedByAllocation;
@ -21,6 +23,11 @@ module('Acceptance | client detail', function(hooks) {
server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
node = server.db.nodes[0];
managementToken = server.create('token');
clientToken = server.create('token');
window.localStorage.nomadTokenSecret = managementToken.secretId;
// Related models
server.create('agent');
server.create('job', { createAllocations: false });
@ -885,6 +892,14 @@ module('Acceptance | client detail', function(hooks) {
assert.notOk(ClientDetail.eligibilityError.isPresent);
});
test('toggling eligibility and node drain are disabled when the active ACL token does not permit node write', async function(assert) {
window.localStorage.nomadTokenSecret = clientToken.secretId;
await ClientDetail.visit({ id: node.id });
assert.ok(ClientDetail.eligibilityToggle.isDisabled);
assert.ok(ClientDetail.drainPopover.isDisabled);
});
});
module('Acceptance | client detail (multi-namespace)', function(hooks) {

View File

@ -0,0 +1,9 @@
export default ability => hooks => {
hooks.beforeEach(function() {
this.ability = this.owner.lookup(`ability:${ability}`);
});
hooks.afterEach(function() {
delete this.ability;
});
};

View File

@ -117,6 +117,7 @@ export default create({
label: text('[data-test-drain-popover] [data-test-popover-trigger]'),
isOpen: isPresent('[data-test-drain-popover-form]'),
toggle: clickable('[data-test-drain-popover] [data-test-popover-trigger]'),
isDisabled: attribute('aria-disabled', '[data-test-popover-trigger]'),
deadlineToggle: toggle('[data-test-drain-deadline-toggle]'),
deadlineOptions: {

View File

@ -0,0 +1,92 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import Service from '@ember/service';
import setupAbility from 'nomad-ui/tests/helpers/setup-ability';
module('Unit | Ability | client', function(hooks) {
setupTest(hooks);
setupAbility('client')(hooks);
test('it permits client write when ACLs are disabled', function(assert) {
const mockToken = Service.extend({
aclEnabled: false,
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canWrite);
});
test('it permits client write for management tokens', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'management' },
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canWrite);
});
test('it permits client write for tokens with a policy that has node-write', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
rulesJSON: {
Node: {
Policy: 'write',
},
},
},
],
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canWrite);
});
test('it permits client write for tokens with a policy that allows write and another policy that disallows it', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
rulesJSON: {
Node: {
Policy: 'write',
},
},
},
{
rulesJSON: {
Node: {
Policy: 'read',
},
},
},
],
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canWrite);
});
test('it blocks client write for tokens with a policy that does not allow node-write', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
rulesJSON: {
Node: {
Policy: 'read',
},
},
},
],
});
this.owner.register('service:token', mockToken);
assert.notOk(this.ability.canWrite);
});
});

View File

@ -1,29 +1,43 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import Service from '@ember/service';
import setupAbility from 'nomad-ui/tests/helpers/setup-ability';
module('Unit | Ability | job', function(hooks) {
setupTest(hooks);
setupAbility('job')(hooks);
test('it permits job run when ACLs are disabled', function(assert) {
const mockToken = Service.extend({
aclEnabled: false,
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canRun);
});
test('it permits job run for management tokens', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'management' },
});
this.owner.register('service:token', mockToken);
const jobAbility = this.owner.lookup('ability:job');
assert.ok(jobAbility.canRun);
assert.ok(this.ability.canRun);
});
test('it permits job run for client tokens with a policy that has namespace submit-job', function(assert) {
const mockSystem = Service.extend({
aclEnabled: true,
activeNamespace: {
name: 'aNamespace',
},
});
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
@ -42,18 +56,19 @@ module('Unit | Ability | job', function(hooks) {
this.owner.register('service:system', mockSystem);
this.owner.register('service:token', mockToken);
const jobAbility = this.owner.lookup('ability:job');
assert.ok(jobAbility.canRun);
assert.ok(this.ability.canRun);
});
test('it permits job run for client tokens with a policy that has default namespace submit-job and no capabilities for active namespace', function(assert) {
const mockSystem = Service.extend({
aclEnabled: true,
activeNamespace: {
name: 'anotherNamespace',
},
});
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
@ -76,18 +91,19 @@ module('Unit | Ability | job', function(hooks) {
this.owner.register('service:system', mockSystem);
this.owner.register('service:token', mockToken);
const jobAbility = this.owner.lookup('ability:job');
assert.ok(jobAbility.canRun);
assert.ok(this.ability.canRun);
});
test('it blocks job run for client tokens with a policy that has no submit-job capability', function(assert) {
const mockSystem = Service.extend({
aclEnabled: true,
activeNamespace: {
name: 'aNamespace',
},
});
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
@ -106,18 +122,19 @@ module('Unit | Ability | job', function(hooks) {
this.owner.register('service:system', mockSystem);
this.owner.register('service:token', mockToken);
const jobAbility = this.owner.lookup('ability:job');
assert.notOk(jobAbility.canRun);
assert.notOk(this.ability.canRun);
});
test('it handles globs in namespace names', function(assert) {
const mockSystem = Service.extend({
aclEnabled: true,
activeNamespace: {
name: 'aNamespace',
},
});
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
@ -156,28 +173,27 @@ module('Unit | Ability | job', function(hooks) {
this.owner.register('service:system', mockSystem);
this.owner.register('service:token', mockToken);
const jobAbility = this.owner.lookup('ability:job');
const systemService = this.owner.lookup('service:system');
systemService.set('activeNamespace.name', 'production-web');
assert.notOk(jobAbility.canRun);
assert.notOk(this.ability.canRun);
systemService.set('activeNamespace.name', 'production-api');
assert.ok(jobAbility.canRun);
assert.ok(this.ability.canRun);
systemService.set('activeNamespace.name', 'production-other');
assert.ok(jobAbility.canRun);
assert.ok(this.ability.canRun);
systemService.set('activeNamespace.name', 'something-suffixed');
assert.ok(jobAbility.canRun);
assert.ok(this.ability.canRun);
systemService.set('activeNamespace.name', 'something-more-suffixed');
assert.notOk(
jobAbility.canRun,
this.ability.canRun,
'expected the namespace with the greatest number of matched characters to be chosen'
);
systemService.set('activeNamespace.name', '000-abc-999');
assert.ok(jobAbility.canRun, 'expected to be able to match against more than one wildcard');
assert.ok(this.ability.canRun, 'expected to be able to match against more than one wildcard');
});
});