[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:
Phil Renaud
2024-07-11 14:54:39 -04:00
committed by GitHub
parent c82dd76a1b
commit 0324e781d4
12 changed files with 256 additions and 24 deletions

3
.changelog/23506.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Allow users to create Global ACL tokens from the Administration UI
```

View File

@@ -59,11 +59,11 @@ export default class ApplicationAdapter extends RESTAdapter {
ajaxOptions(url, verb, options = {}) { ajaxOptions(url, verb, options = {}) {
options.data || (options.data = {}); 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 // Region should only ever be a query param. The default ajaxOptions
// behavior is to include data attributes in the requestBody for PUT // behavior is to include data attributes in the requestBody for PUT
// and POST requests. This works around that. // and POST requests. This works around that.
const region = this.get('system.activeRegion'); const region = options.regionOverride || this.get('system.activeRegion');
if (region) { if (region) {
url = associateRegion(url, region); url = associateRegion(url, region);
} }

View File

@@ -31,6 +31,14 @@ export default class TokenAdapter extends ApplicationAdapter {
createRecord(_store, type, snapshot) { createRecord(_store, type, snapshot) {
let data = this.serialize(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 }); return this.ajax(`${this.buildURL()}/token`, 'POST', { data });
} }
@@ -40,6 +48,11 @@ export default class TokenAdapter extends ApplicationAdapter {
} }
async findSelf() { 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 response = await this.ajax(`${this.buildURL()}/token/self`, 'GET');
const normalized = this.store.normalize('token', response); const normalized = this.store.normalize('token', response);
const tokenRecord = this.store.push(normalized); const tokenRecord = this.store.push(normalized);

View File

@@ -13,16 +13,20 @@ export default class RegionSwitcher extends Component {
@service system; @service system;
@service router; @service router;
@service store; @service store;
@service token;
@computed('system.regions') @computed('system.regions')
get sortedRegions() { get sortedRegions() {
return this.get('system.regions').toArray().sort(); return this.get('system.regions').toArray().sort();
} }
gotoRegion(region) { async gotoRegion(region) {
this.router.transitionTo('jobs', { // Note: redundant but as long as we're using PowerSelect, the implicit set('activeRegion')
queryParams: { region }, // 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() { get keyCommands() {

View File

@@ -95,6 +95,41 @@
</div> </div>
{{/unless}} {{/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> <div>
<Hds::Form::Radio::Group @layout="horizontal" @name="method-demo1" {{on "change" this.updateTokenType}} as |G|> <Hds::Form::Radio::Group @layout="horizontal" @name="method-demo1" {{on "change" this.updateTokenType}} as |G|>
<G.Legend>Client or Management token?</G.Legend> <G.Legend>Client or Management token?</G.Legend>

View File

@@ -15,6 +15,7 @@ export default class TokenEditorComponent extends Component {
@service notifications; @service notifications;
@service router; @service router;
@service store; @service store;
@service system;
@alias('args.roles') roles; @alias('args.roles') roles;
@alias('args.token') activeToken; @alias('args.token') activeToken;
@@ -23,6 +24,14 @@ export default class TokenEditorComponent extends Component {
@tracked tokenPolicies = []; @tracked tokenPolicies = [];
@tracked tokenRoles = []; @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 // when this renders, set up tokenPolicies
constructor() { constructor() {
super(...arguments); super(...arguments);
@@ -31,6 +40,7 @@ export default class TokenEditorComponent extends Component {
if (this.activeToken.isNew) { if (this.activeToken.isNew) {
this.activeToken.expirationTTL = 'never'; this.activeToken.expirationTTL = 'never';
} }
this.tokenRegion = this.system.activeRegion;
} }
@action updateTokenPolicies(policy, event) { @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() { @action async save() {
try { try {
const shouldRedirectAfterSave = this.activeToken.isNew; const shouldRedirectAfterSave = this.activeToken.isNew;
@@ -88,6 +102,12 @@ export default class TokenEditorComponent extends Component {
this.activeToken.roles = []; 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; // Sets to "never" for auto-selecting the radio button;
// if it gets updated by the user, will fall back to "" to represent // if it gets updated by the user, will fall back to "" to represent
// no expiration. However, if the user never updates it, // no expiration. However, if the user never updates it,
@@ -96,7 +116,13 @@ export default class TokenEditorComponent extends Component {
this.activeToken.expirationTTL = null; 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({ this.notifications.add({
title: 'Token Saved', title: 'Token Saved',

View File

@@ -29,7 +29,6 @@ export default class IndexController extends Controller.extend(
@controller('clients') clientsController; @controller('clients') clientsController;
@alias('model.nodes') nodes; @alias('model.nodes') nodes;
@alias('model.agents') agents;
queryParams = [ queryParams = [
{ {

View File

@@ -233,7 +233,7 @@ export default class JobsIndexController extends Controller {
console.log('error fetching job ids', e); console.log('error fetching job ids', e);
this.notifyFetchError(e); this.notifyFetchError(e);
} }
if (this.jobs.length) { if (this.jobs?.length) {
return this.jobs; return this.jobs;
} }
return; return;
@@ -256,7 +256,7 @@ export default class JobsIndexController extends Controller {
console.log('error fetching job allocs', e); console.log('error fetching job allocs', e);
this.notifyFetchError(e); this.notifyFetchError(e);
} }
if (this.jobs.length) { if (this.jobs?.length) {
return this.jobs; return this.jobs;
} }
return; return;

View File

@@ -22,7 +22,6 @@ export default class ClientsRoute extends Route.extend(WithForbiddenState) {
model() { model() {
return RSVP.hash({ return RSVP.hash({
nodes: this.store.findAll('node'), nodes: this.store.findAll('node'),
agents: this.store.findAll('agent'),
nodePools: this.store.findAll('node-pool'), nodePools: this.store.findAll('node-pool'),
}).catch(notifyForbidden(this)); }).catch(notifyForbidden(this));
} }

View File

@@ -735,9 +735,8 @@ export default function () {
}); });
this.post('/acl/token', function (schema, request) { this.post('/acl/token', function (schema, request) {
const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse( const { Name, Policies, Type, ExpirationTTL, ExpirationTime, Global } =
request.requestBody JSON.parse(request.requestBody);
);
function parseDuration(duration) { function parseDuration(duration) {
const [_, value, unit] = duration.match(/(\d+)(\w)/); const [_, value, unit] = duration.match(/(\d+)(\w)/);
@@ -763,6 +762,7 @@ export default function () {
type: Type, type: Type,
id: faker.random.uuid(), id: faker.random.uuid(),
expirationTime, expirationTime,
global: Global,
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
}); });
}); });

View File

@@ -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) { 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 allocation = server.db.allocations[0];
const region = server.db.regions[1].id; const region = server.db.regions[1].id;

View File

@@ -1302,5 +1302,168 @@ module('Acceptance | tokens', function (hooks) {
.dom(expiringTokenExpirationCell) .dom(expiringTokenExpirationCell)
.hasText('in 2 hours', 'Expiration time is relativized and rounded'); .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'
);
}); });
}); });