[ui] Token management interface on policy pages (#15435)

* basic-functionality demo for token CRUD

* Styling for tokens crud

* Tokens crud styles

* Expires, not expiry

* Mobile styles etc

* Refresh and redirect rules for policy save and token creation

* Delete method and associated serializer change

* Ability-checking for tokens

* Update policies acceptance tests to reflect new redirect rules

* Token ability unit tests

* Mirage config methods for token crud

* Token CRUD acceptance tests

* A couple visual diff snapshots

* Add and Delete abilities referenced for token operations

* Changing timeouts and adding a copy to clipboard action

* replaced accessor with secret when copying to clipboard

* PR comments addressed

* Simplified error passing for policy editor
This commit is contained in:
Phil Renaud 2022-12-15 13:11:28 -05:00 committed by GitHub
parent b0730ebb02
commit dce8717866
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 497 additions and 30 deletions

3
.changelog/15435.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: The web UI now provides a Token Management interface for management users on policy pages
```

10
ui/app/abilities/token.js Normal file
View File

@ -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;
}

View File

@ -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;

View File

@ -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,

View File

@ -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;
}

View File

@ -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;

View File

@ -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="<TOKEN_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;
}

View File

@ -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 }),
});
}
}

View File

@ -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))
),
});
}
}

View File

@ -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;
}

View File

@ -32,7 +32,9 @@
@class="policies no-mobile-condense" as |t|>
<t.head>
<th>Policy Name</th>
<th>Tokens</th>
{{#if (can "list token")}}
<th>Tokens</th>
{{/if}}
</t.head>
<t.body as |row|>
<tr data-test-policy-row {{on "click" (action "openPolicy" row.model)}}
@ -43,14 +45,16 @@
<td data-test-policy-name>
<LinkTo @route="policies.policy" @model={{row.model.name}}>{{row.model.name}}</LinkTo>
</td>
<td data-test-policy-token-count>
<span>
{{row.model.tokens.length}}
{{#if (filter-by "isExpired" row.model.tokens)}}
<span class="number-expired">({{get (filter-by "isExpired" row.model.tokens) "length"}} expired)</span>
{{/if}}
</span>
</td>
{{#if (can "list token")}}
<td data-test-policy-token-count>
<span>
<span data-test-policy-total-tokens>{{row.model.tokens.length}}</span>
{{#if (filter-by "isExpired" row.model.tokens)}}
<span data-test-policy-expired-tokens class="number-expired">({{get (filter-by "isExpired" row.model.tokens) "length"}} expired)</span>
{{/if}}
</span>
</td>
{{/if}}
</tr>
</t.body>
</ListTable>

View File

@ -1,16 +1,15 @@
<Breadcrumb @crumb={{hash label=this.model.name args=(array "policies.policy" this.model.name)}} />
<Breadcrumb @crumb={{hash label=this.policy.name args=(array "policies.policy" this.policy.name)}} />
{{page-title "Policy"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
<div>
{{this.model.name}}
{{this.policy.name}}
</div>
{{#if (can "destroy policy")}}
<div>
<TwoStepButton
data-test-delete-button
@idleText="Delete"
@idleText="Delete policy"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure?"
@ -23,7 +22,119 @@
{{/if}}
</h1>
<PolicyEditor
@policy={{this.model}}
@policy={{this.policy}}
/>
{{#if (can "list token")}}
<hr />
<h2 class="title">
Tokens
</h2>
{{#if (can "write token")}}
<div class="token-operations">
<div class="boxed-section">
<div class="boxed-section-head">
<h3>Create a Test Token</h3>
</div>
<div class="boxed-section-body">
<p class="is-info">Create a test token that expires in 10 minutes for testing purposes.</p>
<label>
<button
type="button"
class="button is-info is-outlined create-test-token"
data-test-create-test-token
{{on "click" (perform this.createTestToken)}}
>Create Test Token</button>
</label>
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
<h3>Create Tokens from the Nomad CLI</h3>
</div>
<div class="boxed-section-body">
<p>When you're ready to create more tokens, you can do so via the <a class="external-link" href="https://developer.hashicorp.com/nomad/docs/commands" target="_blank" rel="noopener noreferrer">Nomad CLI <FlightIcon @name="external-link" /></a> with the following:
<pre>
<code>{{this.newTokenString}}</code>
<CopyButton
data-test-copy-button
@clipboardText={{this.newTokenString}}
@compact={{true}}
>
</CopyButton>
</pre>
</p>
</div>
</div>
</div>
{{/if}}
{{#if this.tokens.length}}
<ListTable
@source={{this.tokens}}
@class="tokens no-mobile-condense" as |t|>
<t.head>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
{{#if (can "destroy token")}}
<th>Delete</th>
{{/if}}
</t.head>
<t.body as |row|>
<tr data-test-policy-token-row>
<td data-test-token-name>
<Tooltip @text={{row.model.id}}>
{{row.model.name}}
</Tooltip>
</td>
<td>
{{moment-from-now row.model.createTime interval=1000}}
</td>
<td>
{{#if row.model.expirationTime}}
<Tooltip @text={{row.model.expirationTime}}>
<span data-test-token-expiration-time class="{{if row.model.isExpired "has-text-danger"}}">{{moment-from-now row.model.expirationTime interval=1000}}</span>
</Tooltip>
{{else}}
<span class="has-text-grey">Never</span>
{{/if}}
</td>
{{#if (can "destroy token")}}
<td class="is-200px">
<TwoStepButton
data-test-delete-token-button
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{row.model.isPendingDeletion}}
@onConfirm={{perform this.deleteToken row.model}}
@inlineText={{true}}
@classes={{hash
idleButton="is-danger is-outlined"
confirmButton="is-danger"
}}
/>
</td>
{{/if}}
</tr>
</t.body>
</ListTable>
{{else}}
<div class="empty-message">
<h3 data-test-empty-policies-list-headline class="empty-message-headline">
No Tokens
</h3>
<p class="empty-message-body">
No tokens are using this policy.
</p>
</div>
{{/if}}
{{/if}}
</section>
{{outlet}}

View File

@ -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 });

View File

@ -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())

View File

@ -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];

View File

@ -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 (

View File

@ -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);
});
});