Skip to content

Commit

Permalink
Add ACL-checking to turn off exec button (#7919)
Browse files Browse the repository at this point in the history
This closes #7453. It adds an abstraction to handle the common
needs of ability-determination.
  • Loading branch information
backspace committed May 11, 2020
1 parent 55fa55c commit 8c3a210
Show file tree
Hide file tree
Showing 8 changed files with 366 additions and 88 deletions.
71 changes: 71 additions & 0 deletions ui/app/abilities/abstract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Ability } from 'ember-can';
import { inject as service } from '@ember/service';
import { computed, get } from '@ember/object';
import { equal, not } from '@ember/object/computed';

export default Ability.extend({
system: service(),
token: service(),

bypassAuthorization: not('token.aclEnabled'),
selfTokenIsManagement: equal('token.selfToken.type', 'management'),

activeNamespace: computed('system.activeNamespace.name', function() {
return this.get('system.activeNamespace.name') || 'default';
}),

rulesForActiveNamespace: computed('activeNamespace', 'token.selfTokenPolicies.[]', function() {
let activeNamespace = this.activeNamespace;

return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => {
let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || [];

let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace);

if (matchingNamespace) {
rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace));
}

return rules;
}, []);
}),

// Chooses the closest namespace as described at the bottom here:
// https://www.nomadproject.io/guides/security/acl.html#namespace-rules
_findMatchingNamespace(policyNamespaces, activeNamespace) {
let namespaceNames = policyNamespaces.mapBy('Name');

if (namespaceNames.includes(activeNamespace)) {
return activeNamespace;
}

let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*'));

let matchingNamespaceName = globNamespaceNames.reduce(
(mostMatching, namespaceName) => {
// Convert * wildcards to .* for regex matching
let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*'));
let characterDifference = activeNamespace.length - namespaceName.length;

if (
characterDifference < mostMatching.mostMatchingCharacterDifference &&
activeNamespace.match(namespaceNameRegExp)
) {
return {
mostMatchingNamespaceName: namespaceName,
mostMatchingCharacterDifference: characterDifference,
};
} else {
return mostMatching;
}
},
{ mostMatchingNamespaceName: null, mostMatchingCharacterDifference: Number.MAX_SAFE_INTEGER }
).mostMatchingNamespaceName;

if (matchingNamespaceName) {
return matchingNamespaceName;
} else if (namespaceNames.includes('default')) {
return 'default';
}
},
});
14 changes: 14 additions & 0 deletions ui/app/abilities/allocation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import AbstractAbility from './abstract';
import { computed, get } from '@ember/object';
import { or } from '@ember/object/computed';

export default AbstractAbility.extend({
canExec: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportExec'),

policiesSupportExec: computed('rulesForActiveNamespace.@each.capabilities', function() {
return this.rulesForActiveNamespace.some(rules => {
let capabilities = get(rules, 'Capabilities') || [];
return capabilities.includes('alloc-exec');
});
}),
});
12 changes: 3 additions & 9 deletions ui/app/abilities/client.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { Ability } from 'ember-can';
import { inject as service } from '@ember/service';
import AbstractAbility from './abstract';
import { computed, get } from '@ember/object';
import { equal, or, not } from '@ember/object/computed';

