diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js new file mode 100644 index 000000000000..6f45ae795d3c --- /dev/null +++ b/ui/app/abilities/abstract.js @@ -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'; + } + }, +}); diff --git a/ui/app/abilities/allocation.js b/ui/app/abilities/allocation.js new file mode 100644 index 000000000000..e5ce8c325431 --- /dev/null +++ b/ui/app/abilities/allocation.js @@ -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'); + }); + }), +}); diff --git a/ui/app/abilities/client.js b/ui/app/abilities/client.js index 5278502b9017..58380081c6bb 100644 --- a/ui/app/abilities/client.js +++ b/ui/app/abilities/client.js @@ -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') || []) diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index ac73c05f2fa3..07b4edc73249 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -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'; - } - }, }); diff --git a/ui/app/templates/components/exec/open-button.hbs b/ui/app/templates/components/exec/open-button.hbs index e28fb498e142..bb58282d44e9 100644 --- a/ui/app/templates/components/exec/open-button.hbs +++ b/ui/app/templates/components/exec/open-button.hbs @@ -1,8 +1,12 @@ - +{{#let (cannot "exec allocation") as |cannotExec|}} + +{{/let}} \ No newline at end of file diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index 68e820f32c9f..0a94ad75077c 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -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) { @@ -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); + }); }); diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index 654f9ec4e40f..8fbea87490bb 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -3,7 +3,9 @@ import { create, collection, clickable, + hasClass, isPresent, + property, text, visitable, } from 'ember-cli-page-object'; @@ -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]', { diff --git a/ui/tests/unit/abilities/allocation-test.js b/ui/tests/unit/abilities/allocation-test.js new file mode 100644 index 000000000000..1190bfc84c23 --- /dev/null +++ b/ui/tests/unit/abilities/allocation-test.js @@ -0,0 +1,199 @@ +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 | allocation', function(hooks) { + setupTest(hooks); + setupAbility('allocation')(hooks); + + test('it permits alloc exec when ACLs are disabled', function(assert) { + const mockToken = Service.extend({ + aclEnabled: false, + }); + + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canExec); + }); + + test('it permits alloc exec for management tokens', function(assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'management' }, + }); + + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canExec); + }); + + test('it permits alloc exec for client tokens with a policy that has namespace alloc-exec', function(assert) { + const mockSystem = Service.extend({ + aclEnabled: true, + activeNamespace: { + name: 'aNamespace', + }, + }); + + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: ['alloc-exec'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canExec); + }); + + test('it permits alloc exec for client tokens with a policy that has default namespace alloc-exec 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: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: [], + }, + { + Name: 'default', + Capabilities: ['alloc-exec'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canExec); + }); + + test('it blocks alloc exec for client tokens with a policy that has no alloc-exec capability', function(assert) { + const mockSystem = Service.extend({ + aclEnabled: true, + activeNamespace: { + name: 'aNamespace', + }, + }); + + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'aNamespace', + Capabilities: ['list-jobs'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + assert.notOk(this.ability.canExec); + }); + + 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: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'production-*', + Capabilities: ['alloc-exec'], + }, + { + Name: 'production-api', + Capabilities: ['alloc-exec'], + }, + { + Name: 'production-web', + Capabilities: [], + }, + { + Name: '*-suffixed', + Capabilities: ['alloc-exec'], + }, + { + Name: '*-more-suffixed', + Capabilities: [], + }, + { + Name: '*-abc-*', + Capabilities: ['alloc-exec'], + }, + ], + }, + }, + ], + }); + + this.owner.register('service:system', mockSystem); + this.owner.register('service:token', mockToken); + + const systemService = this.owner.lookup('service:system'); + + systemService.set('activeNamespace.name', 'production-web'); + assert.notOk(this.ability.canExec); + + systemService.set('activeNamespace.name', 'production-api'); + assert.ok(this.ability.canExec); + + systemService.set('activeNamespace.name', 'production-other'); + assert.ok(this.ability.canExec); + + systemService.set('activeNamespace.name', 'something-suffixed'); + assert.ok(this.ability.canExec); + + systemService.set('activeNamespace.name', 'something-more-suffixed'); + assert.notOk( + this.ability.canExec, + 'expected the namespace with the greatest number of matched characters to be chosen' + ); + + systemService.set('activeNamespace.name', '000-abc-999'); + assert.ok(this.ability.canExec, 'expected to be able to match against more than one wildcard'); + }); +});