mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
[ui] Global token CRUD in the web UI (#23506)
* First pass at global token creation and regional awareness at token fetch time * Reset and refetch token when you switch region but stay in place * Ugly and functional global token save * Tests and log cleanup
This commit is contained in:
3
.changelog/23506.txt
Normal file
3
.changelog/23506.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
ui: Allow users to create Global ACL tokens from the Administration UI
|
||||
```
|
||||
@@ -59,11 +59,11 @@ export default class ApplicationAdapter extends RESTAdapter {
|
||||
|
||||
ajaxOptions(url, verb, options = {}) {
|
||||
options.data || (options.data = {});
|
||||
if (this.get('system.shouldIncludeRegion')) {
|
||||
if (options.regionOverride || this.get('system.shouldIncludeRegion')) {
|
||||
// Region should only ever be a query param. The default ajaxOptions
|
||||
// behavior is to include data attributes in the requestBody for PUT
|
||||
// and POST requests. This works around that.
|
||||
const region = this.get('system.activeRegion');
|
||||
const region = options.regionOverride || this.get('system.activeRegion');
|
||||
if (region) {
|
||||
url = associateRegion(url, region);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ export default class TokenAdapter extends ApplicationAdapter {
|
||||
|
||||
createRecord(_store, type, snapshot) {
|
||||
let data = this.serialize(snapshot);
|
||||
if (snapshot.adapterOptions?.region) {
|
||||
// ajaxOptions will try to append a particular region here.
|
||||
// we want instead fo overwrite it with the token's region.
|
||||
return this.ajax(`${this.buildURL()}/token`, 'POST', {
|
||||
data,
|
||||
regionOverride: snapshot.adapterOptions.region,
|
||||
});
|
||||
}
|
||||
return this.ajax(`${this.buildURL()}/token`, 'POST', { data });
|
||||
}
|
||||
|
||||
@@ -40,6 +48,11 @@ export default class TokenAdapter extends ApplicationAdapter {
|
||||
}
|
||||
|
||||
async findSelf() {
|
||||
// the application adapter automatically adds the region parameter to all requests,
|
||||
// but only if the /regions endpoint has been resolved first. Since this request is async,
|
||||
// we can ensure that the regions are loaded before making the token/self request.
|
||||
await this.system.regions;
|
||||
|
||||
const response = await this.ajax(`${this.buildURL()}/token/self`, 'GET');
|
||||
const normalized = this.store.normalize('token', response);
|
||||
const tokenRecord = this.store.push(normalized);
|
||||
|
||||
@@ -13,16 +13,20 @@ export default class RegionSwitcher extends Component {
|
||||
@service system;
|
||||
@service router;
|
||||
@service store;
|
||||
@service token;
|
||||
|
||||
@computed('system.regions')
|
||||
get sortedRegions() {
|
||||
return this.get('system.regions').toArray().sort();
|
||||
}
|
||||
|
||||
gotoRegion(region) {
|
||||
this.router.transitionTo('jobs', {
|
||||
queryParams: { region },
|
||||
});
|
||||
async gotoRegion(region) {
|
||||
// Note: redundant but as long as we're using PowerSelect, the implicit set('activeRegion')
|
||||
// is not something we can await, so we do it explicitly here.
|
||||
this.system.set('activeRegion', region);
|
||||
await this.get('token.fetchSelfTokenAndPolicies').perform().catch();
|
||||
|
||||
this.router.transitionTo({ queryParams: { region } });
|
||||
}
|
||||
|
||||
get keyCommands() {
|
||||
|
||||
@@ -95,6 +95,41 @@
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
{{#if @token.isNew}}
|
||||
{{#if this.system.shouldShowRegions}}
|
||||
<Hds::Form::Radio::Group data-test-global-token-group @layout="horizontal" @name="regional-or-global" {{on "change" this.updateTokenLocality}} as |G|>
|
||||
<G.Legend>Token Region</G.Legend>
|
||||
<G.HelperText>See <Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-tokens#token-replication-settings">ACL token fundamentals: Token replication settings</Hds::Link::Inline> for more information.</G.HelperText>
|
||||
<G.RadioField
|
||||
@id={{this.system.activeRegion}}
|
||||
checked={{eq this.tokenRegion this.system.activeRegion}}
|
||||
data-test-locality="active-region"
|
||||
as |F|>
|
||||
<F.Label data-test-active-region-label>{{this.system.activeRegion}}</F.Label>
|
||||
</G.RadioField>
|
||||
{{#if this.system.defaultRegion.region}}
|
||||
{{!-- template-lint-disable simple-unless --}}
|
||||
{{#unless (eq this.system.activeRegion this.system.defaultRegion.region)}}
|
||||
<G.RadioField
|
||||
@id={{this.system.defaultRegion.region}}
|
||||
checked={{eq this.tokenRegion this.system.defaultRegion.region}}
|
||||
data-test-locality="default-region"
|
||||
as |F|>
|
||||
<F.Label>{{this.system.defaultRegion.region}} (authoritative region)</F.Label>
|
||||
</G.RadioField>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<G.RadioField
|
||||
@id="global"
|
||||
checked={{eq this.tokenRegion "global"}}
|
||||
data-test-locality="global"
|
||||
as |F|>
|
||||
<F.Label>global</F.Label>
|
||||
</G.RadioField>
|
||||
</Hds::Form::Radio::Group>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<div>
|
||||
<Hds::Form::Radio::Group @layout="horizontal" @name="method-demo1" {{on "change" this.updateTokenType}} as |G|>
|
||||
<G.Legend>Client or Management token?</G.Legend>
|
||||
|
||||
@@ -15,6 +15,7 @@ export default class TokenEditorComponent extends Component {
|
||||
@service notifications;
|
||||
@service router;
|
||||
@service store;
|
||||
@service system;
|
||||
|
||||
@alias('args.roles') roles;
|
||||
@alias('args.token') activeToken;
|
||||
@@ -23,6 +24,14 @@ export default class TokenEditorComponent extends Component {
|
||||
@tracked tokenPolicies = [];
|
||||
@tracked tokenRoles = [];
|
||||
|
||||
/**
|
||||
* When creating a token, it can be made global (has access to all regions),
|
||||
* or non-global. If it's non-global, it can be scoped to a specific region.
|
||||
* By default, the token is created in the active region of the UI.
|
||||
* @type {string}
|
||||
*/
|
||||
@tracked tokenRegion = '';
|
||||
|
||||
// when this renders, set up tokenPolicies
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
@@ -31,6 +40,7 @@ export default class TokenEditorComponent extends Component {
|
||||
if (this.activeToken.isNew) {
|
||||
this.activeToken.expirationTTL = 'never';
|
||||
}
|
||||
this.tokenRegion = this.system.activeRegion;
|
||||
}
|
||||
|
||||
@action updateTokenPolicies(policy, event) {
|
||||
@@ -73,6 +83,10 @@ export default class TokenEditorComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action updateTokenLocality(event) {
|
||||
this.tokenRegion = event.target.id;
|
||||
}
|
||||
|
||||
@action async save() {
|
||||
try {
|
||||
const shouldRedirectAfterSave = this.activeToken.isNew;
|
||||
@@ -88,6 +102,12 @@ export default class TokenEditorComponent extends Component {
|
||||
this.activeToken.roles = [];
|
||||
}
|
||||
|
||||
if (this.tokenRegion === 'global') {
|
||||
this.activeToken.global = true;
|
||||
} else {
|
||||
this.activeToken.global = false;
|
||||
}
|
||||
|
||||
// Sets to "never" for auto-selecting the radio button;
|
||||
// if it gets updated by the user, will fall back to "" to represent
|
||||
// no expiration. However, if the user never updates it,
|
||||
@@ -96,7 +116,13 @@ export default class TokenEditorComponent extends Component {
|
||||
this.activeToken.expirationTTL = null;
|
||||
}
|
||||
|
||||
await this.activeToken.save();
|
||||
const adapterRegion = this.activeToken.global
|
||||
? this.system.get('defaultRegion.region')
|
||||
: this.tokenRegion;
|
||||
|
||||
await this.activeToken.save({
|
||||
adapterOptions: adapterRegion ? { region: adapterRegion } : {},
|
||||
});
|
||||
|
||||
this.notifications.add({
|
||||
title: 'Token Saved',
|
||||
|
||||
@@ -29,7 +29,6 @@ export default class IndexController extends Controller.extend(
|
||||
@controller('clients') clientsController;
|
||||
|
||||
@alias('model.nodes') nodes;
|
||||
@alias('model.agents') agents;
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
|
||||
@@ -233,7 +233,7 @@ export default class JobsIndexController extends Controller {
|
||||
console.log('error fetching job ids', e);
|
||||
this.notifyFetchError(e);
|
||||
}
|
||||
if (this.jobs.length) {
|
||||
if (this.jobs?.length) {
|
||||
return this.jobs;
|
||||
}
|
||||
return;
|
||||
@@ -256,7 +256,7 @@ export default class JobsIndexController extends Controller {
|
||||
console.log('error fetching job allocs', e);
|
||||
this.notifyFetchError(e);
|
||||
}
|
||||
if (this.jobs.length) {
|
||||
if (this.jobs?.length) {
|
||||
return this.jobs;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -22,7 +22,6 @@ export default class ClientsRoute extends Route.extend(WithForbiddenState) {
|
||||
model() {
|
||||
return RSVP.hash({
|
||||
nodes: this.store.findAll('node'),
|
||||
agents: this.store.findAll('agent'),
|
||||
nodePools: this.store.findAll('node-pool'),
|
||||
}).catch(notifyForbidden(this));
|
||||
}
|
||||
|
||||
@@ -735,9 +735,8 @@ export default function () {
|
||||
});
|
||||
|
||||
this.post('/acl/token', function (schema, request) {
|
||||
const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse(
|
||||
request.requestBody
|
||||
);
|
||||
const { Name, Policies, Type, ExpirationTTL, ExpirationTime, Global } =
|
||||
JSON.parse(request.requestBody);
|
||||
|
||||
function parseDuration(duration) {
|
||||
const [_, value, unit] = duration.match(/(\d+)(\w)/);
|
||||
@@ -763,6 +762,7 @@ export default function () {
|
||||
type: Type,
|
||||
id: faker.random.uuid(),
|
||||
expirationTime,
|
||||
global: Global,
|
||||
createTime: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,16 +158,6 @@ module('Acceptance | regions (many)', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('switching regions on deep pages redirects to the application root', async function (assert) {
|
||||
const newRegion = server.db.regions[1].id;
|
||||
|
||||
await Allocation.visit({ id: server.db.allocations[0].id });
|
||||
|
||||
await selectChoose('[data-test-region-switcher-parent]', newRegion);
|
||||
|
||||
assert.ok(currentURL().includes('/jobs?'), 'Back at the jobs page');
|
||||
});
|
||||
|
||||
test('navigating directly to a page with the region query param sets the application to that region', async function (assert) {
|
||||
const allocation = server.db.allocations[0];
|
||||
const region = server.db.regions[1].id;
|
||||
|
||||
@@ -1302,5 +1302,168 @@ module('Acceptance | tokens', function (hooks) {
|
||||
.dom(expiringTokenExpirationCell)
|
||||
.hasText('in 2 hours', 'Expiration time is relativized and rounded');
|
||||
});
|
||||
|
||||
test('When no regions are present, Tokens are by default regional', async function (assert) {
|
||||
await visit('/administration/tokens/new');
|
||||
assert.dom('[data-test-global-token-group]').doesNotExist();
|
||||
|
||||
await fillIn('[data-test-token-name-input]', 'Capt. Steven Hiller');
|
||||
await click('[data-test-token-save]');
|
||||
assert.dom('.flash-message.alert-success').exists();
|
||||
const token = server.db.tokens.findBy(
|
||||
(t) => t.name === 'Capt. Steven Hiller'
|
||||
);
|
||||
assert.false(token.global);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
module('Tokens and Regions', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
faker.seed(1);
|
||||
|
||||
server.create('region', { id: 'america' });
|
||||
server.create('region', { id: 'washington-dc' });
|
||||
server.create('region', { id: 'new-york' });
|
||||
server.create('region', { id: 'alien-ship' });
|
||||
|
||||
server.create('agent');
|
||||
server.create('node-pool');
|
||||
server.create('namespace');
|
||||
node = server.create('node');
|
||||
job = server.create('job');
|
||||
managementToken = server.create('token');
|
||||
|
||||
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||
});
|
||||
test('When regions are present, Tokens are by default regional, but can be made global', async function (assert) {
|
||||
await visit('/administration/tokens/new');
|
||||
assert.dom('[data-test-global-token-group]').exists();
|
||||
});
|
||||
|
||||
test('A global token can be created, and gets saved in the authoritative region', async function (assert) {
|
||||
await visit('/administration/tokens/new');
|
||||
assert
|
||||
.dom('[data-test-active-region-label]')
|
||||
.hasText('america', 'america is the default selected region');
|
||||
assert
|
||||
.dom('[data-test-locality]')
|
||||
.exists(
|
||||
{ count: 2 },
|
||||
'When in the authoritative/default region, only it and global are region options'
|
||||
);
|
||||
|
||||
// change region from dropdown
|
||||
await selectChoose('[data-test-region-switcher-parent]', 'washington-dc');
|
||||
|
||||
assert
|
||||
.dom('[data-test-active-region-label]')
|
||||
.hasText('washington-dc', 'washington-dc is the selected region');
|
||||
assert.dom('[data-test-locality="active-region"]').isChecked();
|
||||
|
||||
assert
|
||||
.dom('[data-test-locality]')
|
||||
.exists(
|
||||
{ count: 3 },
|
||||
'When in a region other than the authoritative one, the authoritative group becomes an third option in addition to current region and global'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-token-name-input]', 'Thomas J. Whitmore');
|
||||
await click('[data-test-locality="global"]');
|
||||
assert.dom('[data-test-locality="global"]').isChecked();
|
||||
|
||||
await click('[data-test-token-type="management"]');
|
||||
await click('[data-test-token-save]');
|
||||
|
||||
let globalToken = server.db.tokens.findBy(
|
||||
(t) => t.name === 'Thomas J. Whitmore'
|
||||
);
|
||||
assert.ok(globalToken.global, 'Token has Global set to true');
|
||||
assert.dom('.flash-message.alert-success').exists();
|
||||
let tokenRequest = server.pretender.handledRequests.find((req) => {
|
||||
return req.url.includes('acl/token') && req.method === 'POST';
|
||||
});
|
||||
assert.equal(
|
||||
tokenRequest.queryParams.region,
|
||||
'america',
|
||||
'Global token is saved in the authoritative region, regardless of active UI region'
|
||||
);
|
||||
await percySnapshot(assert);
|
||||
});
|
||||
|
||||
test('A token can be created in a non-authoritative region', async function (assert) {
|
||||
await visit('/administration/tokens/new');
|
||||
assert
|
||||
.dom('[data-test-active-region-label]')
|
||||
.hasText('america', 'america is the default selected region');
|
||||
assert
|
||||
.dom('[data-test-locality]')
|
||||
.exists(
|
||||
{ count: 2 },
|
||||
'When in the authoritative/default region, only it and global are region options'
|
||||
);
|
||||
|
||||
// change region from dropdown
|
||||
await selectChoose('[data-test-region-switcher-parent]', 'alien-ship');
|
||||
|
||||
assert
|
||||
.dom('[data-test-active-region-label]')
|
||||
.hasText('alien-ship', 'alien-ship is the selected region');
|
||||
assert.dom('[data-test-locality="active-region"]').isChecked();
|
||||
|
||||
await fillIn('[data-test-token-name-input]', 'David Levinson');
|
||||
await click('[data-test-token-type="management"]');
|
||||
await click('[data-test-token-save]');
|
||||
assert.dom('.flash-message.alert-success').exists();
|
||||
let token = server.db.tokens.findBy((t) => t.name === 'David Levinson');
|
||||
|
||||
assert.notOk(token.global, 'Token is not global');
|
||||
const tokenRequest = server.pretender.handledRequests.find((req) => {
|
||||
return req.url.includes('acl/token') && req.method === 'POST';
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
tokenRequest.queryParams.region,
|
||||
'alien-ship',
|
||||
'Token is saved in the selected region'
|
||||
);
|
||||
});
|
||||
|
||||
test('A non-global token can be created in the authoritative region', async function (assert) {
|
||||
await visit('/administration/tokens/new');
|
||||
|
||||
// change region from dropdown
|
||||
await selectChoose('[data-test-region-switcher-parent]', 'new-york');
|
||||
|
||||
assert
|
||||
.dom('[data-test-active-region-label]')
|
||||
.hasText('new-york', 'new-york is the selected region');
|
||||
assert.dom('[data-test-locality="active-region"]').isChecked();
|
||||
|
||||
await click('[data-test-locality="default-region"]');
|
||||
assert.dom('[data-test-locality="default-region"]').isChecked();
|
||||
|
||||
await fillIn('[data-test-token-name-input]', 'Russell Casse');
|
||||
await click('[data-test-token-type="management"]');
|
||||
// await this.pauseTest();
|
||||
|
||||
await click('[data-test-token-save]');
|
||||
assert.dom('.flash-message.alert-success').exists();
|
||||
let token = server.db.tokens.findBy((t) => t.name === 'Russell Casse');
|
||||
assert.notOk(token.global, 'Token is not global');
|
||||
const tokenRequest = server.pretender.handledRequests.find((req) => {
|
||||
return req.url.includes('acl/token') && req.method === 'POST';
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
tokenRequest.queryParams.region,
|
||||
'america',
|
||||
'Token is saved in the authoritative region'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user