export default Ability.extend({
token: service(),
import { or } from '@ember/object/computed';

export default AbstractAbility.extend({
// 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') || [])
Expand Down
72 changes: 3 additions & 69 deletions ui/app/abilities/job.js
Original file line number Diff line number Diff line change
@@ -1,80 +1,14 @@
import { Ability } from 'ember-can';
import { inject as service } from '@ember/service';
import AbstractAbility from './abstract';
import { computed, get } from '@ember/object';
import { equal, or, not } from '@ember/object/computed';

export default Ability.extend({
system: service(),
token: service(),
import { or } from '@ember/object/computed';

export default AbstractAbility.extend({
canRun: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportRunning'),

bypassAuthorization: not('token.aclEnabled'),
selfTokenIsManagement: equal('token.selfToken.type', 'management'),

activeNamespace: computed('system.activeNamespace.name', function() {
return this.get('system.activeNamespace.name') || 'default';
}),

rulesForActiveNamespace: computed('activeNamespace', 'token.selfTokenPolicies.[]', function() {
let activeNamespace = this.activeNamespace;

return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => {
let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || [];

let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace);

if (matchingNamespace) {
rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace));
}

return rules;
}, []);
}),

policiesSupportRunning: computed('rulesForActiveNamespace.@each.capabilities', function() {
return this.rulesForActiveNamespace.some(rules => {
let capabilities = get(rules, 'Capabilities') || [];
return capabilities.includes('submit-job');
});
}),

// Chooses the closest namespace as described at the bottom here:
// https://www.nomadproject.io/guides/security/acl.html#namespace-rules
_findMatchingNamespace(policyNamespaces, activeNamespace) {
let namespaceNames = policyNamespaces.mapBy('Name');

if (namespaceNames.includes(activeNamespace)) {
return activeNamespace;
}

let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*'));

let matchingNamespaceName = globNamespaceNames.reduce(
(mostMatching, namespaceName) => {
// Convert * wildcards to .* for regex matching
let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*'));
let characterDifference = activeNamespace.length - namespaceName.length;

if (
characterDifference < mostMatching.mostMatchingCharacterDifference &&
activeNamespace.match(namespaceNameRegExp)
) {
return {
mostMatchingNamespaceName: namespaceName,
mostMatchingCharacterDifference: characterDifference,
};
} else {
return mostMatching;
}
},
{ mostMatchingNamespaceName: null, mostMatchingCharacterDifference: Number.MAX_SAFE_INTEGER }
).mostMatchingNamespaceName;

if (matchingNamespaceName) {
return matchingNamespaceName;
} else if (namespaceNames.includes('default')) {
return 'default';
}
},
});
20 changes: 12 additions & 8 deletions ui/app/templates/components/exec/open-button.hbs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<button
data-test-exec-button
type="button"
class="button exec-button is-outline is-small"
{{action "open"}}>
{{x-icon "console"}}
<span>Exec</span>
</button>
{{#let (cannot "exec allocation") as |cannotExec|}}
<button
data-test-exec-button
type="button"
class="button exec-button is-outline is-small {{if cannotExec "tooltip"}}"
disabled={{if cannotExec 'disabled'}}
aria-label={{if cannotExec "You don’t have permission to exec"}}
{{action "open"}}>
{{x-icon "console"}}
<span>Exec</span>
</button>
{{/let}}
61 changes: 59 additions & 2 deletions ui/tests/acceptance/job-detail-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,16 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);

let job;
let job, clientToken;

hooks.beforeEach(function() {
server.createList('namespace', 2);
server.create('node');
job = server.create('job', { type: 'service', namespaceId: server.db.namespaces[1].name });
job = server.create('job', { type: 'service', status: 'running', namespaceId: server.db.namespaces[1].name });
server.createList('job', 3, { namespaceId: server.db.namespaces[0].name });

server.create('token');
clientToken = server.create('token');
});

test('when there are namespaces, the job detail page states the namespace for the job', async function(assert) {
Expand Down Expand Up @@ -101,4 +104,58 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
assert.equal(jobRow.name, jobs[index].name, `Job ${index} is right`);
});
});

test('the exec button state can change between namespaces', async function(assert) {
const job1 = server.create('job', { status: 'running', namespaceId: server.db.namespaces[0].id });
const job2 = server.create('job', { status: 'running', namespaceId: server.db.namespaces[1].id });

window.localStorage.nomadTokenSecret = clientToken.secretId;

const policy = server.create('policy', {
id: 'something',
name: 'something',
rulesJSON: {
Namespaces: [
{
Name: job1.namespaceId,
Capabilities: ['list-jobs', 'alloc-exec'],
},
{
Name: job2.namespaceId,
Capabilities: ['list-jobs'],
},
],
},
});

clientToken.policyIds = [policy.id];
clientToken.save();

await JobDetail.visit({ id: job1.id });
assert.notOk(JobDetail.execButton.isDisabled);

const secondNamespace = server.db.namespaces[1];
await JobDetail.visit({ id: job2.id, namespace: secondNamespace.name });
assert.ok(JobDetail.execButton.isDisabled);
});

test('the anonymous policy is fetched to check whether to show the exec button', async function(assert) {
window.localStorage.removeItem('nomadTokenSecret');

server.create('policy', {
id: 'anonymous',
name: 'anonymous',
rulesJSON: {
Namespaces: [
{
Name: 'default',
Capabilities: ['list-jobs', 'alloc-exec'],
},
],
},
});

await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name });
assert.notOk(JobDetail.execButton.isDisabled);
});
});
5 changes: 5 additions & 0 deletions ui/tests/pages/jobs/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
create,
collection,
clickable,
hasClass,
isPresent,
property,
text,
visitable,
} from 'ember-cli-page-object';
Expand All @@ -28,6 +30,9 @@ export default create({

execButton: {
scope: '[data-test-exec-button]',
isDisabled: property('disabled'),
hasTooltip: hasClass('tooltip'),
tooltipText: attribute('aria-label'),
},

stats: collection('[data-test-job-stat]', {
Expand Down
Loading

0 comments on commit 8c3a210

Please sign in to comment.