diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js new file mode 100644 index 000000000000..5829b4861504 --- /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 47193a6b78d9..8b333617bd21 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 62ba8ef0b691..d97eb853cd26 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 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 diff --git a/ui/app/routes/jobs/run.js b/ui/app/routes/jobs/run.js index 1cdf52cdfe6b..7bda3b103f88 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 f4aece596916..88bfcd38fdee 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 38bddb5e6aed..8531269c7d26 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 417aeaaa0d77..ab743c4d978d 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 110c8548ec4d..a8ca27446aa7 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 3312b0df2898..7efa1c25aa2f 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 c58670e520b3..e0f700e2c542 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 a037f79fa148..733a1f6ec181 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 685d8e9fc683..011cbf8b8751 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 108f3b49534b..973fb5a5f751 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 eb1c02109913..e683c1c2c765 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 47df6336ec79..68e820f32c9f 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 c46d7567d8c1..1bc3a67e670d 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 5abe55896e22..fcdb0a2d8e50 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 c6607f882871..7990437be450 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 4b3529e62644..31b3042dee1d 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 2c4e4a0279ff..2bfeb53f2df4 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 852a948edfde..febfcc317f73 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 b008803d3e5b..44512450655a 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 2de345e8d219..5d8017cba743 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 000000000000..f0e337192476 --- /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 20cceca6ace0..88793f073195 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==