From 3adb3cd1fec6c3aab531f5a46e0da50a713041cc Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 20 Jan 2020 14:57:01 -0600 Subject: [PATCH] ui: Change Run Job availability based on ACLs (#5944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/app/abilities/job.js | 79 ++++++++ ui/app/models/policy.js | 1 + ui/app/routes/application.js | 43 ++-- ui/app/routes/jobs/run.js | 7 + ui/app/services/token.js | 35 ++++ ui/app/styles/components/tooltip.scss | 4 + ui/app/templates/jobs/index.hbs | 12 +- ui/mirage/config.js | 8 + ui/package.json | 1 + ui/tests/acceptance/allocation-detail-test.js | 4 +- ui/tests/acceptance/client-detail-test.js | 4 +- ui/tests/acceptance/job-allocations-test.js | 4 +- ui/tests/acceptance/job-definition-test.js | 4 +- ui/tests/acceptance/job-deployments-test.js | 4 +- ui/tests/acceptance/job-detail-test.js | 4 +- ui/tests/acceptance/job-evaluations-test.js | 4 +- ui/tests/acceptance/job-run-test.js | 14 ++ ui/tests/acceptance/job-versions-test.js | 4 +- ui/tests/acceptance/jobs-list-test.js | 74 ++++++- ui/tests/acceptance/regions-test.js | 8 +- ui/tests/acceptance/task-detail-test.js | 4 +- ui/tests/acceptance/task-group-detail-test.js | 4 +- ui/tests/pages/jobs/list.js | 6 +- ui/tests/unit/abilities/job-test.js | 183 ++++++++++++++++++ ui/yarn.lock | 50 +++-- 25 files changed, 511 insertions(+), 54 deletions(-) create mode 100644 ui/app/abilities/job.js create mode 100644 ui/tests/unit/abilities/job-test.js diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js new file mode 100644 index 000000000..5829b4861 --- /dev/null +++ b/ui/app/abilities/job.js @@ -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'; + } + }, +}); diff --git a/ui/app/models/policy.js b/ui/app/models/policy.js index 47193a6b7..8b333617b 100644 --- a/ui/app/models/policy.js +++ b/ui/app/models/policy.js @@ -5,4 +5,5 @@ export default Model.extend({ name: attr('string'), description: attr('string'), rules: attr('string'), + rulesJSON: attr(), }); diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 62ba8ef0b..d97eb853c 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -8,6 +8,7 @@ export default Route.extend({ config: service(), system: service(), store: service(), + token: service(), queryParams: { region: { @@ -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 fetchSelfTokenAndPolicies = this.get('token.fetchSelfTokenAndPolicies') + .perform() + .catch(); - const queryParam = transition.to.queryParams.region; - const defaultRegion = this.get('system.defaultRegion.region'); - const currentRegion = this.get('system.activeRegion') || defaultRegion; + return RSVP.all([ + this.get('system.regions'), + this.get('system.defaultRegion'), + fetchSelfTokenAndPolicies, + ]).then(promises => { + if (!this.get('system.shouldShowRegions')) return promises; - // Only reset the store if the region actually changed - if ( - (queryParam && queryParam !== currentRegion) || - (!queryParam && currentRegion !== defaultRegion) - ) { - this.system.reset(); - this.store.unloadAll(); - } + const queryParam = transition.to.queryParams.region; + const defaultRegion = this.get('system.defaultRegion.region'); + const currentRegion = this.get('system.activeRegion') || defaultRegion; - this.set('system.activeRegion', queryParam || defaultRegion); - - return promises; + // 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 diff --git a/ui/app/routes/jobs/run.js b/ui/app/routes/jobs/run.js index 1cdf52cdf..7bda3b103 100644 --- a/ui/app/routes/jobs/run.js +++ b/ui/app/routes/jobs/run.js @@ -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(), @@ -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'), diff --git a/ui/app/services/token.js b/ui/app/services/token.js index f4aece596..88bfcd38f 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -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({ @@ -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 diff --git a/ui/app/styles/components/tooltip.scss b/ui/app/styles/components/tooltip.scss index 38bddb5e6..8531269c7 100644 --- a/ui/app/styles/components/tooltip.scss +++ b/ui/app/styles/components/tooltip.scss @@ -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; diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 417aeaaa0..ab743c4d9 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -15,7 +15,11 @@ {{#if (media "isMobile")}}
- {{#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}} + + {{/if}}
{{/if}}
@@ -48,7 +52,11 @@
{{#if (not (media "isMobile"))}}
- {{#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}} + + {{/if}}
{{/if}} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 110c8548e..a8ca27446 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -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 diff --git a/ui/package.json b/ui/package.json index 3312b0df2..7efa1c25a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index c58670e52..e0f700e2c 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -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' ); diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index a037f79fa..733a1f6ec 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -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' ); diff --git a/ui/tests/acceptance/job-allocations-test.js b/ui/tests/acceptance/job-allocations-test.js index 685d8e9fc..011cbf8b8 100644 --- a/ui/tests/acceptance/job-allocations-test.js +++ b/ui/tests/acceptance/job-allocations-test.js @@ -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' ); diff --git a/ui/tests/acceptance/job-definition-test.js b/ui/tests/acceptance/job-definition-test.js index 108f3b495..973fb5a5f 100644 --- a/ui/tests/acceptance/job-definition-test.js +++ b/ui/tests/acceptance/job-definition-test.js @@ -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' ); diff --git a/ui/tests/acceptance/job-deployments-test.js b/ui/tests/acceptance/job-deployments-test.js index eb1c02109..e683c1c2c 100644 --- a/ui/tests/acceptance/job-deployments-test.js +++ b/ui/tests/acceptance/job-deployments-test.js @@ -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' ); diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index 47df6336e..68e820f32 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -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' ); diff --git a/ui/tests/acceptance/job-evaluations-test.js b/ui/tests/acceptance/job-evaluations-test.js index c46d7567d..1bc3a67e6 100644 --- a/ui/tests/acceptance/job-evaluations-test.js +++ b/ui/tests/acceptance/job-evaluations-test.js @@ -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' ); diff --git a/ui/tests/acceptance/job-run-test.js b/ui/tests/acceptance/job-run-test.js index 5abe55896..fcdb0a2d8 100644 --- a/ui/tests/acceptance/job-run-test.js +++ b/ui/tests/acceptance/job-run-test.js @@ -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( @@ -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) { @@ -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'); + }); }); diff --git a/ui/tests/acceptance/job-versions-test.js b/ui/tests/acceptance/job-versions-test.js index c6607f882..7990437be 100644 --- a/ui/tests/acceptance/job-versions-test.js +++ b/ui/tests/acceptance/job-versions-test.js @@ -40,7 +40,9 @@ module('Acceptance | job versions', function(hooks) { await Versions.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' ); diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 4b3529e62..31b3042de 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -4,6 +4,8 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import JobsList from 'nomad-ui/tests/pages/jobs/list'; +let managementToken, clientToken; + module('Acceptance | jobs list', function(hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -11,6 +13,11 @@ module('Acceptance | jobs list', 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', async function(assert) { @@ -62,11 +69,76 @@ module('Acceptance | jobs list', function(hooks) { test('the new job button transitions to the new job page', async function(assert) { await JobsList.visit(); - await JobsList.runJob(); + await JobsList.runJobButton.click(); assert.equal(currentURL(), '/jobs/run'); }); + test('the job run button is disabled when the token lacks permission', async function(assert) { + window.localStorage.nomadTokenSecret = clientToken.secretId; + await JobsList.visit(); + + assert.ok(JobsList.runJobButton.isDisabled); + + await JobsList.runJobButton.click(); + assert.equal(currentURL(), '/jobs'); + }); + + test('the job run button state can change between namespaces', async function(assert) { + server.createList('namespace', 2); + const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id }); + const job2 = server.create('job', { 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', 'submit-job'], + }, + { + Name: job2.namespaceId, + Capabilities: ['list-jobs'], + }, + ], + }, + }); + + clientToken.policyIds = [policy.id]; + clientToken.save(); + + await JobsList.visit(); + assert.notOk(JobsList.runJobButton.isDisabled); + + const secondNamespace = server.db.namespaces[1]; + await JobsList.visit({ namespace: secondNamespace.id }); + assert.ok(JobsList.runJobButton.isDisabled); + }); + + test('the anonymous policy is fetched to check whether to show the job run button', async function(assert) { + window.localStorage.removeItem('nomadTokenSecret'); + + server.create('policy', { + id: 'anonymous', + name: 'anonymous', + rulesJSON: { + Namespaces: [ + { + Name: 'default', + Capabilities: ['list-jobs', 'submit-job'], + }, + ], + }, + }); + + await JobsList.visit(); + assert.notOk(JobsList.runJobButton.isDisabled); + }); + test('when there are no jobs, there is an empty message', async function(assert) { await JobsList.visit(); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js index 2c4e4a027..2bfeb53f2 100644 --- a/ui/tests/acceptance/regions-test.js +++ b/ui/tests/acceptance/regions-test.js @@ -147,6 +147,7 @@ module('Acceptance | regions (many)', function(hooks) { }); test('when the region is not the default region, all api requests include the region query param', async function(assert) { + window.localStorage.removeItem('nomadTokenSecret'); const region = server.db.regions[1].id; await JobsList.visit({ region }); @@ -154,7 +155,12 @@ module('Acceptance | regions (many)', function(hooks) { await JobsList.jobs.objectAt(0).clickRow(); await PageLayout.gutter.visitClients(); await PageLayout.gutter.visitServers(); - const [regionsRequest, defaultRegionRequest, ...appRequests] = server.pretender.handledRequests; + const [ + , + regionsRequest, + defaultRegionRequest, + ...appRequests + ] = server.pretender.handledRequests; assert.notOk( regionsRequest.url.includes('region='), diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index 852a948ed..febfcc317 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -145,7 +145,9 @@ module('Acceptance | task detail', function(hooks) { await Task.visit({ id: 'not-a-real-allocation', name: task.name }); 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' ); diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index b008803d3..445124506 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -225,7 +225,9 @@ module('Acceptance | task group detail', function(hooks) { await TaskGroup.visit({ id: 'not-a-real-job', name: 'not-a-real-task-group' }); 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' ); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index 2de345e8d..5d8017cba 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -4,6 +4,7 @@ import { collection, clickable, fillable, + is, isPresent, text, visitable, @@ -18,7 +19,10 @@ export default create({ search: fillable('[data-test-jobs-search] input'), - runJob: clickable('[data-test-run-job]'), + runJobButton: { + scope: '[data-test-run-job]', + isDisabled: is('[disabled]'), + }, jobs: collection('[data-test-job-row]', { id: attribute('data-test-job-row'), diff --git a/ui/tests/unit/abilities/job-test.js b/ui/tests/unit/abilities/job-test.js new file mode 100644 index 000000000..f0e337192 --- /dev/null +++ b/ui/tests/unit/abilities/job-test.js @@ -0,0 +1,183 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import Service from '@ember/service'; + +module('Unit | Ability | job', function(hooks) { + setupTest(hooks); + + test('it permits job run for management tokens', function(assert) { + const mockToken = Service.extend({ + selfToken: { type: 'management' }, + }); + + this.owner.register('service:token', mockToken); + + const jobAbility = this.owner.lookup('ability:job'); + assert.ok(jobAbility.canRun); + }); + + test('it permits job run for client tokens with a policy that has namespace submit-job', function(assert) { + const mockSystem = Service.extend({ + activeNamespace: { + name: 'aNamespace', + }, + }); + + const mockToken = Service.extend({ + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: ['submit-job'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + const jobAbility = this.owner.lookup('ability:job'); + assert.ok(jobAbility.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({ + activeNamespace: { + name: 'anotherNamespace', + }, + }); + + const mockToken = Service.extend({ + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: [], + }, + { + Name: 'default', + Capabilities: ['submit-job'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + const jobAbility = this.owner.lookup('ability:job'); + assert.ok(jobAbility.canRun); + }); + + test('it blocks job run for client tokens with a policy that has no submit-job capability', function(assert) { + const mockSystem = Service.extend({ + activeNamespace: { + name: 'aNamespace', + }, + }); + + const mockToken = Service.extend({ + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: ['list-jobs'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + const jobAbility = this.owner.lookup('ability:job'); + assert.notOk(jobAbility.canRun); + }); + + test('it handles globs in namespace names', function(assert) { + const mockSystem = Service.extend({ + activeNamespace: { + name: 'aNamespace', + }, + }); + + const mockToken = Service.extend({ + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'production-*', + Capabilities: ['submit-job'], + }, + { + Name: 'production-api', + Capabilities: ['submit-job'], + }, + { + Name: 'production-web', + Capabilities: [], + }, + { + Name: '*-suffixed', + Capabilities: ['submit-job'], + }, + { + Name: '*-more-suffixed', + Capabilities: [], + }, + { + Name: '*-abc-*', + Capabilities: ['submit-job'], + }, + ], + }, + }, + ], + }); + + 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); + + systemService.set('activeNamespace.name', 'production-api'); + assert.ok(jobAbility.canRun); + + systemService.set('activeNamespace.name', 'production-other'); + assert.ok(jobAbility.canRun); + + systemService.set('activeNamespace.name', 'something-suffixed'); + assert.ok(jobAbility.canRun); + + systemService.set('activeNamespace.name', 'something-more-suffixed'); + assert.notOk( + jobAbility.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'); + }); +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 20cceca6a..88793f073 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -4868,31 +4868,20 @@ ember-basic-dropdown@^2.0.5: ember-maybe-in-element "^0.4.0" ember-truth-helpers "2.1.0" +ember-can@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ember-can/-/ember-can-2.0.0.tgz#b01400380b42aedd9570b89521997a34677feab6" + integrity sha512-4c0HcXUC1HiNwGmW7Gp72Ojhlr/uULQTdJp85up4G3MjonCdV0ZdvPLsMIQITBgWqWY/H5HezMjrdaIDFuEDBA== + dependencies: + ember-cli-babel "7.8.0" + ember-inflector "3.0.1" + ember-cli-babel-plugin-helpers@^1.0.0, ember-cli-babel-plugin-helpers@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.0.tgz#de3baedd093163b6c2461f95964888c1676325ac" integrity sha512-Zr4my8Xn+CzO0gIuFNXji0eTRml5AxZUTDQz/wsNJ5AJAtyFWCY4QtKdoELNNbiCVGt1lq5yLiwTm4scGKu6xA== -ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-beta.7, ember-cli-babel@^6.11.0, ember-cli-babel@^6.16.0, ember-cli-babel@^6.18.0, ember-cli-babel@^6.3.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.7.2, ember-cli-babel@^6.8.0, ember-cli-babel@^6.8.1, ember-cli-babel@^6.8.2, ember-cli-babel@^6.9.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.18.0.tgz#3f6435fd275172edeff2b634ee7b29ce74318957" - integrity sha512-7ceC8joNYxY2wES16iIBlbPSxwKDBhYwC8drU3ZEvuPDMwVv1KzxCNu1fvxyFEBWhwaRNTUxSCsEVoTd9nosGA== - dependencies: - amd-name-resolver "1.2.0" - babel-plugin-debug-macros "^0.2.0-beta.6" - babel-plugin-ember-modules-api-polyfill "^2.6.0" - babel-plugin-transform-es2015-modules-amd "^6.24.0" - babel-polyfill "^6.26.0" - babel-preset-env "^1.7.0" - broccoli-babel-transpiler "^6.5.0" - broccoli-debug "^0.6.4" - broccoli-funnel "^2.0.0" - broccoli-source "^1.1.0" - clone "^2.0.0" - ember-cli-version-checker "^2.1.2" - semver "^5.5.0" - -ember-cli-babel@^7.1.0, ember-cli-babel@^7.7.3: +ember-cli-babel@7.8.0, ember-cli-babel@^7.1.0, ember-cli-babel@^7.7.3: version "7.8.0" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.8.0.tgz#e596500eca0f5a7c9aaee755f803d1542f578acf" integrity sha512-xUBgJQ81fqd7k/KIiGU+pjpoXhrmmRf9pUrqLenNSU5N+yeNFT5a1+w0b+p1F7oBphfXVwuxApdZxrmAHOdA3Q== @@ -4919,6 +4908,25 @@ ember-cli-babel@^7.1.0, ember-cli-babel@^7.7.3: ensure-posix-path "^1.0.2" semver "^5.5.0" +ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-beta.7, ember-cli-babel@^6.11.0, ember-cli-babel@^6.16.0, ember-cli-babel@^6.18.0, ember-cli-babel@^6.3.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.7.2, ember-cli-babel@^6.8.0, ember-cli-babel@^6.8.1, ember-cli-babel@^6.8.2, ember-cli-babel@^6.9.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.18.0.tgz#3f6435fd275172edeff2b634ee7b29ce74318957" + integrity sha512-7ceC8joNYxY2wES16iIBlbPSxwKDBhYwC8drU3ZEvuPDMwVv1KzxCNu1fvxyFEBWhwaRNTUxSCsEVoTd9nosGA== + dependencies: + amd-name-resolver "1.2.0" + babel-plugin-debug-macros "^0.2.0-beta.6" + babel-plugin-ember-modules-api-polyfill "^2.6.0" + babel-plugin-transform-es2015-modules-amd "^6.24.0" + babel-polyfill "^6.26.0" + babel-preset-env "^1.7.0" + broccoli-babel-transpiler "^6.5.0" + broccoli-debug "^0.6.4" + broccoli-funnel "^2.0.0" + broccoli-source "^1.1.0" + clone "^2.0.0" + ember-cli-version-checker "^2.1.2" + semver "^5.5.0" + ember-cli-babel@^7.1.2, ember-cli-babel@^7.5.0: version "7.7.3" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.7.3.tgz#f94709f6727583d18685ca6773a995877b87b8a0" @@ -5556,7 +5564,7 @@ ember-getowner-polyfill@^2.0.1, ember-getowner-polyfill@^2.2.0: ember-cli-version-checker "^2.1.0" ember-factory-for-polyfill "^1.3.1" -"ember-inflector@^2.0.0 || ^3.0.0", ember-inflector@^3.0.1: +ember-inflector@3.0.1, "ember-inflector@^2.0.0 || ^3.0.0", ember-inflector@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ember-inflector/-/ember-inflector-3.0.1.tgz#04be6df4d7e4000f6d6bd70787cdc995f77be4ab" integrity sha512-fngrwMsnhkBt51KZgwNwQYxgURwV4lxtoHdjxf7RueGZ5zM7frJLevhHw7pbQNGqXZ3N+MRkhfNOLkdDK9kFdA==