diff --git a/.changelog/13976.txt b/.changelog/13976.txt new file mode 100644 index 000000000..17a4cbb0b --- /dev/null +++ b/.changelog/13976.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Added a Policy Editor interface for management tokens +``` diff --git a/ui/app/abilities/policy.js b/ui/app/abilities/policy.js new file mode 100644 index 000000000..6ab8144eb --- /dev/null +++ b/ui/app/abilities/policy.js @@ -0,0 +1,12 @@ +import AbstractAbility from './abstract'; +import { alias } from '@ember/object/computed'; +import classic from 'ember-classic-decorator'; + +@classic +export default class Policy extends AbstractAbility { + @alias('selfTokenIsManagement') canRead; + @alias('selfTokenIsManagement') canList; + @alias('selfTokenIsManagement') canWrite; + @alias('selfTokenIsManagement') canUpdate; + @alias('selfTokenIsManagement') canDestroy; +} diff --git a/ui/app/adapters/policy.js b/ui/app/adapters/policy.js index 85be4fbad..cef408be3 100644 --- a/ui/app/adapters/policy.js +++ b/ui/app/adapters/policy.js @@ -4,4 +4,12 @@ import classic from 'ember-classic-decorator'; @classic export default class PolicyAdapter extends ApplicationAdapter { namespace = namespace + '/acl'; + + urlForCreateRecord(_modelName, model) { + return this.urlForUpdateRecord(model.attr('name'), 'policy'); + } + + urlForDeleteRecord(id) { + return this.urlForUpdateRecord(id, 'policy'); + } } diff --git a/ui/app/components/policy-editor.hbs b/ui/app/components/policy-editor.hbs new file mode 100644 index 000000000..353748b18 --- /dev/null +++ b/ui/app/components/policy-editor.hbs @@ -0,0 +1,61 @@ +
\ No newline at end of file diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js new file mode 100644 index 000000000..0005909f5 --- /dev/null +++ b/ui/app/components/policy-editor.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import messageForError from 'nomad-ui/utils/message-from-adapter-error'; + +export default class PolicyEditorComponent extends Component { + @service flashMessages; + @service router; + @service store; + + @alias('args.policy') policy; + + @action updatePolicyRules(value) { + this.policy.set('rules', value); + } + + @action async save(e) { + if (e instanceof Event) { + e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault() + } + try { + const nameRegex = '^[a-zA-Z0-9-]{1,128}$'; + if (!this.policy.name?.match(nameRegex)) { + throw new Error( + 'Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.' + ); + } + + if ( + this.policy.isNew && + this.store.peekRecord('policy', this.policy.name) + ) { + throw new Error( + `A policy with name ${this.policy.name} already exists.` + ); + } + + this.policy.id = this.policy.name; + + await this.policy.save(); + + this.flashMessages.add({ + title: 'Policy Saved', + type: 'success', + destroyOnClick: false, + timeout: 5000, + }); + + this.router.transitionTo('policies'); + } catch (error) { + console.log('error and its', error); + this.flashMessages.add({ + title: `Error creating Policy ${this.policy.name}`, + message: messageForError(error), + type: 'error', + destroyOnClick: false, + sticky: true, + }); + } + } +} diff --git a/ui/app/controllers/policies/index.js b/ui/app/controllers/policies/index.js new file mode 100644 index 000000000..faaa78dd7 --- /dev/null +++ b/ui/app/controllers/policies/index.js @@ -0,0 +1,19 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class PoliciesIndexController extends Controller { + @service router; + get policies() { + return this.model.policies.map((policy) => { + policy.tokens = this.model.tokens.filter((token) => { + return token.policies.includes(policy); + }); + return policy; + }); + } + + @action openPolicy(policy) { + this.router.transitionTo('policies.policy', policy.name); + } +} diff --git a/ui/app/controllers/policies/policy.js b/ui/app/controllers/policies/policy.js new file mode 100644 index 000000000..f54663dd4 --- /dev/null +++ b/ui/app/controllers/policies/policy.js @@ -0,0 +1,46 @@ +// @ts-check +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +export default class PoliciesPolicyController extends Controller { + @service flashMessages; + @service router; + + @tracked isDeleting = false; + + @action + onDeletePrompt() { + this.isDeleting = true; + } + + @action + onDeleteCancel() { + this.isDeleting = false; + } + + @task(function* () { + try { + yield this.model.deleteRecord(); + yield this.model.save(); + this.flashMessages.add({ + title: 'Policy Deleted', + type: 'success', + destroyOnClick: false, + timeout: 5000, + }); + this.router.transitionTo('policies'); + } catch (err) { + this.flashMessages.add({ + title: `Error deleting Policy ${this.model.name}`, + message: err, + type: 'error', + destroyOnClick: false, + sticky: true, + }); + } + }) + deletePolicy; +} diff --git a/ui/app/modifiers/code-mirror.js b/ui/app/modifiers/code-mirror.js index 3e3b4f3c7..b7305b720 100644 --- a/ui/app/modifiers/code-mirror.js +++ b/ui/app/modifiers/code-mirror.js @@ -8,8 +8,18 @@ import 'codemirror/addon/selection/active-line'; import 'codemirror/addon/lint/lint.js'; import 'codemirror/addon/lint/json-lint.js'; import 'codemirror/mode/javascript/javascript'; +import 'codemirror/mode/ruby/ruby'; export default class CodeMirrorModifier extends Modifier { + get autofocus() { + if (Object.hasOwn({ ...this.args.named }, 'autofocus')) { + // spread (...) because proxy, and because Ember over-eagerly prevents named prop lookups for modifier args. + return this.args.named.autofocus; + } else { + return !this.args.named.readOnly; + } + } + didInstall() { this._setup(); } @@ -49,6 +59,10 @@ export default class CodeMirrorModifier extends Modifier { screenReaderLabel: this.args.named.screenReaderLabel || '', }); + if (this.autofocus) { + editor.focus(); + } + editor.on('change', bind(this, this._onChange)); this._editor = editor; diff --git a/ui/app/router.js b/ui/app/router.js index 78236609d..8f47c32af 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -98,6 +98,14 @@ Router.map(function () { path: '/path/*absolutePath', }); }); + + this.route('policies', function () { + this.route('new'); + + this.route('policy', { + path: '/:name', + }); + }); // Mirage-only route for testing OIDC flow if (config['ember-cli-mirage']) { this.route('oidc-mock'); diff --git a/ui/app/routes/policies.js b/ui/app/routes/policies.js new file mode 100644 index 000000000..bcb385e05 --- /dev/null +++ b/ui/app/routes/policies.js @@ -0,0 +1,27 @@ +import Route from '@ember/routing/route'; +import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; +import { inject as service } from '@ember/service'; +import { hash } from 'rsvp'; + +export default class PoliciesRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service can; + @service store; + @service router; + + beforeModel() { + if (this.can.cannot('list policies')) { + this.router.transitionTo('/jobs'); + } + } + + async model() { + return await hash({ + policies: this.store.query('policy', { reload: true }), + tokens: this.store.query('token', { reload: true }), + }); + } +} diff --git a/ui/app/routes/policies/new.js b/ui/app/routes/policies/new.js new file mode 100644 index 000000000..391e1e110 --- /dev/null +++ b/ui/app/routes/policies/new.js @@ -0,0 +1,109 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +const INITIAL_POLICY_RULES = `# See https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-policies for ACL Policy details + +# Example policy structure: + +namespace "default" { + policy = "deny" + capabilities = [] +} + +namespace "example-ns" { + policy = "deny" + capabilities = ["list-jobs", "read-job"] + variables { + # list access to variables in all paths, full access in nested/variables/* + path "*" { + capabilities = ["list"] + } + path "nested/variables/*" { + capabilities = ["write", "read", "destroy", "list"] + } + } +} + +host_volume "example-volume" { + policy = "deny" +} + +agent { + policy = "deny" +} + +node { + policy = "deny" +} + +quota { + policy = "deny" +} + +operator { + policy = "deny" +} + +# Possible Namespace Policies: +# * deny +# * read +# * write +# * scale + +# Possible Namespace Capabilities: +# * list-jobs +# * parse-job +# * read-job +# * submit-job +# * dispatch-job +# * read-logs +# * read-fs +# * alloc-exec +# * alloc-lifecycle +# * csi-write-volume +# * csi-mount-volume +# * list-scaling-policies +# * read-scaling-policy +# * read-job-scaling +# * scale-job + +# Possible Variables capabilities +# * write +# * read +# * destroy +# * list + +# Possible Policies for "agent", "node", "quota", "operator", and "host_volume": +# * deny +# * read +# * write +`; + +export default class PoliciesNewRoute extends Route { + @service can; + @service router; + + beforeModel() { + if (this.can.cannot('write policy')) { + this.router.transitionTo('/policies'); + } + } + + model() { + return this.store.createRecord('policy', { + name: '', + rules: INITIAL_POLICY_RULES, + }); + } + + resetController(controller, isExiting) { + // If the user navigates away from /new, clear the path + controller.set('path', null); + if (isExiting) { + // If user didn't save, delete the freshly created model + if (controller.model.isNew) { + controller.model.destroyRecord(); + } + } + } +} diff --git a/ui/app/routes/policies/policy.js b/ui/app/routes/policies/policy.js new file mode 100644 index 000000000..acd3a2156 --- /dev/null +++ b/ui/app/routes/policies/policy.js @@ -0,0 +1,16 @@ +import Route from '@ember/routing/route'; +import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; +import { inject as service } from '@ember/service'; + +export default class PoliciesPolicyRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service store; + model(params) { + return this.store.findRecord('policy', decodeURIComponent(params.name), { + reload: true, + }); + } +} diff --git a/ui/app/serializers/policy.js b/ui/app/serializers/policy.js index 913dff7f1..e5dab938b 100644 --- a/ui/app/serializers/policy.js +++ b/ui/app/serializers/policy.js @@ -2,9 +2,17 @@ import ApplicationSerializer from './application'; import classic from 'ember-classic-decorator'; @classic -export default class Policy extends ApplicationSerializer { +export default class PolicySerializer extends ApplicationSerializer { + primaryKey = 'Name'; + normalize(typeHash, hash) { hash.ID = hash.Name; return super.normalize(typeHash, hash); } + + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + hash.ID = hash.Name; + return hash; + } } diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index b4784bc1c..f6b97c4f6 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -51,3 +51,4 @@ @import './components/services'; @import './components/task-sub-row'; @import './components/authorization'; +@import './components/policies'; diff --git a/ui/app/styles/components/policies.scss b/ui/app/styles/components/policies.scss new file mode 100644 index 000000000..30919b766 --- /dev/null +++ b/ui/app/styles/components/policies.scss @@ -0,0 +1,27 @@ +table.policies { + tr { + cursor: pointer; + + &:hover td { + background-color: #f5f5f5; + } + a { + color: black; + text-decoration: none; + } + .number-expired { + color: $red; + } + } +} + +.edit-policy { + .policy-editor { + max-height: 600px; + overflow: auto; + } + + .input { + margin-bottom: 1rem; + } +} diff --git a/ui/app/styles/core/notifications.scss b/ui/app/styles/core/notifications.scss index d73a8258b..dffd0f106 100644 --- a/ui/app/styles/core/notifications.scss +++ b/ui/app/styles/core/notifications.scss @@ -4,6 +4,7 @@ section.notifications { position: fixed; bottom: 10px; right: 10px; + z-index: 100; .flash-message { width: 300px; diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss index f1a472c6b..3df4982b5 100644 --- a/ui/app/styles/core/table.scss +++ b/ui/app/styles/core/table.scss @@ -7,9 +7,11 @@ border-collapse: separate; width: 100%; - @media #{$mq-table-overflow} { - display: block; - overflow-x: auto; + &:not(.no-mobile-condense) { + @media #{$mq-table-overflow} { + display: block; + overflow-x: auto; + } } &.is-fixed { diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 097912f64..aa44dd5b4 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -114,7 +114,7 @@ {{#if this.system.agent.version}} diff --git a/ui/app/templates/policies.hbs b/ui/app/templates/policies.hbs new file mode 100644 index 000000000..60bf3d28b --- /dev/null +++ b/ui/app/templates/policies.hbs @@ -0,0 +1,4 @@ +