diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js
new file mode 100644
index 00000000000..6f45ae795d3
--- /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 00000000000..e5ce8c32543
--- /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 5278502b901..58380081c6b 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 ac73c05f2fa..07b4edc7324 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 e28fb498e14..bb58282d44e 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 68e820f32c9..0a94ad75077 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 654f9ec4e40..8fbea87490b 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 00000000000..1190bfc84c2
--- /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');
+ });
+});