diff --git a/.changelog/15435.txt b/.changelog/15435.txt new file mode 100644 index 000000000..383647319 --- /dev/null +++ b/.changelog/15435.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: The web UI now provides a Token Management interface for management users on policy pages +``` diff --git a/ui/app/abilities/token.js b/ui/app/abilities/token.js new file mode 100644 index 000000000..0302f107c --- /dev/null +++ b/ui/app/abilities/token.js @@ -0,0 +1,10 @@ +import AbstractAbility from './abstract'; +import { alias } from '@ember/object/computed'; + +export default class extends AbstractAbility { + @alias('selfTokenIsManagement') canRead; + @alias('selfTokenIsManagement') canList; + @alias('selfTokenIsManagement') canWrite; + @alias('selfTokenIsManagement') canUpdate; + @alias('selfTokenIsManagement') canDestroy; +} diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index 81018aeb9..00edc5ca8 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -2,6 +2,7 @@ import { inject as service } from '@ember/service'; import { default as ApplicationAdapter, namespace } from './application'; import OTTExchangeError from '../utils/ott-exchange-error'; import classic from 'ember-classic-decorator'; +import { singularize } from 'ember-inflector'; @classic export default class TokenAdapter extends ApplicationAdapter { @@ -9,6 +10,17 @@ export default class TokenAdapter extends ApplicationAdapter { namespace = namespace + '/acl'; + createRecord(_store, type, snapshot) { + let data = this.serialize(snapshot); + data.Policies = data.PolicyIDs; + return this.ajax(`${this.buildURL()}/token`, 'POST', { data }); + } + + // Delete at /token instead of /tokens + urlForDeleteRecord(identifier, modelName) { + return `${this.buildURL()}/${singularize(modelName)}/${identifier}`; + } + findSelf() { return this.ajax(`${this.buildURL()}/token/self`, 'GET').then((token) => { const store = this.store; diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js index 0005909f5..d37ef1287 100644 --- a/ui/app/components/policy-editor.js +++ b/ui/app/components/policy-editor.js @@ -2,7 +2,6 @@ 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; @@ -23,10 +22,12 @@ export default class PolicyEditorComponent extends Component { 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.' + `Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.` ); } + const shouldRedirectAfterSave = this.policy.isNew; + if ( this.policy.isNew && this.store.peekRecord('policy', this.policy.name) @@ -47,12 +48,13 @@ export default class PolicyEditorComponent extends Component { timeout: 5000, }); - this.router.transitionTo('policies'); + if (shouldRedirectAfterSave) { + this.router.transitionTo('policies.policy', this.policy.id); + } } catch (error) { - console.log('error and its', error); this.flashMessages.add({ title: `Error creating Policy ${this.policy.name}`, - message: messageForError(error), + message: error, type: 'error', destroyOnClick: false, sticky: true, diff --git a/ui/app/components/tooltip.js b/ui/app/components/tooltip.js index a9fb3120c..feb2ece02 100644 --- a/ui/app/components/tooltip.js +++ b/ui/app/components/tooltip.js @@ -2,7 +2,7 @@ import Component from '@glimmer/component'; export default class Tooltip extends Component { get text() { - const inputText = this.args.text; + const inputText = this.args.text?.toString(); if (!inputText || inputText.length < 30) { return inputText; } diff --git a/ui/app/controllers/policies/index.js b/ui/app/controllers/policies/index.js index faaa78dd7..c0c2b2755 100644 --- a/ui/app/controllers/policies/index.js +++ b/ui/app/controllers/policies/index.js @@ -6,7 +6,7 @@ export default class PoliciesIndexController extends Controller { @service router; get policies() { return this.model.policies.map((policy) => { - policy.tokens = this.model.tokens.filter((token) => { + policy.tokens = (this.model.tokens || []).filter((token) => { return token.policies.includes(policy); }); return policy; diff --git a/ui/app/controllers/policies/policy.js b/ui/app/controllers/policies/policy.js index f54663dd4..b504e5c1e 100644 --- a/ui/app/controllers/policies/policy.js +++ b/ui/app/controllers/policies/policy.js @@ -3,14 +3,26 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import { alias } from '@ember/object/computed'; import { task } from 'ember-concurrency'; export default class PoliciesPolicyController extends Controller { @service flashMessages; @service router; + @service store; + + @alias('model.policy') policy; + @alias('model.tokens') tokens; + + @tracked + error = null; @tracked isDeleting = false; + get newTokenString() { + return `nomad acl token create -name="" -policy="${this.policy.name}" -type=client -ttl=<8h>`; + } + @action onDeletePrompt() { this.isDeleting = true; @@ -23,8 +35,8 @@ export default class PoliciesPolicyController extends Controller { @task(function* () { try { - yield this.model.deleteRecord(); - yield this.model.save(); + yield this.policy.deleteRecord(); + yield this.policy.save(); this.flashMessages.add({ title: 'Policy Deleted', type: 'success', @@ -34,7 +46,7 @@ export default class PoliciesPolicyController extends Controller { this.router.transitionTo('policies'); } catch (err) { this.flashMessages.add({ - title: `Error deleting Policy ${this.model.name}`, + title: `Error deleting Policy ${this.policy.name}`, message: err, type: 'error', destroyOnClick: false, @@ -43,4 +55,69 @@ export default class PoliciesPolicyController extends Controller { } }) deletePolicy; + + async refreshTokens() { + this.tokens = this.store + .peekAll('token') + .filter((token) => + token.policyNames?.includes(decodeURIComponent(this.policy.name)) + ); + } + + @task(function* () { + try { + const newToken = this.store.createRecord('token', { + name: `Example Token for ${this.policy.name}`, + policies: [this.policy], + // New date 10 minutes into the future + expirationTime: new Date(Date.now() + 10 * 60 * 1000), + type: 'client', + }); + yield newToken.save(); + yield this.refreshTokens(); + this.flashMessages.add({ + title: 'Example Token Created', + message: `${newToken.secret}`, + type: 'success', + destroyOnClick: false, + timeout: 30000, + customAction: { + label: 'Copy to Clipboard', + action: () => { + navigator.clipboard.writeText(newToken.secret); + }, + }, + }); + } catch (err) { + this.error = { + title: 'Error creating new token', + description: err, + }; + + throw err; + } + }) + createTestToken; + + @task(function* (token) { + try { + yield token.deleteRecord(); + yield token.save(); + yield this.refreshTokens(); + this.flashMessages.add({ + title: 'Token successfully deleted', + type: 'success', + destroyOnClick: false, + timeout: 5000, + }); + } catch (err) { + this.error = { + title: 'Error deleting token', + description: err, + }; + + throw err; + } + }) + deleteToken; } diff --git a/ui/app/routes/policies.js b/ui/app/routes/policies.js index bcb385e05..8b0c894aa 100644 --- a/ui/app/routes/policies.js +++ b/ui/app/routes/policies.js @@ -21,7 +21,9 @@ export default class PoliciesRoute extends Route.extend( async model() { return await hash({ policies: this.store.query('policy', { reload: true }), - tokens: this.store.query('token', { reload: true }), + tokens: + this.can.can('list tokens') && + this.store.query('token', { reload: true }), }); } } diff --git a/ui/app/routes/policies/policy.js b/ui/app/routes/policies/policy.js index acd3a2156..215c8386d 100644 --- a/ui/app/routes/policies/policy.js +++ b/ui/app/routes/policies/policy.js @@ -2,15 +2,23 @@ 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 PoliciesPolicyRoute extends Route.extend( withForbiddenState, WithModelErrorHandling ) { @service store; - model(params) { - return this.store.findRecord('policy', decodeURIComponent(params.name), { - reload: true, + async model(params) { + return hash({ + policy: this.store.findRecord('policy', decodeURIComponent(params.name), { + reload: true, + }), + tokens: this.store + .peekAll('token') + .filter((token) => + token.policyNames?.includes(decodeURIComponent(params.name)) + ), }); } } diff --git a/ui/app/styles/components/policies.scss b/ui/app/styles/components/policies.scss index 30919b766..83f262eaf 100644 --- a/ui/app/styles/components/policies.scss +++ b/ui/app/styles/components/policies.scss @@ -25,3 +25,48 @@ table.policies { margin-bottom: 1rem; } } + +.token-operations { + margin-bottom: 3rem; + display: grid; + grid-auto-flow: column; + grid-template-columns: repeat(auto-fit, 50%); + grid-auto-rows: minmax(100px, auto); + gap: 1rem; + + .boxed-section { + padding: 0; + margin: 0; + display: grid; + grid-template-rows: auto 1fr; + + .external-link svg { + position: relative; + top: 3px; + } + + button.create-test-token, pre { + margin-top: 1rem; + } + + pre { + display: grid; + grid-template-columns: 1fr auto; + align-content: flex-start; + white-space: normal; + overflow: visible; + } + } +} + +@media #{$mq-hidden-gutter} { + .token-operations { + grid-auto-flow: row; + grid-template-columns: 1fr; + } +} + + +table.tokens { + margin-bottom: 3rem; +} diff --git a/ui/app/templates/policies/index.hbs b/ui/app/templates/policies/index.hbs index 116dde74f..8ad5903df 100644 --- a/ui/app/templates/policies/index.hbs +++ b/ui/app/templates/policies/index.hbs @@ -32,7 +32,9 @@ @class="policies no-mobile-condense" as |t|> Policy Name - Tokens + {{#if (can "list token")}} + Tokens + {{/if}} {{row.model.name}} - - - {{row.model.tokens.length}} - {{#if (filter-by "isExpired" row.model.tokens)}} - ({{get (filter-by "isExpired" row.model.tokens) "length"}} expired) - {{/if}} - - + {{#if (can "list token")}} + + + {{row.model.tokens.length}} + {{#if (filter-by "isExpired" row.model.tokens)}} + ({{get (filter-by "isExpired" row.model.tokens) "length"}} expired) + {{/if}} + + + {{/if}} diff --git a/ui/app/templates/policies/policy.hbs b/ui/app/templates/policies/policy.hbs index ea81ca92a..a7da79646 100644 --- a/ui/app/templates/policies/policy.hbs +++ b/ui/app/templates/policies/policy.hbs @@ -1,16 +1,15 @@ - - + {{page-title "Policy"}}

- {{this.model.name}} + {{this.policy.name}}
{{#if (can "destroy policy")}}
+ + {{#if (can "list token")}} +
+ +

+ Tokens +

+ + {{#if (can "write token")}} +
+
+
+

Create a Test Token

+
+
+

Create a test token that expires in 10 minutes for testing purposes.

+ +
+
+
+
+

Create Tokens from the Nomad CLI

+
+
+

When you're ready to create more tokens, you can do so via the Nomad CLI with the following: +

+                {{this.newTokenString}}
+                
+                
+              
+

+
+
+
+ {{/if}} + + {{#if this.tokens.length}} + + + Name + Created + Expires + {{#if (can "destroy token")}} + Delete + {{/if}} + + + + + + {{row.model.name}} + + + + {{moment-from-now row.model.createTime interval=1000}} + + + {{#if row.model.expirationTime}} + + {{moment-from-now row.model.expirationTime interval=1000}} + + {{else}} + Never + {{/if}} + + {{#if (can "destroy token")}} + + + + {{/if}} + + + + {{else}} +
+

+ No Tokens +

+

+ No tokens are using this policy. +

+
+ {{/if}} + {{/if}} +

+ {{outlet}} \ No newline at end of file diff --git a/ui/mirage/config.js b/ui/mirage/config.js index a1d3def58..e81967249 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -447,6 +447,23 @@ export default function () { return this.serialize(tokens.all()); }); + this.delete('/acl/token/:id', function (schema, request) { + const { id } = request.params; + server.db.tokens.remove(id); + return ''; + }); + + this.post('/acl/token', function (schema, request) { + const { Name, Policies, Type } = JSON.parse(request.requestBody); + return server.create('token', { + name: Name, + policyIds: Policies, + type: Type, + id: faker.random.uuid(), + createTime: new Date().toISOString(), + }); + }); + this.get('/acl/token/self', function ({ tokens }, req) { const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); diff --git a/ui/mirage/factories/token.js b/ui/mirage/factories/token.js index 12961a3bb..1f3a8cb14 100644 --- a/ui/mirage/factories/token.js +++ b/ui/mirage/factories/token.js @@ -14,6 +14,7 @@ export default Factory.extend({ oneTimeSecret: () => faker.random.uuid(), afterCreate(token, server) { + if (token.policyIds && token.policyIds.length) return; const policyIds = Array(faker.random.number({ min: 1, max: 5 })) .fill(0) .map(() => faker.hacker.verb()) diff --git a/ui/tests/acceptance/policies-test.js b/ui/tests/acceptance/policies-test.js index ed09d5645..db03c33e1 100644 --- a/ui/tests/acceptance/policies-test.js +++ b/ui/tests/acceptance/policies-test.js @@ -46,7 +46,11 @@ module('Acceptance | policies', function (hooks) { assert.dom('[data-test-title]').includesText(server.db.policies[0].name); await click('button[type="submit"]'); assert.dom('.flash-message.alert-success').exists(); - assert.equal(currentURL(), '/policies'); + assert.equal( + currentURL(), + `/policies/${server.db.policies[0].name}`, + 'remain on page after save' + ); // Reset Token window.localStorage.nomadTokenSecret = null; }); @@ -68,7 +72,12 @@ module('Acceptance | policies', function (hooks) { await typeIn('[data-test-policy-name-input]', 'My-Fun-Policy'); await click('button[type="submit"]'); assert.dom('.flash-message.alert-success').exists(); - assert.equal(currentURL(), '/policies'); + assert.equal( + currentURL(), + '/policies/My-Fun-Policy', + 'redirected to the now-created policy' + ); + await visit('/policies'); const newPolicy = [...findAll('[data-test-policy-name]')].filter((a) => a.textContent.includes('My-Fun-Policy') )[0]; diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index f017560af..c7c0cfbc2 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -13,6 +13,7 @@ import percySnapshot from '@percy/ember'; import faker from 'nomad-ui/mirage/faker'; import moment from 'moment'; import { run } from '@ember/runloop'; +import { allScenarios } from '../../mirage/scenarios/default'; let job; let node; @@ -424,6 +425,123 @@ module('Acceptance | tokens', function (hooks) { ); }); + test('Tokens are shown on the policies index page', async function (assert) { + allScenarios.policiesTestCluster(server); + // Create an expired token + server.create('token', { + name: 'Expired Token', + id: 'just-expired', + policyIds: [server.db.policies[0].name], + expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago + }); + + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + assert.dom('[data-test-policy-token-count]').exists(); + const expectedFirstPolicyTokens = server.db.tokens.filter((token) => { + return token.policyIds.includes(server.db.policies[0].name); + }); + assert + .dom('[data-test-policy-total-tokens]') + .hasText(expectedFirstPolicyTokens.length.toString()); + assert.dom('[data-test-policy-expired-tokens]').hasText('(1 expired)'); + window.localStorage.nomadTokenSecret = null; + }); + + test('Tokens are shown on a policy page', async function (assert) { + allScenarios.policiesTestCluster(server); + // Create an expired token + server.create('token', { + name: 'Expired Token', + id: 'just-expired', + policyIds: [server.db.policies[0].name], + expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago + }); + + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + + await click('[data-test-policy-row]:first-child'); + assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`); + + const expectedFirstPolicyTokens = server.db.tokens.filter((token) => { + return token.policyIds.includes(server.db.policies[0].name); + }); + + assert + .dom('[data-test-policy-token-row]') + .exists( + { count: expectedFirstPolicyTokens.length }, + 'Expected number of tokens are shown' + ); + assert.dom('[data-test-token-expiration-time]').hasText('10 minutes ago'); + + window.localStorage.nomadTokenSecret = null; + }); + + test('Tokens Deletion', async function (assert) { + allScenarios.policiesTestCluster(server); + // Create an expired token + server.create('token', { + name: 'Doomed Token', + id: 'enjoying-my-day-here', + policyIds: [server.db.policies[0].name], + }); + + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + + await click('[data-test-policy-row]:first-child'); + assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`); + + assert + .dom('[data-test-policy-token-row]') + .exists({ count: 3 }, 'Expected number of tokens are shown'); + + const doomedTokenRow = [...findAll('[data-test-policy-token-row]')].find( + (a) => a.textContent.includes('Doomed Token') + ); + + assert.dom(doomedTokenRow).exists(); + + await click(doomedTokenRow.querySelector('button')); + assert + .dom(doomedTokenRow.querySelector('[data-test-confirm-button]')) + .exists(); + await click(doomedTokenRow.querySelector('[data-test-confirm-button]')); + assert.dom('.flash-message.alert-success').exists(); + assert + .dom('[data-test-policy-token-row]') + .exists({ count: 2 }, 'One fewer token after deletion'); + await percySnapshot(assert); + window.localStorage.nomadTokenSecret = null; + }); + + test('Test Token Creation', async function (assert) { + allScenarios.policiesTestCluster(server); + + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/policies'); + + await click('[data-test-policy-row]:first-child'); + assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`); + + assert + .dom('[data-test-policy-token-row]') + .exists({ count: 2 }, 'Expected number of tokens are shown'); + + await click('[data-test-create-test-token]'); + assert.dom('.flash-message.alert-success').exists(); + assert + .dom('[data-test-policy-token-row]') + .exists({ count: 3 }, 'One more token after test token creation'); + assert + .dom('[data-test-policy-token-row]:last-child [data-test-token-name]') + .hasText(`Example Token for ${server.db.policies[0].name}`); + await percySnapshot(assert); + window.localStorage.nomadTokenSecret = null; + }); + function getHeader({ requestHeaders }, name) { // Headers are case-insensitive, but object property look up is not return ( diff --git a/ui/tests/unit/abilities/token-test.js b/ui/tests/unit/abilities/token-test.js new file mode 100644 index 000000000..a6ae191b5 --- /dev/null +++ b/ui/tests/unit/abilities/token-test.js @@ -0,0 +1,48 @@ +/* eslint-disable ember/avoid-leaking-state-in-ember-objects */ +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 | token', function (hooks) { + setupTest(hooks); + setupAbility('token')(hooks); + + test('A non-management user can do nothing with tokens', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + }); + this.owner.register('service:token', mockToken); + assert.notOk(this.ability.canRead); + assert.notOk(this.ability.canList); + assert.notOk(this.ability.canWrite); + assert.notOk(this.ability.canUpdate); + assert.notOk(this.ability.canDestroy); + }); + + test('A management user can do everything with tokens', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'management' }, + }); + this.owner.register('service:token', mockToken); + assert.ok(this.ability.canRead); + assert.ok(this.ability.canList); + assert.ok(this.ability.canWrite); + assert.ok(this.ability.canUpdate); + assert.ok(this.ability.canDestroy); + }); + + test('A non-ACL agent (bypassAuthorization) does not allow anything', function (assert) { + const mockToken = Service.extend({ + aclEnabled: false, + }); + this.owner.register('service:token', mockToken); + assert.notOk(this.ability.canRead); + assert.notOk(this.ability.canList); + assert.notOk(this.ability.canWrite); + assert.notOk(this.ability.canUpdate); + assert.notOk(this.ability.canDestroy); + }); +});