diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index fbc8d982c6b3..892837658c72 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -1,5 +1,5 @@ import AbstractAbility from './abstract'; -import { computed } from '@ember/object'; +import { computed, get } from '@ember/object'; import { or } from '@ember/object/computed'; export default class Job extends AbstractAbility { @@ -9,7 +9,7 @@ export default class Job extends AbstractAbility { @or( 'bypassAuthorization', 'selfTokenIsManagement', - 'policiesSupportRunning', + 'specificNamespaceSupportsRunning', 'policiesSupportScaling' ) canScale; @@ -27,8 +27,34 @@ export default class Job extends AbstractAbility { ) canDispatch; - @computed('rulesForNamespace.@each.capabilities') + policyNamespacesIncludePermissions(policies = [], permissions = []) { + // For each policy record, extract all policies of all namespaces + const allNamespacePolicies = policies + .toArray() + .map((policy) => get(policy, 'rulesJSON.Namespaces')) + .flat() + .map((namespace = {}) => { + return namespace.Capabilities; + }) + .flat() + .compact(); + + // Check for requested permissions + return allNamespacePolicies.some((policy) => { + return permissions.includes(policy); + }); + } + + @computed('token.selfTokenPolicies.[]') get policiesSupportRunning() { + return this.policyNamespacesIncludePermissions( + this.token.selfTokenPolicies, + ['submit-job'] + ); + } + + @computed('rulesForNamespace.@each.capabilities') + get specificNamespaceSupportsRunning() { return this.namespaceIncludesCapability('submit-job'); } diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 8545b4aa39ec..08cc9b98e28c 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -30,7 +30,7 @@ export default class JobAdapter extends WatchableNamespaceIDs { } parse(spec) { - const url = addToPath(this.urlForFindAll('job'), '/parse'); + const url = addToPath(this.urlForFindAll('job'), '/parse?namespace=*'); return this.ajax(url, 'POST', { data: { JobHCL: spec, diff --git a/ui/app/components/job-editor.js b/ui/app/components/job-editor.js index 6b9d9da0d587..c2944e4a24f2 100644 --- a/ui/app/components/job-editor.js +++ b/ui/app/components/job-editor.js @@ -62,7 +62,8 @@ export default class JobEditor extends Component { try { yield this.job.parse(); } catch (err) { - const error = messageFromAdapterError(err) || 'Could not parse input'; + const error = + messageFromAdapterError(err, 'parse jobs') || 'Could not parse input'; this.set('parseError', error); this.scrollToError(); return; @@ -72,7 +73,8 @@ export default class JobEditor extends Component { const plan = yield this.job.plan(); this.set('planOutput', plan); } catch (err) { - const error = messageFromAdapterError(err) || 'Could not plan job'; + const error = + messageFromAdapterError(err, 'plan jobs') || 'Could not plan job'; this.set('planError', error); this.scrollToError(); } diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 59ea143a31bc..2a758f44a7b7 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -468,6 +468,37 @@ module('Acceptance | jobs list', function (hooks) { await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE }); assert.notOk(JobsList.runJobButton.isDisabled); + await JobsList.visit({ namespace: READ_ONLY_NAMESPACE }); + assert.notOk(JobsList.runJobButton.isDisabled); + }); + + test('when the user has no client tokens that allow them to run a job', async function (assert) { + const READ_AND_WRITE_NAMESPACE = 'read-and-write-namespace'; + const READ_ONLY_NAMESPACE = 'read-only-namespace'; + + server.create('namespace', { id: READ_ONLY_NAMESPACE }); + + const policy = server.create('policy', { + id: 'something', + name: 'something', + rulesJSON: { + Namespaces: [ + { + Name: READ_ONLY_NAMESPACE, + Capabilities: ['list-job'], + }, + ], + }, + }); + + clientToken.policyIds = [policy.id]; + clientToken.save(); + + window.localStorage.nomadTokenSecret = clientToken.secretId; + + await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE }); + assert.ok(JobsList.runJobButton.isDisabled); + await JobsList.visit({ namespace: READ_ONLY_NAMESPACE }); assert.ok(JobsList.runJobButton.isDisabled); }); diff --git a/ui/tests/integration/components/job-editor-test.js b/ui/tests/integration/components/job-editor-test.js index aab1e7973d63..05c121ff4d6a 100644 --- a/ui/tests/integration/components/job-editor-test.js +++ b/ui/tests/integration/components/job-editor-test.js @@ -177,7 +177,7 @@ module('Integration | Component | job-editor', function (hooks) { await planJob(spec); const requests = this.server.pretender.handledRequests.mapBy('url'); assert.ok( - requests.includes('/v1/jobs/parse'), + requests.includes('/v1/jobs/parse?namespace=*'), 'HCL job spec is parsed first' ); assert.ok( diff --git a/ui/tests/unit/abilities/job-test.js b/ui/tests/unit/abilities/job-test.js index f1f89233bbf9..70e2c1eded45 100644 --- a/ui/tests/unit/abilities/job-test.js +++ b/ui/tests/unit/abilities/job-test.js @@ -251,19 +251,18 @@ module('Unit | Ability | job', function (hooks) { this.owner.register('service:token', mockToken); assert.ok( - this.can.cannot('run job', null, { namespace: 'production-web' }) + this.can.can( + 'run job', + null, + { namespace: 'production-web' }, + 'The existence of a single namespace where a job can be run means that can run is enabled' + ) ); assert.ok(this.can.can('run job', null, { namespace: 'production-api' })); assert.ok(this.can.can('run job', null, { namespace: 'production-other' })); assert.ok( this.can.can('run job', null, { namespace: 'something-suffixed' }) ); - assert.ok( - this.can.cannot('run job', null, { - namespace: 'something-more-suffixed', - }), - 'expected the namespace with the greatest number of matched characters to be chosen' - ); assert.ok( this.can.can('run job', null, { namespace: '000-abc-999' }), 'expected to be able to match against more than one wildcard' diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index e34b3c8d79ab..534c22376d3a 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -522,7 +522,7 @@ module('Unit | Adapter | Job', function (hooks) { await this.subject().parse('job "name-goes-here" {'); const request = this.server.pretender.handledRequests[0]; - assert.equal(request.url, `/v1/jobs/parse?region=${region}`); + assert.equal(request.url, `/v1/jobs/parse?namespace=*®ion=${region}`); assert.equal(request.method, 'POST'); assert.deepEqual(JSON.parse(request.requestBody), { JobHCL: 'job "name-goes-here" {',