Skip to content

Commit 8c3a210

Browse files
authored
Add ACL-checking to turn off exec button (#7919)
This closes #7453. It adds an abstraction to handle the common needs of ability-determination.
1 parent 55fa55c commit 8c3a210

File tree

8 files changed

+366
-88
lines changed

8 files changed

+366
-88
lines changed

ui/app/abilities/abstract.js

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Ability } from 'ember-can';
2+
import { inject as service } from '@ember/service';
3+
import { computed, get } from '@ember/object';
4+
import { equal, not } from '@ember/object/computed';
5+
6+
export default Ability.extend({
7+
system: service(),
8+
token: service(),
9+
10+
bypassAuthorization: not('token.aclEnabled'),
11+
selfTokenIsManagement: equal('token.selfToken.type', 'management'),
12+
13+
activeNamespace: computed('system.activeNamespace.name', function() {
14+
return this.get('system.activeNamespace.name') || 'default';
15+
}),
16+
17+
rulesForActiveNamespace: computed('activeNamespace', 'token.selfTokenPolicies.[]', function() {
18+
let activeNamespace = this.activeNamespace;
19+
20+
return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => {
21+
let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || [];
22+
23+
let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace);
24+
25+
if (matchingNamespace) {
26+
rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace));
27+
}
28+
29+
return rules;
30+
}, []);
31+
}),
32+
33+
// Chooses the closest namespace as described at the bottom here:
34+
// https://www.nomadproject.io/guides/security/acl.html#namespace-rules
35+
_findMatchingNamespace(policyNamespaces, activeNamespace) {
36+
let namespaceNames = policyNamespaces.mapBy('Name');
37+
38+
if (namespaceNames.includes(activeNamespace)) {
39+
return activeNamespace;
40+
}
41+
42+
let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*'));
43+
44+
let matchingNamespaceName = globNamespaceNames.reduce(
45+
(mostMatching, namespaceName) => {
46+
// Convert * wildcards to .* for regex matching
47+
let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*'));
48+
let characterDifference = activeNamespace.length - namespaceName.length;
49+
50+
if (
51+
characterDifference < mostMatching.mostMatchingCharacterDifference &&
52+
activeNamespace.match(namespaceNameRegExp)
53+
) {
54+
return {
55+
mostMatchingNamespaceName: namespaceName,
56+
mostMatchingCharacterDifference: characterDifference,
57+
};
58+
} else {
59+
return mostMatching;
60+
}
61+
},
62+
{ mostMatchingNamespaceName: null, mostMatchingCharacterDifference: Number.MAX_SAFE_INTEGER }
63+
).mostMatchingNamespaceName;
64+
65+
if (matchingNamespaceName) {
66+
return matchingNamespaceName;
67+
} else if (namespaceNames.includes('default')) {
68+
return 'default';
69+
}
70+
},
71+
});

ui/app/abilities/allocation.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import AbstractAbility from './abstract';
2+
import { computed, get } from '@ember/object';
3+
import { or } from '@ember/object/computed';
4+
5+
export default AbstractAbility.extend({
6+
canExec: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportExec'),
7+
8+
policiesSupportExec: computed('[email protected]', function() {
9+
return this.rulesForActiveNamespace.some(rules => {
10+
let capabilities = get(rules, 'Capabilities') || [];
11+
return capabilities.includes('alloc-exec');
12+
});
13+
}),
14+
});

ui/app/abilities/client.js

