From 0324e781d45e462709ecaeac039e636cdb1720f0 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Thu, 11 Jul 2024 14:54:39 -0400 Subject: [PATCH] [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 --- .changelog/23506.txt | 3 + ui/app/adapters/application.js | 4 +- ui/app/adapters/token.js | 13 +++ ui/app/components/region-switcher.js | 12 +- ui/app/components/token-editor.hbs | 35 ++++++ ui/app/components/token-editor.js | 28 ++++- ui/app/controllers/clients/index.js | 1 - ui/app/controllers/jobs/index.js | 4 +- ui/app/routes/clients.js | 1 - ui/mirage/config.js | 6 +- ui/tests/acceptance/regions-test.js | 10 -- ui/tests/acceptance/token-test.js | 163 +++++++++++++++++++++++++++ 12 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 .changelog/23506.txt diff --git a/.changelog/23506.txt b/.changelog/23506.txt new file mode 100644 index 000000000..b57d6500d --- /dev/null +++ b/.changelog/23506.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Allow users to create Global ACL tokens from the Administration UI +``` diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index 68f6506ab..d5f56964a 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -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); } diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index c4d2db900..b436d6a40 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -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); diff --git a/ui/app/components/region-switcher.js b/ui/app/components/region-switcher.js index a40e661be..127b19e98 100644 --- a/ui/app/components/region-switcher.js +++ b/ui/app/components/region-switcher.js @@ -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() { diff --git a/ui/app/components/token-editor.hbs b/ui/app/components/token-editor.hbs index 9045f4a51..da3f32081 100644 --- a/ui/app/components/token-editor.hbs +++ b/ui/app/components/token-editor.hbs @@ -95,6 +95,41 @@ {{/unless}} + {{#if @token.isNew}} + {{#if this.system.shouldShowRegions}} + + Token Region + See ACL token fundamentals: Token replication settings for more information. + + {{this.system.activeRegion}} + + {{#if this.system.defaultRegion.region}} + {{!-- template-lint-disable simple-unless --}} + {{#unless (eq this.system.activeRegion this.system.defaultRegion.region)}} + + {{this.system.defaultRegion.region}} (authoritative region) + + {{/unless}} + {{/if}} + + global + + + {{/if}} + {{/if}} +
Client or Management token? diff --git a/ui/app/components/token-editor.js b/ui/app/components/token-editor.js index 3cd8eb161..770910281 100644 --- a/ui/app/components/token-editor.js +++ b/ui/app/components/token-editor.js @@ -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', diff --git a/ui/app/controllers/clients/index.js b/ui/app/controllers/clients/index.js index ea2bc84bd..006348454 100644 --- a/ui/app/controllers/clients/index.js +++ b/ui/app/controllers/clients/index.js @@ -29,7 +29,6 @@ export default class IndexController extends Controller.extend( @controller('clients') clientsController; @alias('model.nodes') nodes; - @alias('model.agents') agents; queryParams = [ { diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 74a2bf4dd..f72f18629 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -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; diff --git a/ui/app/routes/clients.js b/ui/app/routes/clients.js index ef6e9cd8d..e74f9bdc0 100644 --- a/ui/app/routes/clients.js +++ b/ui/app/routes/clients.js @@ -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)); } diff --git a/ui/mirage/config.js b/ui/mirage/config.js index dbad607dc..0375daece 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -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(), }); }); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js index d461d597b..48ccdda3b 100644 --- a/ui/tests/acceptance/regions-test.js +++ b/ui/tests/acceptance/regions-test.js @@ -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; diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index a16723293..c29dd44b2 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -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' + ); }); });