diff --git a/.changelog/11672.txt b/.changelog/11672.txt
new file mode 100644
index 00000000000..07c9c6e8308
--- /dev/null
+++ b/.changelog/11672.txt
@@ -0,0 +1,3 @@
+```release-note:bug
+ui: Fix the ACL requirements for displaying the job details page
+```
diff --git a/ui/app/abilities/client.js b/ui/app/abilities/client.js
index 350200dc81a..f48eeb93e4d 100644
--- a/ui/app/abilities/client.js
+++ b/ui/app/abilities/client.js
@@ -7,18 +7,30 @@ import classic from 'ember-classic-decorator';
export default class Client extends AbstractAbility {
// Map abilities to policy options (which are coarse for nodes)
// instead of specific behaviors.
+ @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeRead')
+ canRead;
+
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeWrite')
canWrite;
@computed('token.selfTokenPolicies.[]')
- get policiesIncludeNodeWrite() {
- // For each policy record, extract the Node policy
- const policies = (this.get('token.selfTokenPolicies') || [])
- .toArray()
- .map(policy => get(policy, 'rulesJSON.Node.Policy'))
- .compact();
+ get policiesIncludeNodeRead() {
+ return policiesIncludePermissions(this.get('token.selfTokenPolicies'), ['read', 'write']);
+ }
- // Node write is allowed if any policy allows it
- return policies.some(policy => policy === 'write');
+ @computed('token.selfTokenPolicies.[]')
+ get policiesIncludeNodeWrite() {
+ return policiesIncludePermissions(this.get('token.selfTokenPolicies'), ['write']);
}
}
+
+function policiesIncludePermissions(policies = [], permissions = []) {
+ // For each policy record, extract the Node policy
+ const nodePolicies = policies
+ .toArray()
+ .map(policy => get(policy, 'rulesJSON.Node.Policy'))
+ .compact();
+
+ // Check for requested permissions
+ return nodePolicies.some(policy => permissions.includes(policy));
+}
diff --git a/ui/app/components/job-page/abstract.js b/ui/app/components/job-page/abstract.js
index 29beaa6aa12..4239f844fce 100644
--- a/ui/app/components/job-page/abstract.js
+++ b/ui/app/components/job-page/abstract.js
@@ -5,6 +5,7 @@ import classic from 'ember-classic-decorator';
@classic
export default class Abstract extends Component {
+ @service can;
@service system;
job = null;
@@ -20,6 +21,10 @@ export default class Abstract extends Component {
// Set to a { title, description } to surface an error
errorMessage = null;
+ get shouldDisplayClientInformation() {
+ return this.can.can('read client') && this.job.hasClientStatus;
+ }
+
@action
clearErrorMessage() {
this.set('errorMessage', null);
diff --git a/ui/app/components/job-page/parameterized-child.js b/ui/app/components/job-page/parameterized-child.js
index daf0e418340..3f941067c06 100644
--- a/ui/app/components/job-page/parameterized-child.js
+++ b/ui/app/components/job-page/parameterized-child.js
@@ -1,14 +1,11 @@
import { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
-import { inject as service } from '@ember/service';
import PeriodicChildJobPage from './periodic-child';
import classic from 'ember-classic-decorator';
-import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class ParameterizedChild extends PeriodicChildJobPage {
@alias('job.decodedPayload') payload;
- @service store;
@computed('payload')
get payloadJSON() {
@@ -20,10 +17,4 @@ export default class ParameterizedChild extends PeriodicChildJobPage {
}
return json;
}
-
- @jobClientStatus('nodes', 'job') jobClientStatus;
-
- get nodes() {
- return this.store.peekAll('node');
- }
}
diff --git a/ui/app/components/job-page/parts/job-client-status-summary.js b/ui/app/components/job-page/parts/job-client-status-summary.js
index 15ac0c45922..515f085688e 100644
--- a/ui/app/components/job-page/parts/job-client-status-summary.js
+++ b/ui/app/components/job-page/parts/job-client-status-summary.js
@@ -2,20 +2,26 @@ import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
+import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
@classNames('boxed-section')
export default class JobClientStatusSummary extends Component {
job = null;
- jobClientStatus = null;
+ nodes = null;
+ forceCollapsed = false;
gotoClients() {}
- @computed
+ @computed('forceCollapsed')
get isExpanded() {
+ if (this.forceCollapsed) return false;
+
const storageValue = window.localStorage.nomadExpandJobClientStatusSummary;
return storageValue != null ? JSON.parse(storageValue) : true;
}
+ @jobClientStatus('nodes', 'job') jobClientStatus;
+
@action
onSliceClick(ev, slice) {
this.gotoClients([slice.className.camelize()]);
diff --git a/ui/app/components/job-page/periodic-child.js b/ui/app/components/job-page/periodic-child.js
index d581d88dc29..dfe42225dc3 100644
--- a/ui/app/components/job-page/periodic-child.js
+++ b/ui/app/components/job-page/periodic-child.js
@@ -1,13 +1,9 @@
import AbstractJobPage from './abstract';
import { computed } from '@ember/object';
-import { inject as service } from '@ember/service';
import classic from 'ember-classic-decorator';
-import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class PeriodicChild extends AbstractJobPage {
- @service store;
-
@computed('job.{name,id}', 'job.parent.{name,id}')
get breadcrumbs() {
const job = this.job;
@@ -25,10 +21,4 @@ export default class PeriodicChild extends AbstractJobPage {
},
];
}
-
- @jobClientStatus('nodes', 'job') jobClientStatus;
-
- get nodes() {
- return this.store.peekAll('node');
- }
}
diff --git a/ui/app/components/job-page/sysbatch.js b/ui/app/components/job-page/sysbatch.js
index 0819ed49426..abdbdfdf3df 100644
--- a/ui/app/components/job-page/sysbatch.js
+++ b/ui/app/components/job-page/sysbatch.js
@@ -1,15 +1,5 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
-import { inject as service } from '@ember/service';
-import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
-export default class Sysbatch extends AbstractJobPage {
- @service store;
-
- @jobClientStatus('nodes', 'job') jobClientStatus;
-
- get nodes() {
- return this.store.peekAll('node');
- }
-}
+export default class Sysbatch extends AbstractJobPage {}
diff --git a/ui/app/components/job-page/system.js b/ui/app/components/job-page/system.js
index 5909c8f163d..bf2c0444246 100644
--- a/ui/app/components/job-page/system.js
+++ b/ui/app/components/job-page/system.js
@@ -1,15 +1,5 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
-import { inject as service } from '@ember/service';
-import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
-export default class System extends AbstractJobPage {
- @service store;
-
- @jobClientStatus('nodes', 'job') jobClientStatus;
-
- get nodes() {
- return this.store.peekAll('node');
- }
-}
+export default class System extends AbstractJobPage {}
diff --git a/ui/app/components/list-accordion/accordion-head.js b/ui/app/components/list-accordion/accordion-head.js
index 320092634b1..6ef4f9f2a67 100644
--- a/ui/app/components/list-accordion/accordion-head.js
+++ b/ui/app/components/list-accordion/accordion-head.js
@@ -9,8 +9,10 @@ export default class AccordionHead extends Component {
'data-test-accordion-head' = true;
buttonLabel = 'toggle';
+ tooltip = '';
isOpen = false;
isExpandable = true;
+ isDisabled = false;
item = null;
onClose() {}
diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js
index 2b663959f97..b57a44343a2 100644
--- a/ui/app/controllers/jobs/job/index.js
+++ b/ui/app/controllers/jobs/job/index.js
@@ -1,5 +1,5 @@
import { inject as service } from '@ember/service';
-import { alias } from '@ember/object/computed';
+import { computed } from '@ember/object';
import Controller from '@ember/controller';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
import { action } from '@ember/object';
@@ -23,7 +23,15 @@ export default class IndexController extends Controller.extend(WithNamespaceRese
currentPage = 1;
- @alias('model') job;
+ @computed('model.job')
+ get job() {
+ return this.model.job;
+ }
+
+ @computed('model.nodes.[]')
+ get nodes() {
+ return this.model.nodes;
+ }
sortProperty = 'name';
sortDescending = false;
diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js
index 028ee23bf40..79db30dd155 100644
--- a/ui/app/models/allocation.js
+++ b/ui/app/models/allocation.js
@@ -30,6 +30,16 @@ export default class Allocation extends Model {
@fragment('resources') allocatedResources;
@attr('number') jobVersion;
+ // Store basic node information returned by the API to avoid the need for
+ // node:read ACL permission.
+ @attr('string') nodeName;
+ @computed
+ get shortNodeId() {
+ return this.belongsTo('node')
+ .id()
+ .split('-')[0];
+ }
+
@attr('number') modifyIndex;
@attr('date') modifyTime;
diff --git a/ui/app/routes/jobs/job/index.js b/ui/app/routes/jobs/job/index.js
index 463edee0101..b085ca744f5 100644
--- a/ui/app/routes/jobs/job/index.js
+++ b/ui/app/routes/jobs/job/index.js
@@ -1,4 +1,5 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
import { collect } from '@ember/object/computed';
import {
watchRecord,
@@ -9,34 +10,49 @@ import {
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default class IndexRoute extends Route.extend(WithWatchers) {
+ @service can;
+ @service store;
+
async model() {
- // Optimizing future node look ups by preemptively loading everything
- await this.store.findAll('node');
- return this.modelFor('jobs.job');
+ const job = this.modelFor('jobs.job');
+ if (!job) {
+ return { job, nodes: [] };
+ }
+
+ // Optimizing future node look ups by preemptively loading all nodes if
+ // necessary and allowed.
+ if (this.can.can('read client') && job.get('hasClientStatus')) {
+ await this.store.findAll('node');
+ }
+ const nodes = this.store.peekAll('node');
+ return { job, nodes };
}
startWatchers(controller, model) {
- if (!model) {
+ if (!model.job) {
return;
}
controller.set('watchers', {
- model: this.watch.perform(model),
- summary: this.watchSummary.perform(model.get('summary')),
- allocations: this.watchAllocations.perform(model),
- evaluations: this.watchEvaluations.perform(model),
+ model: this.watch.perform(model.job),
+ summary: this.watchSummary.perform(model.job.get('summary')),
+ allocations: this.watchAllocations.perform(model.job),
+ evaluations: this.watchEvaluations.perform(model.job),
latestDeployment:
- model.get('supportsDeployments') && this.watchLatestDeployment.perform(model),
+ model.job.get('supportsDeployments') && this.watchLatestDeployment.perform(model.job),
list:
- model.get('hasChildren') &&
- this.watchAllJobs.perform({ namespace: model.namespace.get('name') }),
- nodes: model.get('hasClientStatus') && this.watchNodes.perform(),
+ model.job.get('hasChildren') &&
+ this.watchAllJobs.perform({ namespace: model.job.namespace.get('name') }),
+ nodes:
+ this.can.can('read client') &&
+ model.job.get('hasClientStatus') &&
+ this.watchNodes.perform(),
});
}
setupController(controller, model) {
// Parameterized and periodic detail pages, which list children jobs,
// should sort by submit time.
- if (model && ['periodic', 'parameterized'].includes(model.templateType)) {
+ if (model.job && ['periodic', 'parameterized'].includes(model.job.templateType)) {
controller.setProperties({
sortProperty: 'submitTime',
sortDescending: true,
diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs
index efa1915524c..934d69336d6 100644
--- a/ui/app/templates/components/allocation-row.hbs
+++ b/ui/app/templates/components/allocation-row.hbs
@@ -1,8 +1,10 @@