+3-9
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
import { Ability } from 'ember-can';
2-
import { inject as service } from '@ember/service';
1+
import AbstractAbility from './abstract';
32
import { computed, get } from '@ember/object';
4-
import { equal, or, not } from '@ember/object/computed';
5-
6-
export default Ability.extend({
7-
token: service(),
3+
import { or } from '@ember/object/computed';
84

5+
export default AbstractAbility.extend({
96
// Map abilities to policy options (which are coarse for nodes)
107
// instead of specific behaviors.
118
canWrite: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeWrite'),
129

13-
bypassAuthorization: not('token.aclEnabled'),
14-
selfTokenIsManagement: equal('token.selfToken.type', 'management'),
15-
1610
policiesIncludeNodeWrite: computed('token.selfTokenPolicies.[]', function() {
1711
// For each policy record, extract the Node policy
1812
const policies = (this.get('token.selfTokenPolicies') || [])

ui/app/abilities/job.js

+3-69
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,14 @@
1-
import { Ability } from 'ember-can';
2-
import { inject as service } from '@ember/service';
1+
import AbstractAbility from './abstract';
32
import { computed, get } from '@ember/object';
4-
import { equal, or, not } from '@ember/object/computed';
5-
6-
export default Ability.extend({
7-
system: service(),
8-
token: service(),
3+
import { or } from '@ember/object/computed';
94

5+
export default AbstractAbility.extend({
106
canRun: or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportRunning'),
117

12-
bypassAuthorization: not('token.aclEnabled'),
13-
selfTokenIsManagement: equal('token.selfToken.type', 'management'),
14-
15-
activeNamespace: computed('system.activeNamespace.name', function() {
16-
return this.get('system.activeNamespace.name') || 'default';
17-
}),
18-
19-
rulesForActiveNamespace: computed('activeNamespace', 'token.selfTokenPolicies.[]', function() {
20-
let activeNamespace = this.activeNamespace;
21-
22-
return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => {
23-
let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || [];
24-
25-
let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace);
26-
27-
if (matchingNamespace) {
28-
rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace));
29-
}
30-
31-
return rules;
32-
}, []);
33-
}),
34-
358
policiesSupportRunning: computed('[email protected]', function() {
369
return this.rulesForActiveNamespace.some(rules => {
3710
let capabilities = get(rules, 'Capabilities') || [];
3811
return capabilities.includes('submit-job');
3912
});
4013
}),
41-
42-
// Chooses the closest namespace as described at the bottom here:
43-
// https://www.nomadproject.io/guides/security/acl.html#namespace-rules
44-
_findMatchingNamespace(policyNamespaces, activeNamespace) {
45-
let namespaceNames = policyNamespaces.mapBy('Name');
46-
47-
if (namespaceNames.includes(activeNamespace)) {
48-
return activeNamespace;
49-
}
50-
51-
let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*'));
52-
53-
let matchingNamespaceName = globNamespaceNames.reduce(
54-
(mostMatching, namespaceName) => {
55-
// Convert * wildcards to .* for regex matching
56-
let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*'));
57-
let characterDifference = activeNamespace.length - namespaceName.length;
58-
59-
if (
60-
characterDifference < mostMatching.mostMatchingCharacterDifference &&
61-
activeNamespace.match(namespaceNameRegExp)
62-
) {
63-
return {
64-
mostMatchingNamespaceName: namespaceName,
65-
mostMatchingCharacterDifference: characterDifference,
66-
};
67-
} else {
68-
return mostMatching;
69-
}
70-
},
71-
{ mostMatchingNamespaceName: null, mostMatchingCharacterDifference: Number.MAX_SAFE_INTEGER }
72-
).mostMatchingNamespaceName;
73-
74-
if (matchingNamespaceName) {
75-
return matchingNamespaceName;
76-
} else if (namespaceNames.includes('default')) {
77-
return 'default';
78-
}
79-
},
8014
});
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
<button
2-
data-test-exec-button
3-
type="button"
4-
class="button exec-button is-outline is-small"
5-
{{action "open"}}>
6-
{{x-icon "console"}}
7-
<span>Exec</span>
8-
</button>
1+
{{#let (cannot "exec allocation") as |cannotExec|}}
2+
<button
3+
data-test-exec-button
4+
type="button"
5+
class="button exec-button is-outline is-small {{if cannotExec "tooltip"}}"
6+
disabled={{if cannotExec 'disabled'}}
7+
aria-label={{if cannotExec "You don’t have permission to exec"}}
8+
{{action "open"}}>
9+
{{x-icon "console"}}
10+
<span>Exec</span>
11+
</button>
12+
{{/let}}

ui/tests/acceptance/job-detail-test.js

+59-2
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,16 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
6464
setupApplicationTest(hooks);
6565
setupMirage(hooks);
6666

67-
let job;
67+
let job, clientToken;
6868

6969
hooks.beforeEach(function() {
7070
server.createList('namespace', 2);
7171
server.create('node');
72-
job = server.create('job', { type: 'service', namespaceId: server.db.namespaces[1].name });
72+
job = server.create('job', { type: 'service', status: 'running', namespaceId: server.db.namespaces[1].name });
7373
server.createList('job', 3, { namespaceId: server.db.namespaces[0].name });
74+
75+
server.create('token');
76+
clientToken = server.create('token');
7477
});
7578

7679
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) {
101104
assert.equal(jobRow.name, jobs[index].name, `Job ${index} is right`);
102105
});
103106
});
107+
108+
test('the exec button state can change between namespaces', async function(assert) {
109+
const job1 = server.create('job', { status: 'running', namespaceId: server.db.namespaces[0].id });
110+
const job2 = server.create('job', { status: 'running', namespaceId: server.db.namespaces[1].id });
111+
112+
window.localStorage.nomadTokenSecret = clientToken.secretId;
113+
114+
const policy = server.create('policy', {
115+
id: 'something',
116+
name: 'something',
117+
rulesJSON: {
118+
Namespaces: [
119+
{
120+
Name: job1.namespaceId,
121+
Capabilities: ['list-jobs', 'alloc-exec'],
122+
},
123+
{
124+
Name: job2.namespaceId,
125+
Capabilities: ['list-jobs'],
126+
},
127+
],
128+
},
129+
});
130+
131+
clientToken.policyIds = [policy.id];
132+
clientToken.save();
133+
134+
await JobDetail.visit({ id: job1.id });
135+
assert.notOk(JobDetail.execButton.isDisabled);
136+
137+
const secondNamespace = server.db.namespaces[1];
138+
await JobDetail.visit({ id: job2.id, namespace: secondNamespace.name });
139+
assert.ok(JobDetail.execButton.isDisabled);
140+
});
141+
142+
test('the anonymous policy is fetched to check whether to show the exec button', async function(assert) {
143+
window.localStorage.removeItem('nomadTokenSecret');
144+
145+
server.create('policy', {
146+
id: 'anonymous',
147+
name: 'anonymous',
148+
rulesJSON: {
149+
Namespaces: [
150+
{
151+
Name: 'default',
152+
Capabilities: ['list-jobs', 'alloc-exec'],
153+
},
154+
],
155+
},
156+
});
157+
158+
await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name });
159+
assert.notOk(JobDetail.execButton.isDisabled);
160+
});
104161
});

ui/tests/pages/jobs/detail.js

+5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
create,
44
collection,
55
clickable,
6+
hasClass,
67
isPresent,
8+
property,
79
text,
810
visitable,
911
} from 'ember-cli-page-object';
@@ -28,6 +30,9 @@ export default create({
2830

2931
execButton: {
3032
scope: '[data-test-exec-button]',
33+
isDisabled: property('disabled'),
34+
hasTooltip: hasClass('tooltip'),
35+
tooltipText: attribute('aria-label'),
3136
},
3237

3338
stats: collection('[data-test-job-stat]', {

0 commit comments

Comments
 (0)