Skip to content

Commit

Permalink
ui: Change Run Job availability based on ACLs (#5944)
Browse files Browse the repository at this point in the history
This builds on API changes in #6017 and #6021 to conditionally turn off the
“Run Job” button based on the current token’s capabilities, or the capabilities
of the anonymous policy if no token is present.

If you try to visit the job-run route directly, it redirects to the job list.
  • Loading branch information
backspace committed Jan 20, 2020
1 parent 6c3a29a commit 3adb3cd
Show file tree
Hide file tree
Showing 25 changed files with 514 additions and 57 deletions.
79 changes: 79 additions & 0 deletions ui/app/abilities/job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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';

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

canRun: or('selfTokenIsManagement', 'policiesSupportRunning'),

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';
}
},
});
1 change: 1 addition & 0 deletions ui/app/models/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export default Model.extend({
name: attr('string'),
description: attr('string'),
rules: attr('string'),
rulesJSON: attr(),
});
49 changes: 28 additions & 21 deletions ui/app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default Route.extend({
config: service(),
system: service(),
store: service(),
token: service(),

queryParams: {
region: {
Expand All @@ -22,28 +23,34 @@ export default Route.extend({
},

beforeModel(transition) {
return RSVP.all([this.get('system.regions'), this.get('system.defaultRegion')]).then(
promises => {
if (!this.get('system.shouldShowRegions')) return promises;

const queryParam = transition.to.queryParams.region;
const defaultRegion = this.get('system.defaultRegion.region');
const currentRegion = this.get('system.activeRegion') || defaultRegion;

// Only reset the store if the region actually changed
if (
(queryParam && queryParam !== currentRegion) ||
(!queryParam && currentRegion !== defaultRegion)
) {
this.system.reset();
this.store.unloadAll();
}

this.set('system.activeRegion', queryParam || defaultRegion);

return promises;
const fetchSelfTokenAndPolicies = this.get('token.fetchSelfTokenAndPolicies')
.perform()
.catch();

return RSVP.all([
this.get('system.regions'),
this.get('system.defaultRegion'),
fetchSelfTokenAndPolicies,
]).then(promises => {
if (!this.get('system.shouldShowRegions')) return promises;

const queryParam = transition.to.queryParams.region;
const defaultRegion = this.get('system.defaultRegion.region');
const currentRegion = this.get('system.activeRegion') || defaultRegion;

// Only reset the store if the region actually changed
if (
(queryParam && queryParam !== currentRegion) ||
(!queryParam && currentRegion !== defaultRegion)
) {
this.system.reset();
this.store.unloadAll();
}
);

this.set('system.activeRegion', queryParam || defaultRegion);

return promises;
});
},

// Model is being used as a way to transfer the provided region
Expand Down
7 changes: 7 additions & 0 deletions ui/app/routes/jobs/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default Route.extend({
can: service(),
store: service(),
system: service(),

Expand All @@ -12,6 +13,12 @@ export default Route.extend({
},
],

beforeModel() {
if (this.can.cannot('run job')) {
this.transitionTo('jobs');
}
},

model() {
return this.store.createRecord('job', {
namespace: this.get('system.activeNamespace'),
Expand Down
35 changes: 35 additions & 0 deletions ui/app/services/token.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import Service, { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import { getOwner } from '@ember/application';
import { assign } from '@ember/polyfills';
import { task } from 'ember-concurrency';
import queryString from 'query-string';
import fetch from 'nomad-ui/utils/fetch';

export default Service.extend({
store: service(),
system: service(),

secret: computed({
Expand All @@ -22,6 +26,37 @@ export default Service.extend({
},
}),

fetchSelfToken: task(function*() {
const TokenAdapter = getOwner(this).lookup('adapter:token');
try {
return yield TokenAdapter.findSelf();
} catch (e) {
return null;
}
}),

selfToken: alias('fetchSelfToken.lastSuccessful.value'),

fetchSelfTokenPolicies: task(function*() {
try {
if (this.selfToken) {
return yield this.selfToken.get('policies');
} else {
let policy = yield this.store.findRecord('policy', 'anonymous');
return [policy];
}
} catch (e) {
return [];
}
}),

selfTokenPolicies: alias('fetchSelfTokenPolicies.lastSuccessful.value'),

fetchSelfTokenAndPolicies: task(function*() {
yield this.fetchSelfToken.perform();
yield this.fetchSelfTokenPolicies.perform();
}),

// All non Ember Data requests should go through authorizedRequest.
// However, the request that gets regions falls into that category.
// This authorizedRawRequest is necessary in order to fetch data
Expand Down
4 changes: 4 additions & 0 deletions ui/app/styles/components/tooltip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
transition: top 0.1s ease-in-out;
}

.tooltip.is-right-aligned::after {
transform: translateX(-75%);
}

.tooltip:hover::after,
.tooltip.always-active::after {
opacity: 1;
Expand Down
12 changes: 10 additions & 2 deletions ui/app/templates/jobs/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
</div>
{{#if (media "isMobile")}}
<div class="toolbar-item is-right-aligned">
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
{{#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 don’t have permission to run jobs" disabled>Run Job</button>
{{/if}}
</div>
{{/if}}
<div class="toolbar-item is-right-aligned is-mobile-full-width">
Expand Down Expand Up @@ -48,7 +52,11 @@
</div>
{{#if (not (media "isMobile"))}}
<div class="toolbar-item is-right-aligned">
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
{{#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 don’t have permission to run jobs" disabled>Run Job</button>
{{/if}}
</div>
{{/if}}
</div>
Expand Down
8 changes: 8 additions & 0 deletions ui/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ export default function() {
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });

if (req.params.id === 'anonymous') {
if (policy) {
return this.serialize(policy);
} else {
return new Response(404, {}, null);
}
}

// Return the policy only if the token that matches the request header
// includes the policy or if the token that matches the request header
// is of type management
Expand Down
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"d3-transition": "^1.1.0",
"ember-ajax": "^5.0.0",
"ember-auto-import": "^1.2.21",
"ember-can": "^2.0.0",
"ember-cli": "~3.12.0",
"ember-cli-babel": "^7.7.3",
"ember-cli-clipboard": "^0.13.0",
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/allocation-detail-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,9 @@ module('Acceptance | allocation detail', function(hooks) {
await Allocation.visit({ id: 'not-a-real-allocation' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/allocation/not-a-real-allocation',
'A request to the nonexistent allocation is made'
);
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/client-detail-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ module('Acceptance | client detail', function(hooks) {
await ClientDetail.visit({ id: 'not-a-real-node' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/node/not-a-real-node',
'A request to the nonexistent node is made'
);
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/job-allocations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ module('Acceptance | job allocations', function(hooks) {
await Allocations.visit({ id: 'not-a-real-job' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/job/not-a-real-job',
'A request to the nonexistent job is made'
);
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/job-definition-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ module('Acceptance | job definition', function(hooks) {
await Definition.visit({ id: 'not-a-real-job' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/job/not-a-real-job',
'A request to the nonexistent job is made'
);
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/job-deployments-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ module('Acceptance | job deployments', function(hooks) {
await Deployments.visit({ id: 'not-a-real-job' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/job/not-a-real-job',
'A request to the nonexistent job is made'
);
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/job-detail-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ moduleForJob(
await JobDetail.visit({ id: 'not-a-real-job' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/job/not-a-real-job',
'A request to the nonexistent job is made'
);
Expand Down
4 changes: 3 additions & 1 deletion ui/tests/acceptance/job-evaluations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ module('Acceptance | job evaluations', function(hooks) {
await Evaluations.visit({ id: 'not-a-real-job' });

assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/job/not-a-real-job',
'A request to the nonexistent job is made'
);
Expand Down
14 changes: 14 additions & 0 deletions ui/tests/acceptance/job-run-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import JobRun from 'nomad-ui/tests/pages/jobs/run';
const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';

let managementToken, clientToken;

const jsonJob = overrides => {
return JSON.stringify(
assign(
Expand Down Expand Up @@ -45,6 +47,11 @@ module('Acceptance | job run', function(hooks) {
hooks.beforeEach(function() {
// Required for placing allocations (a result of creating jobs)
server.create('node');

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

window.localStorage.nomadTokenSecret = managementToken.secretId;
});

test('visiting /jobs/run', async function(assert) {
Expand Down Expand Up @@ -86,4 +93,11 @@ module('Acceptance | job run', function(hooks) {
`Redirected to the job overview page for ${newJobName} and switched the namespace to ${newNamespace}`
);
});

test('when the user doesn’t have permission to run a job, redirects to the job overview page', async function(assert) {
window.localStorage.nomadTokenSecret = clientToken.secretId;

await JobRun.visit();
assert.equal(currentURL(), '/jobs');
});
});
Loading

0 comments on commit 3adb3cd

Please sign in to comment.