diff --git a/.changelog/17770.txt b/.changelog/17770.txt new file mode 100644 index 000000000..b282f6a09 --- /dev/null +++ b/.changelog/17770.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: observe a token's roles' rules in the UI and add an interface for managing tokens, roles, and policies +``` diff --git a/nomad/structs/acl.go b/nomad/structs/acl.go index 0135e2104..1495a0116 100644 --- a/nomad/structs/acl.go +++ b/nomad/structs/acl.go @@ -363,9 +363,12 @@ func (a *ACLToken) Validate(minTTL, maxTTL time.Duration, existing *ACLToken) er if existing.ExpirationTTL != a.ExpirationTTL { mErr.Errors = append(mErr.Errors, errors.New("cannot update expiration TTL")) } - if existing.ExpirationTime != a.ExpirationTime { - mErr.Errors = append(mErr.Errors, errors.New("cannot update expiration time")) + if a.ExpirationTime != nil { + if !existing.ExpirationTime.Equal(*a.ExpirationTime) { + mErr.Errors = append(mErr.Errors, errors.New("cannot update expiration time")) + } } + } return mErr.ErrorOrNil() diff --git a/ui/app/abilities/role.js b/ui/app/abilities/role.js new file mode 100644 index 000000000..d691adefc --- /dev/null +++ b/ui/app/abilities/role.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AbstractAbility from './abstract'; +import { alias } from '@ember/object/computed'; +import classic from 'ember-classic-decorator'; + +@classic +export default class Role extends AbstractAbility { + @alias('selfTokenIsManagement') canRead; + @alias('selfTokenIsManagement') canList; + @alias('selfTokenIsManagement') canWrite; + @alias('selfTokenIsManagement') canUpdate; + @alias('selfTokenIsManagement') canDestroy; +} diff --git a/ui/app/adapters/role.js b/ui/app/adapters/role.js new file mode 100644 index 000000000..c9e27fc33 --- /dev/null +++ b/ui/app/adapters/role.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// @ts-check +import { default as ApplicationAdapter, namespace } from './application'; +import classic from 'ember-classic-decorator'; +import { singularize } from 'ember-inflector'; +@classic +export default class RoleAdapter extends ApplicationAdapter { + namespace = namespace + '/acl'; + + urlForCreateRecord(modelName) { + let baseUrl = this.buildURL(modelName); + return singularize(baseUrl); + } + + urlForDeleteRecord(id) { + return this.urlForUpdateRecord(id, 'role'); + } +} diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index 21becb4f5..a2ee7a2a3 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -15,9 +15,22 @@ export default class TokenAdapter extends ApplicationAdapter { namespace = namespace + '/acl'; + methodForRequest(params) { + if (params.requestType === 'updateRecord') { + return 'POST'; + } + return super.methodForRequest(params); + } + + updateRecord(store, type, snapshot) { + let data = this.serialize(snapshot); + return this.ajax(`${this.buildURL()}/token/${snapshot.id}`, 'POST', { + data, + }); + } + createRecord(_store, type, snapshot) { let data = this.serialize(snapshot); - data.Policies = data.PolicyIDs; return this.ajax(`${this.buildURL()}/token`, 'POST', { data }); } diff --git a/ui/app/components/access-control-subnav.js b/ui/app/components/access-control-subnav.js new file mode 100644 index 000000000..9f15fc113 --- /dev/null +++ b/ui/app/components/access-control-subnav.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@ember/component'; +import { tagName } from '@ember-decorators/component'; +import { inject as service } from '@ember/service'; + +@tagName('') +export default class AccessControlSubnav extends Component { + @service keyboard; +} diff --git a/ui/app/components/editable-variable-link.hbs b/ui/app/components/editable-variable-link.hbs index 3a6938040..3d4de5bc5 100644 --- a/ui/app/components/editable-variable-link.hbs +++ b/ui/app/components/editable-variable-link.hbs @@ -7,9 +7,9 @@ {{#if (can "write variable")}} {{#with (editable-variable-link @path existingPaths=@existingPaths namespace=@namespace) as |link|}} {{#if link.model}} - {{@path}} + {{@path}} {{else}} - {{@path}} + {{@path}} {{/if}} {{/with}} {{else}} diff --git a/ui/app/components/policy-editor.hbs b/ui/app/components/policy-editor.hbs index 084899cb1..6d0b52804 100644 --- a/ui/app/components/policy-editor.hbs +++ b/ui/app/components/policy-editor.hbs @@ -14,7 +14,7 @@ @type="text" @value={{@policy.name}} class="input" - {{autofocus}} + {{autofocus}} /> {{/if}} @@ -34,7 +34,7 @@ mode="ruby" content=@policy.rules onUpdate=this.updatePolicyRules - autofocus=(not @policy.isNew) + autofocus=false extraKeys=(hash Cmd-Enter=this.save) }} /> @@ -55,12 +55,12 @@ \ No newline at end of file diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js index b1a6d253d..0fcbb0909 100644 --- a/ui/app/components/policy-editor.js +++ b/ui/app/components/policy-editor.js @@ -30,20 +30,22 @@ export default class PolicyEditorComponent extends Component { `Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.` ); } - const shouldRedirectAfterSave = this.policy.isNew; - + // Because we set the ID for adapter/serialization reasons just before save here, + // that becomes a barrier to our Unique Name validation. So we explicltly exclude + // the current policy when checking for uniqueness. if ( this.policy.isNew && - this.store.peekRecord('policy', this.policy.name) + this.store + .peekAll('policy') + .filter((policy) => policy !== this.policy) + .findBy('name', this.policy.name) ) { throw new Error( `A policy with name ${this.policy.name} already exists.` ); } - - this.policy.id = this.policy.name; - + this.policy.set('id', this.policy.name); await this.policy.save(); this.notifications.add({ @@ -52,7 +54,10 @@ export default class PolicyEditorComponent extends Component { }); if (shouldRedirectAfterSave) { - this.router.transitionTo('policies.policy', this.policy.id); + this.router.transitionTo( + 'access-control.policies.policy', + this.policy.id + ); } } catch (error) { this.notifications.add({ diff --git a/ui/app/components/profile-navbar-item.hbs b/ui/app/components/profile-navbar-item.hbs index 6a3ba2fde..17b84c168 100644 --- a/ui/app/components/profile-navbar-item.hbs +++ b/ui/app/components/profile-navbar-item.hbs @@ -4,22 +4,16 @@ ~}} {{#if this.token.selfToken}} - - Profile - {{option.label}} - + + + + + + + + {{else}} Sign In diff --git a/ui/app/components/profile-navbar-item.js b/ui/app/components/profile-navbar-item.js index 5a7062f60..ceca8226d 100644 --- a/ui/app/components/profile-navbar-item.js +++ b/ui/app/components/profile-navbar-item.js @@ -4,38 +4,24 @@ */ // @ts-check - import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; export default class ProfileNavbarItemComponent extends Component { @service token; @service router; @service store; - profileOptions = [ - { - label: 'Authorization', - key: 'authorization', - action: () => { - this.router.transitionTo('settings.tokens'); - }, - }, - { - label: 'Sign Out', - key: 'sign-out', - action: () => { - this.token.setProperties({ - secret: undefined, - }); + @action + signOut() { + this.token.setProperties({ + secret: undefined, + }); - // Clear out all data to ensure only data the anonymous token is privileged to see is shown - this.store.unloadAll(); - this.token.reset(); - this.router.transitionTo('jobs.index'); - }, - }, - ]; - - profileSelection = this.profileOptions[0]; + // Clear out all data to ensure only data the anonymous token is privileged to see is shown + this.store.unloadAll(); + this.token.reset(); + this.router.transitionTo('jobs.index'); + } } diff --git a/ui/app/components/role-editor.hbs b/ui/app/components/role-editor.hbs new file mode 100644 index 000000000..e34eb5eca --- /dev/null +++ b/ui/app/components/role-editor.hbs @@ -0,0 +1,78 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + +
+ +
+ +
+ + + <:body as |B|> + + + + + {{B.data.name}} + {{B.data.description}} + + + View Policy Definition + + + + + +
+ +
+ {{#if (can "update role")}} + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/components/role-editor.js b/ui/app/components/role-editor.js new file mode 100644 index 000000000..2fcd48d69 --- /dev/null +++ b/ui/app/components/role-editor.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check + +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +export default class RoleEditorComponent extends Component { + @service notifications; + @service router; + @service store; + + @alias('args.role') role; + + @tracked rolePolicies = []; + + // when this renders, set up rolePOlicies + constructor() { + super(...arguments); + this.rolePolicies = this.role.policies.toArray() || []; + } + + @action updateRolePolicies(policy, event) { + let { checked } = event.target; + if (checked) { + this.rolePolicies.push(policy); + } else { + this.rolePolicies = this.rolePolicies.filter((p) => p !== policy); + } + } + + @action async save(e) { + if (e instanceof Event) { + e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault() + } + try { + const nameRegex = '^[a-zA-Z0-9-]{1,128}$'; + if (!this.role.name?.match(nameRegex)) { + throw new Error( + `Role name must be 1-128 characters long and can only contain letters, numbers, and dashes.` + ); + } + + const shouldRedirectAfterSave = this.role.isNew; + + if (this.role.isNew && this.store.peekRecord('role', this.role.name)) { + throw new Error(`A role with name ${this.role.name} already exists.`); + } + + this.role.policies = this.rolePolicies; + + await this.role.save(); + + this.notifications.add({ + title: 'Role Saved', + color: 'success', + }); + + if (shouldRedirectAfterSave) { + this.router.transitionTo('access-control.roles.role', this.role.id); + } + } catch (error) { + this.notifications.add({ + title: `Error creating Role ${this.role.name}`, + message: error, + color: 'critical', + sticky: true, + }); + } + } +} diff --git a/ui/app/components/token-editor.hbs b/ui/app/components/token-editor.hbs new file mode 100644 index 000000000..273f90c0f --- /dev/null +++ b/ui/app/components/token-editor.hbs @@ -0,0 +1,239 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + +
+ {{#if @token.isNew}} + + Expiration time + + + {{!-- Radio to select between 1, 4, 8, 24, or never --}} + + + 10 minutes + + + 8 hours + + + 24 hours + + + Never + + + Custom + + + + {{#if @token.expirationTime}} + + + {{/if}} + + {{else}} + + {{#if @token.expirationTime}} + Token {{#if @token.isExpired}}expired{{else}}expires{{/if}} + + {{moment-from-now @token.expirationTime interval=1000}} + + {{else}} + Token never expires + {{/if}} + + {{/if}} +
+ + {{#unless @token.isNew}} +
+ + Token Accessor + +
+ +
+ + Token Secret + +
+ {{/unless}} + +
+ + Client or Management token? + See Token types documentation for more information. + + Client + + + Management + + +
+ + {{#if (eq @token.type "client")}} +
+ + {{#if @policies.length}} + + <:body as |B|> + + + + + {{B.data.name}} + {{B.data.description}} + + + View Policy Definition + + + + + + {{else}} +
+

+ No Policies +

+

+ Get started by creating a new policy +

+
+ {{/if}} +
+ +
+ + {{#if @roles.length}} + + <:body as |B|> + + + + + {{B.data.name}} + {{B.data.description}} + +
+ {{#each B.data.policies as |policy|}} + {{#if policy.name}} + + {{/if}} + {{else}} + Role contains no policies + {{/each}} +
+
+ + + View Role Info + + +
+ +
+ {{else}} +
+

+ No Roles +

+

+ Get started by creating a new role +

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

Management-type tokens have access to all permissions.

+ {{/if}} + +
+ {{#if (can "update token")}} + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/components/token-editor.js b/ui/app/components/token-editor.js new file mode 100644 index 000000000..5c5fb86b9 --- /dev/null +++ b/ui/app/components/token-editor.js @@ -0,0 +1,120 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +export default class TokenEditorComponent extends Component { + @service notifications; + @service router; + @service store; + + @alias('args.roles') roles; + @alias('args.token') activeToken; + @alias('args.policies') policies; + + @tracked tokenPolicies = []; + @tracked tokenRoles = []; + + // when this renders, set up tokenPolicies + constructor() { + super(...arguments); + this.tokenPolicies = this.activeToken.policies.toArray() || []; + this.tokenRoles = this.activeToken.roles.toArray() || []; + if (this.activeToken.isNew) { + this.activeToken.expirationTTL = 'never'; + } + } + + @action updateTokenPolicies(policy, event) { + let { checked } = event.target; + if (checked) { + this.tokenPolicies.push(policy); + } else { + this.tokenPolicies = this.tokenPolicies.filter((p) => p !== policy); + } + } + + @action updateTokenRoles(role, event) { + let { checked } = event.target; + if (checked) { + this.tokenRoles.push(role); + } else { + this.tokenRoles = this.tokenRoles.filter((p) => p !== role); + } + } + + @action updateTokenType(event) { + let tokenType = event.target.id; + this.activeToken.type = tokenType; + } + + @action updateTokenExpirationTime(event) { + // Override expirationTTL if user selects a time + this.activeToken.expirationTTL = null; + this.activeToken.expirationTime = new Date(event.target.value); + } + @action updateTokenExpirationTTL(event) { + // Override expirationTime if user selects a TTL + this.activeToken.expirationTime = null; + if (event.target.value === 'never') { + this.activeToken.expirationTTL = null; + } else if (event.target.value === 'custom') { + this.activeToken.expirationTime = new Date(); + } else { + this.activeToken.expirationTTL = event.target.value; + } + } + + @action async save() { + try { + const shouldRedirectAfterSave = this.activeToken.isNew; + + this.activeToken.policies = this.tokenPolicies; + this.activeToken.roles = this.tokenRoles; + + if (this.activeToken.type === 'management') { + // Management tokens cannot have policies or roles + this.activeToken.policyIDs = []; + this.activeToken.policyNames = []; + this.activeToken.policies = []; + this.activeToken.roles = []; + } + + // 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, + // it stays as the string "never", where the API expects a null value. + if (this.activeToken.expirationTTL === 'never') { + this.activeToken.expirationTTL = null; + } + + await this.activeToken.save(); + + this.notifications.add({ + title: 'Token Saved', + color: 'success', + }); + + if (shouldRedirectAfterSave) { + this.router.transitionTo( + 'access-control.tokens.token', + this.activeToken.id + ); + } + } catch (error) { + this.notifications.add({ + title: `Error creating Token ${this.activeToken.name}`, + message: error, + color: 'critical', + sticky: true, + }); + } + } +} diff --git a/ui/app/controllers/access-control/policies/index.js b/ui/app/controllers/access-control/policies/index.js new file mode 100644 index 000000000..322dcb6be --- /dev/null +++ b/ui/app/controllers/access-control/policies/index.js @@ -0,0 +1,94 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +export default class AccessControlPoliciesIndexController extends Controller { + @service router; + @service notifications; + @service can; + + get columns() { + const defaultColumns = [ + { + key: 'name', + label: 'Name', + isSortable: true, + }, + { + key: 'description', + label: 'Description', + }, + ]; + + const tokensColumn = { + key: 'tokens', + label: 'Tokens', + isSortable: true, + }; + + const deleteColumn = { + key: 'delete', + label: 'Delete', + }; + + return [ + ...defaultColumns, + ...(this.can.can('list token') ? [tokensColumn] : []), + ...(this.can.can('destroy policy') ? [deleteColumn] : []), + ]; + } + + get policies() { + return this.model.policies.map((policy) => { + policy.tokens = (this.model.tokens || []).filter((token) => { + return token.policies.includes(policy); + }); + return policy; + }); + } + + @action openPolicy(policy) { + this.router.transitionTo('access-control.policies.policy', policy.name); + } + + @action goToNewPolicy() { + this.router.transitionTo('access-control.policies.new'); + } + + @task(function* (policy) { + try { + yield policy.deleteRecord(); + yield policy.save(); + + // Cleanup: Remove references from roles and tokens + this.store.peekAll('role').forEach((role) => { + role.policies.removeObject(policy); + }); + this.store.peekAll('token').forEach((token) => { + token.policies.removeObject(policy); + }); + if (this.store.peekRecord('policy', policy.id)) { + this.store.unloadRecord(policy); + } + + this.notifications.add({ + title: `Policy ${policy.name} successfully deleted`, + color: 'success', + }); + } catch (err) { + this.error = { + title: 'Error deleting policy', + description: err, + }; + + throw err; + } + }) + deletePolicy; +} diff --git a/ui/app/controllers/policies/policy.js b/ui/app/controllers/access-control/policies/policy.js similarity index 72% rename from ui/app/controllers/policies/policy.js rename to ui/app/controllers/access-control/policies/policy.js index 41796c908..5d69c6bbf 100644 --- a/ui/app/controllers/policies/policy.js +++ b/ui/app/controllers/access-control/policies/policy.js @@ -5,13 +5,11 @@ // @ts-check 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 { +export default class AccessControlPoliciesPolicyController extends Controller { @service notifications; @service router; @service store; @@ -19,36 +17,32 @@ export default class PoliciesPolicyController extends Controller { @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>`; + return `nomad acl token create -name="" -policy="${this.policy.name}" -type=client -ttl=8h`; } - - @action - onDeletePrompt() { - this.isDeleting = true; - } - - @action - onDeleteCancel() { - this.isDeleting = false; - } - @task(function* () { try { yield this.policy.deleteRecord(); yield this.policy.save(); + + // Cleanup: Remove references from roles and tokens + this.store.peekAll('role').forEach((role) => { + role.policies.removeObject(this.policy); + }); + this.store.peekAll('token').forEach((token) => { + token.policies.removeObject(this.policy); + }); + if (this.store.peekRecord('policy', this.policy.id)) { + this.store.unloadRecord(this.policy); + } + this.notifications.add({ title: 'Policy Deleted', color: 'success', type: `success`, destroyOnClick: false, }); - this.router.transitionTo('policies'); + this.router.transitionTo('access-control.policies'); } catch (err) { this.notifications.add({ title: `Error deleting Policy ${this.policy.name}`, @@ -92,12 +86,12 @@ export default class PoliciesPolicyController extends Controller { }, }); } catch (err) { - this.error = { - title: 'Error creating new token', - description: err, - }; - - throw err; + this.notifications.add({ + title: 'Error creating test token', + message: err, + color: 'critical', + sticky: true, + }); } }) createTestToken; @@ -112,12 +106,12 @@ export default class PoliciesPolicyController extends Controller { color: 'success', }); } catch (err) { - this.error = { + this.notifications.add({ title: 'Error deleting token', - description: err, - }; - - throw err; + message: err, + color: 'critical', + sticky: true, + }); } }) deleteToken; diff --git a/ui/app/controllers/access-control/roles/index.js b/ui/app/controllers/access-control/roles/index.js new file mode 100644 index 000000000..4807b1490 --- /dev/null +++ b/ui/app/controllers/access-control/roles/index.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +export default class AccessControlRolesIndexController extends Controller { + @service router; + @service notifications; + @service can; + + get columns() { + const defaultColumns = [ + { + key: 'name', + label: 'Name', + isSortable: true, + }, + { + key: 'description', + label: 'Description', + }, + ]; + + const policiesColumn = { + key: 'policies', + label: 'Policies', + }; + + const tokensColumn = { + key: 'tokens', + label: 'Tokens', + isSortable: true, + }; + + const deleteColumn = { + key: 'delete', + label: 'Delete', + }; + + return [ + ...defaultColumns, + ...(this.can.can('list token') ? [tokensColumn] : []), + ...(this.can.can('list policy') ? [policiesColumn] : []), + ...(this.can.can('destroy role') ? [deleteColumn] : []), + ]; + } + + get roles() { + return this.model.roles.map((role) => { + role.tokens = (this.model.tokens || []).filter((token) => { + return token.roles.includes(role); + }); + return role; + }); + } + + @action openRole(role) { + this.router.transitionTo('access-control.roles.role', role.id); + } + + @action goToNewRole() { + this.router.transitionTo('access-control.roles.new'); + } + + @task(function* (role) { + try { + yield role.deleteRecord(); + yield role.save(); + this.notifications.add({ + title: `Role ${role.name} successfully deleted`, + color: 'success', + }); + } catch (err) { + this.error = { + title: 'Error deleting role', + description: err, + }; + + throw err; + } + }) + deleteRole; +} diff --git a/ui/app/controllers/access-control/roles/role.js b/ui/app/controllers/access-control/roles/role.js new file mode 100644 index 000000000..a2c2189fa --- /dev/null +++ b/ui/app/controllers/access-control/roles/role.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; + +export default class AccessControlRolesRoleController extends Controller { + @service notifications; + @service router; + @service store; + + @alias('model.role') role; + @alias('model.tokens') tokens; + @alias('model.policies') policies; + + get newTokenString() { + return `nomad acl token create -name="" -role-name="${this.role.name}" -type=client -ttl=8h`; + } + + @task(function* () { + try { + yield this.role.deleteRecord(); + yield this.role.save(); + this.notifications.add({ + title: 'Role Deleted', + color: 'success', + type: `success`, + destroyOnClick: false, + }); + this.router.transitionTo('access-control.roles'); + } catch (err) { + this.notifications.add({ + title: `Error deleting Role ${this.role.name}`, + message: err, + color: 'critical', + sticky: true, + }); + } + }) + deleteRole; + + async refreshTokens() { + this.tokens = this.store.peekAll('token').filter((token) => + token.roles.any((role) => { + return role.id === decodeURIComponent(this.role.id); + }) + ); + } + + @task(function* () { + try { + const newToken = this.store.createRecord('token', { + name: `Example Token for ${this.role.name}`, + roles: [this.role], + // New date 10 minutes into the future + expirationTime: new Date(Date.now() + 10 * 60 * 1000), + type: 'client', + }); + yield newToken.save(); + yield this.refreshTokens(); + this.notifications.add({ + title: 'Example Token Created', + message: `${newToken.secret}`, + color: 'success', + timeout: 30000, + customAction: { + label: 'Copy to Clipboard', + action: () => { + navigator.clipboard.writeText(newToken.secret); + }, + }, + }); + } catch (err) { + this.notifications.add({ + title: 'Error creating test token', + message: err, + color: 'critical', + sticky: true, + }); + } + }) + createTestToken; + + @task(function* (token) { + try { + yield token.deleteRecord(); + yield token.save(); + yield this.refreshTokens(); + this.notifications.add({ + title: 'Token successfully deleted', + color: 'success', + }); + } catch (err) { + this.notifications.add({ + title: 'Error deleting token', + message: err, + color: 'critical', + sticky: true, + }); + } + }) + deleteToken; +} diff --git a/ui/app/controllers/access-control/tokens/index.js b/ui/app/controllers/access-control/tokens/index.js new file mode 100644 index 000000000..1150feaba --- /dev/null +++ b/ui/app/controllers/access-control/tokens/index.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Controller from '@ember/controller'; +import { task } from 'ember-concurrency'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class AccessControlTokensIndexController extends Controller { + @service notifications; + @service router; + @service token; + + @task(function* (token) { + try { + yield token.deleteRecord(); + yield token.save(); + this.notifications.add({ + title: `Token ${token.name} successfully deleted`, + color: 'success', + }); + } catch (err) { + this.error = { + title: 'Error deleting token', + description: err, + }; + + throw err; + } + }) + deleteToken; + + get selfToken() { + return this.token.selfToken; + } + + @action openToken(token) { + this.router.transitionTo('access-control.tokens.token', token.id); + } + + @action goToNewToken() { + this.router.transitionTo('access-control.tokens.new'); + } +} diff --git a/ui/app/controllers/access-control/tokens/token.js b/ui/app/controllers/access-control/tokens/token.js new file mode 100644 index 000000000..2527fa592 --- /dev/null +++ b/ui/app/controllers/access-control/tokens/token.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; + +export default class AccessControlTokensTokenController extends Controller { + @service notifications; + @service router; + @service store; + + @alias('model.roles') roles; + @alias('model.token') activeToken; // looks like .token is an Ember reserved name? + @alias('model.policies') policies; + + @task(function* () { + try { + yield this.activeToken.deleteRecord(); + yield this.activeToken.save(); + this.notifications.add({ + title: 'Token Deleted', + color: 'success', + type: `success`, + destroyOnClick: false, + }); + this.router.transitionTo('access-control.tokens'); + } catch (err) { + this.notifications.add({ + title: `Error deleting Token ${this.activeToken.name}`, + message: err, + color: 'critical', + sticky: true, + }); + } + }) + deleteToken; +} diff --git a/ui/app/models/role.js b/ui/app/models/role.js new file mode 100644 index 000000000..431f67d86 --- /dev/null +++ b/ui/app/models/role.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// @ts-check +import Model from '@ember-data/model'; +import { attr, hasMany } from '@ember-data/model'; + +export default class Role extends Model { + @attr('string') name; + @attr('string') description; + @hasMany('policy', { defaultValue: () => [] }) policies; + @attr() policyNames; +} diff --git a/ui/app/models/token.js b/ui/app/models/token.js index 0be091fba..10c364ae9 100644 --- a/ui/app/models/token.js +++ b/ui/app/models/token.js @@ -15,12 +15,31 @@ export default class Token extends Model { @attr('date') createTime; @attr('string') type; @hasMany('policy') policies; + @hasMany('role') roles; @attr() policyNames; @attr('date') expirationTime; + // Note on verbatim: updating a token requires passing in its expiration time, where + // the API performs an equality check. However, we want to display it as a nicely + // formatted date in the UI. @attr('date') does this for us, but it strips the + // nanoseconds. Thus, our serializer retains the original value in a separate field + // that gets used on PUT requests when needed. + @attr() expirationTimeVerbatim; + @attr() expirationTTL; + @alias('id') accessor; get isExpired() { return this.expirationTime && this.expirationTime < new Date(); } + + /** + * Combined policies directly on the token, and policies inferred from token's role[s] + */ + get combinedPolicies() { + return [ + ...this.policies.toArray(), + ...this.roles.map((role) => role.policies.toArray()).flat(), + ].uniq(); + } } diff --git a/ui/app/modifiers/code-mirror.js b/ui/app/modifiers/code-mirror.js index ed65ef8fe..ca0278533 100644 --- a/ui/app/modifiers/code-mirror.js +++ b/ui/app/modifiers/code-mirror.js @@ -25,8 +25,15 @@ export default class CodeMirrorModifier extends Modifier { } } - didInstall() { - this._setup(); + element = null; + args = {}; + + modify(element, positional, named) { + if (!this.element) { + this.element = element; + this.args = { positional, named }; + this._setup(); + } } didUpdateArguments() { diff --git a/ui/app/router.js b/ui/app/router.js index e9cc9fe9e..fde299397 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -111,11 +111,24 @@ Router.map(function () { }); }); - this.route('policies', function () { - this.route('new'); - - this.route('policy', { - path: '/:name', + this.route('access-control', function () { + this.route('policies', function () { + this.route('new'); + this.route('policy', { + path: '/:name', + }); + }); + this.route('roles', function () { + this.route('new'); + this.route('role', { + path: '/:id', + }); + }); + this.route('tokens', function () { + this.route('new'); + this.route('token', { + path: '/:id', + }); }); }); // Mirage-only route for testing OIDC flow diff --git a/ui/app/routes/access-control.js b/ui/app/routes/access-control.js new file mode 100644 index 000000000..6980bdadc --- /dev/null +++ b/ui/app/routes/access-control.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +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 RSVP from 'rsvp'; + +export default class AccessControlRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service can; + @service store; + @service router; + + beforeModel() { + if ( + this.can.cannot('list policies') || + this.can.cannot('list roles') || + this.can.cannot('list tokens') + ) { + this.router.transitionTo('/jobs'); + } + } + + // Load our tokens, roles, and policies + model() { + return RSVP.hash({ + policies: this.store.findAll('policy', { reload: true }), + roles: this.store.findAll('role', { reload: true }), + tokens: this.store.findAll('token', { reload: true }), + }); + } + + // After model: check for all tokens[].policies and roles[].policies to see if any of them are listed + // that aren't also in the policies list. + // If any of them are, unload them from the store — they are orphans. + afterModel(model) { + let policies = model.policies; + let roles = model.roles; + let tokens = model.tokens; + + roles.forEach((role) => { + let orphanedPolicies = []; + role.policies.forEach((policy) => { + if (policy && !policies.includes(policy)) { + orphanedPolicies.push(policy); + } + }); + orphanedPolicies.forEach((policy) => { + role.policies.removeObject(policy); + if (this.store.peekRecord('policy', policy.id)) { + this.store.unloadRecord(policy); + } + }); + }); + + tokens.forEach((token) => { + let orphanedPolicies = []; + token.policies.forEach((policy) => { + if (policy && !policies.includes(policy)) { + orphanedPolicies.push(policy); + } + }); + orphanedPolicies.forEach((policy) => { + token.policies.removeObject(policy); + if (this.store.peekRecord('policy', policy.id)) { + this.store.unloadRecord(policy); + } + }); + }); + } +} diff --git a/ui/app/routes/policies/new.js b/ui/app/routes/access-control/policies/new.js similarity index 91% rename from ui/app/routes/policies/new.js rename to ui/app/routes/access-control/policies/new.js index fcec079d4..9b52a619c 100644 --- a/ui/app/routes/policies/new.js +++ b/ui/app/routes/access-control/policies/new.js @@ -84,13 +84,13 @@ operator { # * write `; -export default class PoliciesNewRoute extends Route { +export default class AccessControlPoliciesNewRoute extends Route { @service can; @service router; beforeModel() { if (this.can.cannot('write policy')) { - this.router.transitionTo('/policies'); + this.router.transitionTo('/access-control/policies'); } } @@ -102,8 +102,6 @@ export default class PoliciesNewRoute extends Route { } resetController(controller, isExiting) { - // If the user navigates away from /new, clear the path - controller.set('path', null); if (isExiting) { // If user didn't save, delete the freshly created model if (controller.model.isNew) { diff --git a/ui/app/routes/policies/policy.js b/ui/app/routes/access-control/policies/policy.js similarity index 90% rename from ui/app/routes/policies/policy.js rename to ui/app/routes/access-control/policies/policy.js index f7a9b5100..70fec6e4c 100644 --- a/ui/app/routes/policies/policy.js +++ b/ui/app/routes/access-control/policies/policy.js @@ -9,7 +9,7 @@ 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( +export default class AccessControlPoliciesPolicyRoute extends Route.extend( withForbiddenState, WithModelErrorHandling ) { diff --git a/ui/app/routes/access-control/roles/new.js b/ui/app/routes/access-control/roles/new.js new file mode 100644 index 000000000..84b41483d --- /dev/null +++ b/ui/app/routes/access-control/roles/new.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class AccessControlRolesNewRoute extends Route { + @service can; + @service router; + + beforeModel() { + if (this.can.cannot('write role')) { + this.router.transitionTo('/access-control/roles'); + } + } + + async model() { + let role = await this.store.createRecord('role', { + name: '', + }); + return { + role, + policies: await this.store.findAll('policy'), + }; + } + + resetController(controller, isExiting) { + if (isExiting) { + // If user didn't save, delete the freshly created model + if (controller.model.role.isNew) { + controller.model.role.destroyRecord(); + } + } + } +} diff --git a/ui/app/routes/access-control/roles/role.js b/ui/app/routes/access-control/roles/role.js new file mode 100644 index 000000000..e5fe1bc72 --- /dev/null +++ b/ui/app/routes/access-control/roles/role.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +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 AccessControlRolesRoleRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service store; + + async model(params) { + let role = await this.store.findRecord( + 'role', + decodeURIComponent(params.id), + { + reload: true, + } + ); + + let policies = this.store.peekAll('policy'); + + return hash({ + role, + tokens: this.store.peekAll('token').filter((token) => { + return token.roles.any((role) => { + return role.id === decodeURIComponent(params.id); + }); + }), + policies, + }); + } +} diff --git a/ui/app/routes/access-control/tokens/new.js b/ui/app/routes/access-control/tokens/new.js new file mode 100644 index 000000000..979b84526 --- /dev/null +++ b/ui/app/routes/access-control/tokens/new.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class AccessControlTokensNewRoute extends Route { + @service can; + @service router; + + beforeModel() { + if (this.can.cannot('write token')) { + this.router.transitionTo('/access-control/tokens'); + } + } + + async model() { + let token = await this.store.createRecord('token', { + name: '', + type: 'client', + }); + return { + token, + policies: await this.store.findAll('policy'), + roles: await this.store.findAll('role'), + }; + } + + resetController(controller, isExiting) { + if (isExiting) { + // If user didn't save, delete the freshly created model + if (controller.model.token.isNew) { + controller.model.token.destroyRecord(); + } + } + } +} diff --git a/ui/app/routes/access-control/tokens/token.js b/ui/app/routes/access-control/tokens/token.js new file mode 100644 index 000000000..24ef10a67 --- /dev/null +++ b/ui/app/routes/access-control/tokens/token.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +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 AccessControlTokensTokenRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service store; + @service token; + + // Route guard to prevent you from wrecking your current token + beforeModel() { + let id = this.paramsFor('access-control.tokens.token').id; + if (this.token.selfToken && this.token.selfToken.id === id) { + this.transitionTo('/access-control/tokens'); + } + } + + async model(params) { + let token = await this.store.findRecord( + 'token', + decodeURIComponent(params.id), + { + reload: true, + } + ); + + let policies = this.store.peekAll('policy'); + let roles = this.store.peekAll('role'); + + return hash({ + token, + roles, + policies, + }); + } +} diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 72156c92c..690f1e485 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -56,7 +56,7 @@ export default class ApplicationRoute extends Route { this.controllerFor('application').set('error', e); } - const fetchSelfTokenAndPolicies = this.get( + const fetchSelfTokenAndPolicies = await this.get( 'token.fetchSelfTokenAndPolicies' ) .perform() diff --git a/ui/app/serializers/role.js b/ui/app/serializers/role.js new file mode 100644 index 000000000..2b1aa057c --- /dev/null +++ b/ui/app/serializers/role.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// @ts-check +import ApplicationSerializer from './application'; +import classic from 'ember-classic-decorator'; +import { copy } from 'ember-copy'; + +@classic +export default class RoleSerializer extends ApplicationSerializer { + normalize(typeHash, hash) { + hash.Policies = hash.Policies || []; // null guard + hash.PolicyIDs = hash.Policies.map((policy) => policy.Name); + hash.PolicyNames = copy(hash.PolicyIDs); + return super.normalize(typeHash, hash); + } + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + // required for update/PUT requests + if (snapshot.id) { + hash.ID = snapshot.id; + } + hash.Policies = hash.PolicyIDs.map((policy) => { + return { + Name: policy, + }; + }); + delete hash.PolicyIDs; + delete hash.PolicyNames; + return hash; + } +} diff --git a/ui/app/serializers/token.js b/ui/app/serializers/token.js index c6b2fefff..f5933d4cf 100644 --- a/ui/app/serializers/token.js +++ b/ui/app/serializers/token.js @@ -18,6 +18,35 @@ export default class TokenSerializer extends ApplicationSerializer { normalize(typeHash, hash) { hash.PolicyIDs = hash.Policies; hash.PolicyNames = copy(hash.Policies); + hash.Roles = hash.Roles || []; + hash.RoleIDs = hash.Roles.map((role) => role.ID); + hash.ExpirationTimeVerbatim = hash.ExpirationTime; return super.normalize(typeHash, hash); } + + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + + if (snapshot.id) { + hash.AccessorID = snapshot.id; + // If expirationTimeNanos exists, use that when saving expirationTime for equality check reasons; + // see note in token model. + hash.ExpirationTime = hash.ExpirationTimeVerbatim || hash.ExpirationTime; + } + + delete hash.CreateTime; + delete hash.SecretID; + + hash.Policies = hash.PolicyIDs || []; + delete hash.PolicyIDs; + delete hash.PolicyNames; + + hash.Roles = + (hash.RoleIDs || []).map((id) => { + return { ID: id }; + }) || []; + delete hash.RoleIDs; + + return hash; + } } diff --git a/ui/app/services/token.js b/ui/app/services/token.js index bd8d0d347..976703d9b 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -71,7 +71,24 @@ export default class TokenService extends Service { @task(function* () { try { if (this.selfToken) { - return yield this.selfToken.get('policies'); + // return yield this.selfToken.get('policies'); + let tokenPolicies = yield this.selfToken.get('policies'); + let rolePolicies = []; + const roles = yield this.selfToken.get('roles'); + if (roles.length) { + yield Promise.all( + roles.map((role) => { + return role.policies; + }) + ); + rolePolicies = roles + .map((role) => { + return role.policies; + }) + .map((policies) => policies.toArray()) + .flat(); + } + return [...tokenPolicies.toArray(), ...rolePolicies]; } else { let policy = yield this.store.findRecord('policy', 'anonymous'); return [policy]; diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 2992e310b..83022f51f 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -3,7 +3,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -@import './components/accordion'; +@import './components/accordion-internal'; @import './components/badge-nomad-internal'; @import './components/boxed-section'; @import './components/codemirror'; @@ -59,3 +59,4 @@ @import './components/policies'; @import './components/metadata-editor'; @import './components/job-status-panel'; +@import './components/access-control'; diff --git a/ui/app/styles/components/access-control.scss b/ui/app/styles/components/access-control.scss new file mode 100644 index 000000000..dca06d979 --- /dev/null +++ b/ui/app/styles/components/access-control.scss @@ -0,0 +1,84 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +.access-control-overview { + .intro { + margin-bottom: 2rem; + p { + margin-bottom: 1rem; + } + footer { + display: flex; + gap: 1rem; + } + } + + .section-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + & > div { + padding: 1rem; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 0.5rem; + + & > p { + margin-bottom: 0.5rem; + } + & > a { + font-weight: bold; + font-size: 1.5rem; + text-decoration: none; + + &.hds-button { + font-weight: normal; + font-size: inherit; + } + } + } + } +} + +.acl-table { + .tag-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + + a { + white-space: nowrap; + } + } +} + +.acl-form { + display: grid; + gap: 2rem; + + .selection-checkbox { + position: relative; + label { + cursor: pointer; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 12px 16px; + } + } + + .expiration-time fieldset { + margin-bottom: 1rem; + } +} + +.acl-explainer { + display: grid; + grid-template-columns: 1fr auto; + gap: 2rem; + margin-bottom: 2rem; +} diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion-internal.scss similarity index 100% rename from ui/app/styles/components/accordion.scss rename to ui/app/styles/components/accordion-internal.scss diff --git a/ui/app/styles/components/page-layout.scss b/ui/app/styles/components/page-layout.scss index 82418b4bc..6eb8a0611 100644 --- a/ui/app/styles/components/page-layout.scss +++ b/ui/app/styles/components/page-layout.scss @@ -17,7 +17,7 @@ // Defensive styles in case header height goes over 100px, causing // the left gutter menu to be on top of the header. height: $header-height; - overflow: hidden; + overflow: visible; } .page-body { diff --git a/ui/app/styles/components/policies.scss b/ui/app/styles/components/policies.scss index 43722d543..5bc84361f 100644 --- a/ui/app/styles/components/policies.scss +++ b/ui/app/styles/components/policies.scss @@ -3,6 +3,8 @@ * SPDX-License-Identifier: MPL-2.0 */ +// TODO: merge this in with access-control.scss, consider getting rid of policies table specific stuff + table.policies { tr { cursor: pointer; @@ -47,10 +49,11 @@ table.policies { .external-link svg { position: relative; - top: 3px; + top: 3px; } - button.create-test-token, pre { + button.create-test-token, + pre { margin-top: 1rem; } @@ -71,7 +74,6 @@ table.policies { } } - table.tokens { margin-bottom: 3rem; } diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index 5e8aa3a06..204fe486d 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -17,9 +17,10 @@ color: $primary-invert; padding-left: 20px; padding-right: 20px; - overflow: hidden; + overflow: visible; align-items: center; justify-content: space-between; + z-index: $z-gutter; .navbar-item { color: rgba($primary-invert, 0.8); @@ -159,24 +160,16 @@ .profile-dropdown { padding: 0.5rem 1rem 0.5rem 0.75rem; - background-color: transparent; - border: none !important; - height: auto; - box-shadow: none !important; + z-index: $z-gutter; + button.hds-dropdown-toggle-icon { + border-color: var(--token-color-palette-neutral-200); + background-color: transparent; + color: var(--token-color-surface-primary); - &:focus { - background-color: #21a572; - } - - .ember-power-select-prefix { - color: rgba($primary-invert, 0.8); - } - .ember-power-select-selected-item { - margin-left: 0; - border: none; - } - .ember-power-select-status-icon { - border-top-color: white; + &.hds-dropdown-toggle-icon--is-open { + background-color: var(--token-color-surface-primary); + color: var(--token-color-foreground-primary); + } } } diff --git a/ui/app/templates/access-control.hbs b/ui/app/templates/access-control.hbs new file mode 100644 index 000000000..d970551d6 --- /dev/null +++ b/ui/app/templates/access-control.hbs @@ -0,0 +1,12 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Access Control"}} + + + + + {{outlet}} + \ No newline at end of file diff --git a/ui/app/templates/access-control/index.hbs b/ui/app/templates/access-control/index.hbs new file mode 100644 index 000000000..e2d3fb531 --- /dev/null +++ b/ui/app/templates/access-control/index.hbs @@ -0,0 +1,44 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
+

Your Nomad cluster has Access Control enabled, which you can use to control access to data and APIs. Here, you can manage the Tokens, Policies, and Roles for your system.

+
+ + +
+
+
+ + + {{this.model.tokens.length}} {{pluralize "Token" this.model.tokens.length}} + +

User access tokens are associated with one or more policies or roles to grant specific capabilities.

+ +
+ + + {{this.model.roles.length}} {{pluralize "Role" this.model.roles.length}} + +

Roles group one or more Policies into higher-level sets of permissions.

+ +
+ + + {{this.model.policies.length}} {{pluralize "Policy" this.model.policies.length}} + +

Sets of rules defining the capabilities granted to adhering tokens.

+ +
+
+
+{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/access-control/policies.hbs b/ui/app/templates/access-control/policies.hbs new file mode 100644 index 000000000..ad1807ef0 --- /dev/null +++ b/ui/app/templates/access-control/policies.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Policies"}} + +{{outlet}} diff --git a/ui/app/templates/access-control/policies/index.hbs b/ui/app/templates/access-control/policies/index.hbs new file mode 100644 index 000000000..8f985b4d2 --- /dev/null +++ b/ui/app/templates/access-control/policies/index.hbs @@ -0,0 +1,82 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
+

+ ACL Policies are sets of rules defining the capabilities granted to adhering tokens. You can create, modify, and delete them here. +

+
+ {{#if (can "write policy")}} + + {{else}} + + {{/if}} +
+
+ + {{#if this.policies.length}} + + + <:body as |B|> + + + {{B.data.name}} + {{B.data.description}} + {{#if (can "list token")}} + + {{B.data.tokens.length}} + {{#if (filter-by "isExpired" B.data.tokens)}} + ({{get (filter-by "isExpired" B.data.tokens) "length"}} expired) + {{/if}} + + {{/if}} + {{#if (can "destroy policy")}} + + + + {{/if}} + + + + {{else}} +
+

+ No Policies +

+

+ Get started by creating a new policy +

+
+ {{/if}} +
diff --git a/ui/app/templates/policies/new.hbs b/ui/app/templates/access-control/policies/new.hbs similarity index 75% rename from ui/app/templates/policies/new.hbs rename to ui/app/templates/access-control/policies/new.hbs index fdfbbb834..fe8a52cca 100644 --- a/ui/app/templates/policies/new.hbs +++ b/ui/app/templates/access-control/policies/new.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: MPL-2.0 ~}} - + {{page-title "Create Policy"}}

diff --git a/ui/app/templates/policies/policy.hbs b/ui/app/templates/access-control/policies/policy.hbs similarity index 75% rename from ui/app/templates/policies/policy.hbs rename to ui/app/templates/access-control/policies/policy.hbs index 524aa19b3..2cee97365 100644 --- a/ui/app/templates/policies/policy.hbs +++ b/ui/app/templates/access-control/policies/policy.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: MPL-2.0 ~}} - + {{page-title "Policy"}}

@@ -12,17 +12,10 @@ {{#if (can "destroy policy")}}
- +
{{/if}}

@@ -90,7 +83,7 @@ - + {{row.model.name}} @@ -104,24 +97,14 @@ {{moment-from-now row.model.expirationTime interval=1000}} {{else}} - Never + Never {{/if}} {{#if (can "destroy token")}} - {{/if}} @@ -141,5 +124,3 @@ {{/if}}
- -{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/access-control/roles.hbs b/ui/app/templates/access-control/roles.hbs new file mode 100644 index 000000000..5ad053437 --- /dev/null +++ b/ui/app/templates/access-control/roles.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Roles"}} + +{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/access-control/roles/index.hbs b/ui/app/templates/access-control/roles/index.hbs new file mode 100644 index 000000000..de16e0f5b --- /dev/null +++ b/ui/app/templates/access-control/roles/index.hbs @@ -0,0 +1,102 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
+

+ ACL Roles group one or more Policies into higher-level sets of permissions. A user token can have any number of roles or policies. +

+
+ {{#if (can "write role")}} + + {{else}} + + {{/if}} +
+
+ + {{#if this.roles.length}} + + <:body as |B|> + + + {{B.data.name}} + {{B.data.description}} + {{#if (can "list token")}} + + {{B.data.tokens.length}} + {{#if (filter-by "isExpired" B.data.tokens)}} + ({{get (filter-by "isExpired" B.data.tokens) "length"}} expired) + {{/if}} + + {{/if}} + {{#if (can "list policy")}} + +
+ {{#each B.data.policyNames as |policyName|}} + {{#let (find-by "name" policyName this.model.policies) as |policy|}} + {{#if policy}} + + {{else}} + + {{/if}} + {{/let}} + {{else}} + No Policies + {{/each}} +
+
+ {{/if}} + {{#if (can "destroy role")}} + + + + {{/if}} +
+ +
+ + {{else}} +
+

+ No Roles +

+

+ Get started by creating a new role +

+
+ {{/if}} +
diff --git a/ui/app/templates/access-control/roles/new.hbs b/ui/app/templates/access-control/roles/new.hbs new file mode 100644 index 000000000..c8d623ccb --- /dev/null +++ b/ui/app/templates/access-control/roles/new.hbs @@ -0,0 +1,27 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + +{{page-title "Create Role"}} +
+

+ Create Role +

+ {{#if this.model.policies.length}} + + {{else}} +
+

+ No Policies +

+

+ At least one Policy is required to create a Role; create a new policy +

+
+ {{/if}} +
diff --git a/ui/app/templates/access-control/roles/role.hbs b/ui/app/templates/access-control/roles/role.hbs new file mode 100644 index 000000000..777743c66 --- /dev/null +++ b/ui/app/templates/access-control/roles/role.hbs @@ -0,0 +1,123 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Role"}} +
+

+
+ Edit Role +
+ {{#if (can "destroy role")}} + + {{/if}} +

+ + + {{#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 role. +

+
+ {{/if}} + {{/if}} + +
diff --git a/ui/app/templates/access-control/tokens.hbs b/ui/app/templates/access-control/tokens.hbs new file mode 100644 index 000000000..f1987c3aa --- /dev/null +++ b/ui/app/templates/access-control/tokens.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Tokens"}} + +{{outlet}} diff --git a/ui/app/templates/access-control/tokens/index.hbs b/ui/app/templates/access-control/tokens/index.hbs new file mode 100644 index 000000000..76f0ed113 --- /dev/null +++ b/ui/app/templates/access-control/tokens/index.hbs @@ -0,0 +1,149 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
+

+ ACL Tokens are associated with one or more policies or roles to grant specific capabilities. Users can use these to sign into, and operate, Nomad with the permissions laid out in their policies. +

+
+ {{#if (can "write token")}} + + {{else}} + + {{/if}} +
+
+ {{#if this.model.tokens.length}} + + <:body as |B|> + + + {{#if (eq B.data.id this.selfToken.id)}} + {{B.data.name}} + {{else}} + + {{B.data.name}} + + {{/if}} + + {{B.data.type}} + {{moment-from-now B.data.createTime interval=1000}} + + {{#if B.data.expirationTime}} + + {{moment-from-now B.data.expirationTime interval=1000}} + + {{else}} + Never + {{/if}} + + + +
+ {{!-- + We don't treat roles (roleNames) the same as policies, because Roles' names are currently + returning blank on the /tokens endpoint: https://github.com/hashicorp/nomad/issues/18451 + TODO: when that's fixed, we can use an #each #let pattern like we do for policyNames. + --}} + {{#each B.data.roles as |role|}} + {{#if role.name}} + + {{/if}} + {{else}} + {{#if (eq B.data.type "management")}} + Management Access + {{else}} + No Roles + {{/if}} + {{/each}} +
+
+ + +
+ {{#each B.data.policyNames as |policyName|}} + {{#let (find-by "name" policyName this.model.policies) as |policy|}} + {{#if policy}} + + {{else}} + + {{/if}} + {{/let}} + {{else}} + {{#if (eq B.data.type "management")}} + Management Access + {{else}} + No Policies + {{/if}} + {{/each}} +
+
+ + {{#if (can "destroy token")}} + + {{#if (eq B.data.id this.selfToken.id)}} + + + + {{else}} + + {{/if}} + + {{/if}} + +
+ +
+ {{else}} +
+

+ No Tokens +

+

+ Get started by creating a new policy +

+
+ {{/if}} +
diff --git a/ui/app/templates/access-control/tokens/new.hbs b/ui/app/templates/access-control/tokens/new.hbs new file mode 100644 index 000000000..85c54292a --- /dev/null +++ b/ui/app/templates/access-control/tokens/new.hbs @@ -0,0 +1,17 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + +{{page-title "Create Token"}} +
+

+ Create Token +

+ +
diff --git a/ui/app/templates/access-control/tokens/token.hbs b/ui/app/templates/access-control/tokens/token.hbs new file mode 100644 index 000000000..d2d7ef667 --- /dev/null +++ b/ui/app/templates/access-control/tokens/token.hbs @@ -0,0 +1,24 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Token"}} +
+

+
+ Edit Token +
+ {{#if (can "destroy token")}} + + {{/if}} +

+ +
diff --git a/ui/app/templates/components/access-control-subnav.hbs b/ui/app/templates/components/access-control-subnav.hbs new file mode 100644 index 000000000..cb0bdcbc9 --- /dev/null +++ b/ui/app/templates/components/access-control-subnav.hbs @@ -0,0 +1,13 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
    +
  • Overview
  • +
  • Tokens
  • +
  • Roles
  • +
  • Policies
  • +
+
diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 421f5fe5c..507329834 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -135,16 +135,16 @@
  • - Policies + Access Control
  • {{/if}} diff --git a/ui/app/templates/jobs/job/variables.hbs b/ui/app/templates/jobs/job/variables.hbs index 4cd6a35ee..385f1b27c 100644 --- a/ui/app/templates/jobs/job/variables.hbs +++ b/ui/app/templates/jobs/job/variables.hbs @@ -12,7 +12,7 @@ Automatic Access to Variables -

    Tasks in this job can have automatic access to Nomad Variables.

    +

    Tasks in this job can have automatic access to Nomad Variables.

    • Use diff --git a/ui/app/templates/settings/tokens.hbs b/ui/app/templates/settings/tokens.hbs index fdd3cd69c..b0c7733ad 100644 --- a/ui/app/templates/settings/tokens.hbs +++ b/ui/app/templates/settings/tokens.hbs @@ -3,12 +3,18 @@ SPDX-License-Identifier: MPL-2.0 ~}} -{{page-title "Authorization"}} +{{page-title (if this.tokenRecord "Profile" "Sign In")}}
      {{#if this.isValidatingToken}} {{else}} -

      Authorization and access control

      +

      + {{#if this.tokenRecord}} + Profile + {{else}} + Sign In + {{/if}} +

      @@ -159,6 +165,31 @@
      + + {{#if this.tokenRecord.roles.length}} +

      Roles

      + {{#each this.tokenRecord.roles as |role|}} +
      +
      + {{role.name}} +
      +
      + {{#if role.description}} +

      + {{role.description}} +

      + {{/if}} +
      +

      Policies

      + {{#each role.policies as |policy|}} +
    • {{policy.name}}
    • + {{/each}} +
      +
      +
      + {{/each}} + {{/if}} +

      Policies

      {{#if (eq this.tokenRecord.type "management")}}
      @@ -167,8 +198,8 @@
      {{else}} - {{#each this.tokenRecord.policies as |policy|}} -
      + {{#each this.tokenRecord.combinedPolicies as |policy|}} +
      {{policy.name}}
      diff --git a/ui/mirage/config.js b/ui/mirage/config.js index fac9d3761..20f2e6729 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -299,8 +299,16 @@ export default function () { }); if (token) { - const { policyIds } = token; - const policies = server.db.policies.find(policyIds); + const policyIds = token.policyIds || []; + + const roleIds = token.roleIds || []; + const roles = server.db.roles.find(roleIds); + const rolePolicyIds = roles.map((role) => role.policyIds).flat(); + + const policies = server.db.policies.find([ + ...policyIds, + ...rolePolicyIds, + ]); const hasReadPolicy = policies.find( (p) => p.rulesJSON.Node?.Policy === 'read' || @@ -476,16 +484,59 @@ export default function () { }); this.post('/acl/token', function (schema, request) { - const { Name, Policies, Type } = JSON.parse(request.requestBody); + const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse( + request.requestBody + ); + + function parseDuration(duration) { + const [_, value, unit] = duration.match(/(\d+)(\w)/); + const unitMap = { + s: 1000, + m: 1000 * 60, + h: 1000 * 60 * 60, + d: 1000 * 60 * 60 * 24, + }; + return value * unitMap[unit]; + } + + // If there's an expirationTime, use that. Otherwise, use the TTL. + const expirationTime = ExpirationTime + ? new Date(ExpirationTime) + : ExpirationTTL + ? new Date(Date.now() + parseDuration(ExpirationTTL)) + : null; + return server.create('token', { name: Name, policyIds: Policies, type: Type, id: faker.random.uuid(), + expirationTime, createTime: new Date().toISOString(), }); }); + this.post('/acl/token/:id', function (schema, request) { + // If both Policies and Roles arrays are empty, return an error + const { Policies, Roles } = JSON.parse(request.requestBody); + if (!Policies.length && !Roles.length) { + return new Response( + 500, + {}, + 'Either Policies or Roles must be specified' + ); + } + return new Response( + 200, + {}, + { + id: request.params.id, + Policies, + Roles, + } + ); + }); + this.get('/acl/token/self', function ({ tokens }, req) { const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); @@ -557,7 +608,6 @@ export default function () { const policy = policies.findBy({ name: req.params.id }); const secret = req.requestHeaders['X-Nomad-Token']; const tokenForSecret = tokens.findBy({ secretId: secret }); - if (req.params.id === 'anonymous') { if (policy) { return this.serialize(policy); @@ -565,13 +615,15 @@ export default function () { return new Response(404, {}, null); } } - // Return the policy only if the token that matches the request header // includes the policy or if the token that matches the request header // is of type management if ( tokenForSecret && (tokenForSecret.policies.includes(policy) || + tokenForSecret.roles.models.any((role) => + role.policies.includes(policy) + ) || tokenForSecret.type === 'management') ) { return this.serialize(policy); @@ -581,21 +633,82 @@ export default function () { return new Response(403, {}, null); }); + this.get('/acl/roles', function ({ roles }, req) { + return this.serialize(roles.all()); + }); + + this.get('/acl/role/:id', function ({ roles }, req) { + const role = roles.findBy({ id: req.params.id }); + return this.serialize(role); + }); + + this.post('/acl/role', function (schema, request) { + const { Name, Description } = JSON.parse(request.requestBody); + return server.create('role', { + name: Name, + description: Description, + }); + }); + + this.put('/acl/role/:id', function (schema, request) { + const { Policies } = JSON.parse(request.requestBody); + if (!Policies.length) { + return new Response(500, {}, 'Policies must be specified'); + } + return new Response( + 200, + {}, + { + id: request.params.id, + Policies, + } + ); + }); + + this.delete('/acl/role/:id', function (schema, request) { + const { id } = request.params; + + // Also update any tokens whose policyIDs include this policy + const tokens = + server.schema.tokens.where((token) => token.roleIds?.includes(id)) || []; + tokens.models.forEach((token) => { + token.update({ + roleIds: token.roleIds.filter((roleId) => roleId !== id), + }); + }); + + server.db.roles.remove(id); + return ''; + }); + this.get('/acl/policies', function ({ policies }, req) { return this.serialize(policies.all()); }); this.delete('/acl/policy/:id', function (schema, request) { const { id } = request.params; - schema.tokens - .all() - .models.filter((token) => token.policyIds.includes(id)) - .forEach((token) => { - token.update({ - policyIds: token.policyIds.filter((pid) => pid !== id), - }); + + // Also update any tokens whose policyIDs include this policy + const tokens = + server.schema.tokens.where((token) => token.policyIds?.includes(id)) || + []; + tokens.models.forEach((token) => { + token.update({ + policyIds: token.policyIds.filter((policyId) => policyId !== id), }); + }); + + // Also update any roles whose policyIDs include this policy + const roles = + server.schema.roles.where((role) => role.policyIds?.includes(id)) || []; + roles.models.forEach((role) => { + role.update({ + policyIds: role.policyIds.filter((policyId) => policyId !== id), + }); + }); + server.db.policies.remove(id); + return ''; }); diff --git a/ui/mirage/factories/policy.js b/ui/mirage/factories/policy.js index 39a44312c..bdb3ab924 100644 --- a/ui/mirage/factories/policy.js +++ b/ui/mirage/factories/policy.js @@ -7,11 +7,17 @@ import { Factory } from 'ember-cli-mirage'; import faker from 'nomad-ui/mirage/faker'; export default Factory.extend({ - id: () => faker.hacker.verb(), + // Extra randomness appended to not conflict with the otherwise-uniq'd policies generated + // in factories.token.afterCreate + id: () => + `${faker.hacker.verb().replace(/\s/g, '-')}-${faker.random.alphaNumeric( + 5 + )}`, name() { return this.id; }, - description: () => (faker.random.number(10) >= 2 ? faker.lorem.sentence() : null), + description: () => + faker.random.number(10) >= 2 ? faker.lorem.sentence() : null, rules: `# Allow read only access to the default namespace namespace "default" { policy = "read" diff --git a/ui/mirage/factories/token.js b/ui/mirage/factories/token.js index a5d03fa1d..efbbee221 100644 --- a/ui/mirage/factories/token.js +++ b/ui/mirage/factories/token.js @@ -19,161 +19,166 @@ 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()) - .uniq(); + // If the user has neither policies, nor roles with policies, add some fake ones. + if ( + !(token.policyIds && token.policyIds.length) && + !(token.roles && token.roles.models.map((r) => r.policies).flat().length) + ) { + const policyIds = Array(faker.random.number({ min: 1, max: 5 })) + .fill(0) + .map(() => faker.hacker.verb().replace(/\s/g, '-')) + .uniq(); - policyIds.forEach((policy) => { - const dbPolicy = server.db.policies.find(policy); - if (!dbPolicy) { - server.create('policy', { id: policy }); - } - }); - - token.update({ policyIds }); - - // Create a special policy with variables rules in place - if (token.id === '53cur3-v4r14bl35') { - const variableMakerPolicy = { - id: 'Variable Maker', - rules: ` -# Allow read only access to the default namespace -namespace "*" { - policy = "read" - capabilities = ["list-jobs", "alloc-exec", "read-logs"] - variables { - # Base access is to all abilities for all variables - path "*" { - capabilities = ["list", "read", "destroy", "create"] - } - } -} - -node { - policy = "read" -} - `, - - rulesJSON: { - Namespaces: [ - { - Name: '*', - Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], - Variables: { - Paths: [ - { - Capabilities: ['write', 'read', 'destroy', 'list'], - PathSpec: '*', - }, - ], - }, - }, - ], - }, - }; - server.create('policy', variableMakerPolicy); - token.policyIds.push(variableMakerPolicy.id); - } - if (token.id === 'f3w3r-53cur3-v4r14bl35') { - const variableViewerPolicy = { - id: 'Variable Viewer', - rules: ` -# Allow read only access to the default namespace -namespace "*" { - policy = "read" - capabilities = ["list-jobs", "alloc-exec", "read-logs"] - variables { - # Base access is to all abilities for all variables - path "*" { - capabilities = ["list"] - } - } -} - -namespace "namespace-1" { - policy = "read" - capabilities = ["list-jobs", "alloc-exec", "read-logs"] - variables { - # Base access is to all abilities for all variables - path "*" { - capabilities = ["list", "read", "destroy", "create"] - } - } -} - -namespace "namespace-2" { - policy = "read" - capabilities = ["list-jobs", "alloc-exec", "read-logs"] - variables { - # Base access is to all abilities for all variables - path "blue/*" { - capabilities = ["list", "read", "destroy", "create"] - } - path "nomad/jobs/*" { - capabilities = ["list", "read", "create"] - } - } -} - -node { - policy = "read" -} - `, - - rulesJSON: { - Namespaces: [ - { - Name: '*', - Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], - Variables: { - Paths: [ - { - Capabilities: ['list'], - PathSpec: '*', - }, - ], - }, - }, - { - Name: 'namespace-1', - Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], - Variables: { - Paths: [ - { - Capabilities: ['list', 'read', 'destroy', 'create'], - PathSpec: '*', - }, - ], - }, - }, - { - Name: 'namespace-2', - Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], - Variables: { - Paths: [ - { - Capabilities: ['list', 'read', 'destroy', 'create'], - PathSpec: 'blue/*', - }, - { - Capabilities: ['list', 'read', 'create'], - PathSpec: 'nomad/jobs/*', - }, - ], - }, - }, - ], - }, - }; - server.create('policy', variableViewerPolicy); - token.policyIds.push(variableViewerPolicy.id); - } - if (token.id === '3XP1R35-1N-3L3V3N-M1NU735') { - token.update({ - expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000), + policyIds.forEach((policy) => { + const dbPolicy = server.db.policies.find(policy); + if (!dbPolicy) { + server.create('policy', { id: policy }); + } }); + + token.update({ policyIds }); + + // Create a special policy with variables rules in place + if (token.id === '53cur3-v4r14bl35') { + const variableMakerPolicy = { + id: 'Variable-Maker', + rules: ` + # Allow read only access to the default namespace + namespace "*" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + variables { + # Base access is to all abilities for all variables + path "*" { + capabilities = ["list", "read", "destroy", "create"] + } + } + } + + node { + policy = "read" + } + `, + + rulesJSON: { + Namespaces: [ + { + Name: '*', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + Variables: { + Paths: [ + { + Capabilities: ['write', 'read', 'destroy', 'list'], + PathSpec: '*', + }, + ], + }, + }, + ], + }, + }; + server.create('policy', variableMakerPolicy); + token.policyIds.push(variableMakerPolicy.id); + } + if (token.id === 'f3w3r-53cur3-v4r14bl35') { + const variableViewerPolicy = { + id: 'Variable-Viewer', + rules: ` + # Allow read only access to the default namespace + namespace "*" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + variables { + # Base access is to all abilities for all variables + path "*" { + capabilities = ["list"] + } + } + } + + namespace "namespace-1" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + variables { + # Base access is to all abilities for all variables + path "*" { + capabilities = ["list", "read", "destroy", "create"] + } + } + } + + namespace "namespace-2" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + variables { + # Base access is to all abilities for all variables + path "blue/*" { + capabilities = ["list", "read", "destroy", "create"] + } + path "nomad/jobs/*" { + capabilities = ["list", "read", "create"] + } + } + } + + node { + policy = "read" + } + `, + + rulesJSON: { + Namespaces: [ + { + Name: '*', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + Variables: { + Paths: [ + { + Capabilities: ['list'], + PathSpec: '*', + }, + ], + }, + }, + { + Name: 'namespace-1', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + Variables: { + Paths: [ + { + Capabilities: ['list', 'read', 'destroy', 'create'], + PathSpec: '*', + }, + ], + }, + }, + { + Name: 'namespace-2', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + Variables: { + Paths: [ + { + Capabilities: ['list', 'read', 'destroy', 'create'], + PathSpec: 'blue/*', + }, + { + Capabilities: ['list', 'read', 'create'], + PathSpec: 'nomad/jobs/*', + }, + ], + }, + }, + ], + }, + }; + server.create('policy', variableViewerPolicy); + token.policyIds.push(variableViewerPolicy.id); + } + if (token.id === '3XP1R35-1N-3L3V3N-M1NU735') { + token.update({ + expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000), + }); + } } }, }); diff --git a/ui/mirage/models/token.js b/ui/mirage/models/token.js new file mode 100644 index 000000000..c3ea6d51d --- /dev/null +++ b/ui/mirage/models/token.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { Model, belongsTo, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + policies: hasMany(), + roles: hasMany(), +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 58c0ed14b..b9316ceed 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -26,6 +26,7 @@ export const allScenarios = { variableTestCluster, servicesTestCluster, policiesTestCluster, + rolesTestCluster, ...topoScenarios, ...sysbatchScenarios, }; @@ -96,6 +97,102 @@ function smallCluster(server) { activeDeployment: true, }); + server.create('policy', { + id: 'client-reader', + name: 'client-reader', + description: "Can read nodes and that's about it", + rulesJSON: { + Node: { + Policy: 'read', + }, + }, + rules: `# Allow node read access`, + }); + + server.create('policy', { + id: 'client-writer', + name: 'client-writer', + description: 'Can write to nodes', + rulesJSON: { + Node: { + Policy: 'write', + }, + }, + rules: `# Allow node write access`, + }); + + server.create('policy', { + id: 'job-reader', + name: 'job-reader', + description: "Can read jobs and that's about it", + rulesJSON: { + namespace: { + '*': { + policy: 'read', + }, + }, + }, + rules: `# Job read access`, + }); + + server.create('policy', { + id: 'job-writer', + name: 'job-writer', + description: 'Can write jobs', + rulesJSON: { + Namespaces: [ + { + Name: '*', + Policy: '', + Capabilities: ['submit-job'], + Variables: null, + }, + ], + }, + rules: `# Job write access`, + }); + + server.create('policy', { + id: 'variable-lister', + name: 'variable-lister', + description: 'Can list variables', + rulesJSON: { + namespace: { + '*': { + variables: { + path: { + capabilities: ['list'], + pathspec: '*', + }, + }, + }, + }, + }, + rules: `# Variable list access`, + }); + + server.create('role', { + id: 'operator', + name: 'operator', + description: 'Can operate', + policyIds: ['client-reader', 'client-writer', 'job-reader', 'job-writer'], + }); + + server.create('role', { + id: 'sysadmin', + name: 'sysadmin', + description: 'Can modify nodes', + policyIds: ['client-reader', 'client-writer'], + }); + + server.create('token', { + type: 'client', + name: 'Tiarna Riarthóir', + id: 'administrator-token', + roleIds: ['operator', 'sysadmin'], + policyIds: ['variable-lister'], + }); + //#region Active Deployment const activelyDeployingJobGroups = 2; @@ -439,6 +536,218 @@ function policiesTestCluster(server) { server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); } +function rolesTestCluster(server) { + faker.seed(1); + + server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); + server.createList('node-pool', 2); + server.createList('node', 5); + server.createList('job', 5); + + // createTokens(server); + + // Create policies + const clientReaderPolicy = server.create('policy', { + id: 'client-reader', + name: 'client-reader', + description: "Can read nodes and that's about it", + rulesJSON: { + Node: { + Policy: 'read', + }, + }, + }); + + const clientWriterPolicy = server.create('policy', { + id: 'client-writer', + name: 'client-writer', + description: 'Can write to nodes', + rulesJSON: { + Node: { + Policy: 'write', + }, + }, + }); + + const clientDenierPolicy = server.create('policy', { + id: 'client-denier', + name: 'client-denier', + description: "Can't do anything with Clients", + rulesJSON: { + Node: { + Policy: 'deny', + }, + }, + }); + + const jobDenierPolicy = server.create('policy', { + id: 'job-denier', + name: 'job-denier', + description: "Can't do anything with Jobs", + rulesJSON: { + namespace: { + '*': { + policy: 'deny', + }, + }, + }, + }); + + const operatorPolicy = server.create('policy', { + id: 'operator', + name: 'operator', + description: 'Can operate', + rulesJSON: { + operator: { + policy: 'write', + }, + }, + }); + + const jobReaderPolicy = server.create('policy', { + id: 'job-reader', + name: 'job-reader', + description: 'Can learn about jobs', + rulesJSON: { + namespace: { + '*': { + policy: 'read', + }, + }, + }, + }); + + const highLevelJobPolicy = server.create('policy', { + id: 'job-writer', + name: 'job-writer', + description: 'Can do lots with jobs', + rulesJSON: { + Namespaces: [ + { + Name: '*', + Policy: '', + Capabilities: ['submit-job'], + Variables: null, + }, + ], + }, + }); + + // Create roles + const editorRole = server.create('role', { + id: 'editor', + name: 'editor', + description: 'Can edit things', + policyIds: [clientWriterPolicy.id], + }); + + const highLevelRole = server.create('role', { + id: 'high-level', + name: 'high-level', + description: 'Can do lots of things', + policyIds: [highLevelJobPolicy.id], + }); + + const readerRole = server.create('role', { + id: 'reader', + name: 'reader', + description: 'Can read things', + policyIds: [clientReaderPolicy.id, jobReaderPolicy.id], + }); + + const denierRole = server.create('role', { + id: 'denier', + name: 'denier', + description: "Can't do anything", + policyIds: [clientDenierPolicy.id, jobDenierPolicy.id], + }); + + // Create tokens + + let managementToken = server.create('token', { + type: 'management', + name: 'Management Token', + }); + + let clientReaderToken = server.create('token', { + type: 'client', + name: "N. O'DeReader", + policyIds: [clientReaderPolicy.id], + }); + + let clientWriterToken = server.create('token', { + type: 'client', + name: "N. O'DeWriter", + policyIds: [clientWriterPolicy.id], + }); + + let dualPolicyToken = server.create('token', { + type: 'client', + name: 'Multi-policy Token', + policyIds: [clientReaderPolicy.id, clientWriterPolicy.id], + }); + + let highLevelViaPolicyToken = server.create('token', { + type: 'client', + name: 'High Level Policy Token', + policyIds: [highLevelJobPolicy.id], + }); + + let highLevelViaRoleToken = server.create('token', { + type: 'client', + name: 'High Level Role Token', + roleIds: [highLevelRole.id], + }); + + let policyAndRoleToken = server.create('token', { + type: 'client', + name: 'Policy And Role Token', + policyIds: [operatorPolicy.id], + roleIds: [readerRole.id], + }); + + let multiRoleToken = server.create('token', { + type: 'client', + name: 'Multi Role Token', + roleIds: [editorRole.id, highLevelRole.id], + }); + + let multiRoleAndPolicyToken = server.create('token', { + type: 'client', + name: 'Multi Role And Policy Token', + roleIds: [editorRole.id, highLevelRole.id], + policyIds: [clientWriterPolicy.id], // also included within editorRole, so redundant here. + }); + + let noClientsViaPolicyToken = server.create('token', { + type: 'client', + name: 'Clientless Policy Token', + policyIds: [clientDenierPolicy.id], + }); + + let noClientsViaRoleToken = server.create('token', { + type: 'client', + name: 'Clientless Role Token', + roleIds: [denierRole.id], + }); + + // malleable test token + server.create('token', { + name: 'Clay-Token', + id: 'cl4y-t0k3n', + type: 'client', + policyIds: [clientReaderPolicy.id, operatorPolicy.id], + roleIds: [editorRole.id], + expirationTime: new Date(new Date().getTime() + 60 * 60 * 1000), + }); + + logTokens(server); + + server.create('auth-method', { name: 'vault' }); + + server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); +} + function servicesTestCluster(server) { faker.seed(1); server.create('feature', { name: 'Dynamic Application Sizing' }); diff --git a/ui/mirage/serializers/role.js b/ui/mirage/serializers/role.js new file mode 100644 index 000000000..eb906c034 --- /dev/null +++ b/ui/mirage/serializers/role.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + serializeIds: 'always', + + serialize() { + var json = ApplicationSerializer.prototype.serialize.apply(this, arguments); + if (json instanceof Array) { + json.forEach(serializeRole); + } else { + serializeRole(json); + } + return json; + }, +}); + +function serializeRole(role) { + role.Policies = (role.Policies || []).map((policy) => { + return { ID: policy, Name: policy }; + }); + delete role.PolicyIDs; + return role; +} diff --git a/ui/mirage/serializers/token.js b/ui/mirage/serializers/token.js index b1b597528..0dff1c96c 100644 --- a/ui/mirage/serializers/token.js +++ b/ui/mirage/serializers/token.js @@ -12,6 +12,29 @@ export default ApplicationSerializer.extend({ if (relationship === 'policies') { return 'Policies'; } - return ApplicationSerializer.prototype.keyForRelationshipIds.apply(this, arguments); + if (relationship === 'roles') { + return 'Roles'; + } + return ApplicationSerializer.prototype.keyForRelationshipIds.apply( + this, + arguments + ); + }, + + serialize() { + var json = ApplicationSerializer.prototype.serialize.apply(this, arguments); + if (json instanceof Array) { + json.forEach(serializeToken); + } else { + serializeToken(json); + } + return json; }, }); + +function serializeToken(token) { + token.Roles = (token.Roles || []).map((role) => { + return { ID: role, Name: role }; + }); + return token; +} diff --git a/ui/package.json b/ui/package.json index 9072a8bea..247a225b0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -87,7 +87,7 @@ "ember-cli-sri": "^2.1.1", "ember-cli-string-helpers": "^6.1.0", "ember-cli-terser": "^4.0.2", - "ember-click-outside": "^3.0.0", + "ember-click-outside": "^5.0.0", "ember-composable-helpers": "^5.0.0", "ember-concurrency": "^2.2.1", "ember-copy": "^2.0.1", @@ -178,7 +178,7 @@ }, "dependencies": { "@babel/helper-string-parser": "^7.19.4", - "@hashicorp/design-system-components": "^2.6.0", + "@hashicorp/design-system-components": "^2.12.0", "@hashicorp/ember-flight-icons": "^3.0.4", "@percy/cli": "^1.6.1", "@percy/ember": "^3.0.0", diff --git a/ui/tests/acceptance/access-control-test.js b/ui/tests/acceptance/access-control-test.js new file mode 100644 index 000000000..82e00eb5f --- /dev/null +++ b/ui/tests/acceptance/access-control-test.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { currentURL, triggerKeyEvent } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import AccessControl from 'nomad-ui/tests/pages/access-control'; +import Tokens from 'nomad-ui/tests/pages/settings/tokens'; +import { allScenarios } from '../../mirage/scenarios/default'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; + +// Several related tests within Access Control are contained in the Tokens, Roles, +// and Policies acceptance tests. + +module('Acceptance | access control', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + window.localStorage.clear(); + window.sessionStorage.clear(); + // server.create('token'); + allScenarios.rolesTestCluster(server); + }); + + test('Access Control is only accessible by a management user', async function (assert) { + assert.expect(7); + await AccessControl.visit(); + + assert.equal( + currentURL(), + '/jobs', + 'redirected to the jobs page if a non-management token on /access-control' + ); + + await AccessControl.visitTokens(); + assert.equal( + currentURL(), + '/jobs', + 'redirected to the jobs page if a non-management token on /tokens' + ); + + assert.dom('[data-test-gutter-link="access-control"]').doesNotExist(); + + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + + assert.dom('[data-test-gutter-link="access-control"]').exists(); + + await AccessControl.visit(); + assert.equal( + currentURL(), + '/access-control', + 'management token can access /access-control' + ); + + await a11yAudit(assert); + + await AccessControl.visitTokens(); + assert.equal( + currentURL(), + '/access-control/tokens', + 'management token can access /access-control/tokens' + ); + }); + + test('Access control index content', async function (assert) { + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + + await AccessControl.visit(); + assert.dom('[data-test-tokens-card]').exists(); + assert.dom('[data-test-roles-card]').exists(); + assert.dom('[data-test-policies-card]').exists(); + + const numberOfTokens = server.db.tokens.length; + const numberOfRoles = server.db.roles.length; + const numberOfPolicies = server.db.policies.length; + + assert + .dom('[data-test-tokens-card] a') + .includesText(`${numberOfTokens} Tokens`); + assert + .dom('[data-test-roles-card] a') + .includesText(`${numberOfRoles} Roles`); + assert + .dom('[data-test-policies-card] a') + .includesText(`${numberOfPolicies} Policies`); + }); + + test('Access control subnav', async function (assert) { + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + + await AccessControl.visit(); + + assert.equal(currentURL(), '/access-control'); + + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { + shiftKey: true, + }); + assert.equal( + currentURL(), + `/access-control/tokens`, + 'Shift+ArrowRight takes you to the next tab (Tokens)' + ); + + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { + shiftKey: true, + }); + assert.equal( + currentURL(), + `/access-control/roles`, + 'Shift+ArrowRight takes you to the next tab (Roles)' + ); + + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { + shiftKey: true, + }); + assert.equal( + currentURL(), + `/access-control/policies`, + 'Shift+ArrowRight takes you to the next tab (Policies)' + ); + + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { + shiftKey: true, + }); + assert.equal( + currentURL(), + `/access-control`, + 'Shift+ArrowLeft takes you back to the Access Control index page' + ); + }); +}); diff --git a/ui/tests/acceptance/global-header-test.js b/ui/tests/acceptance/global-header-test.js index b34a0927c..e38c9bdfa 100644 --- a/ui/tests/acceptance/global-header-test.js +++ b/ui/tests/acceptance/global-header-test.js @@ -74,7 +74,7 @@ module('Acceptance | global header', function (hooks) { assert.false(Layout.navbar.end.signInLink.isVisible); await Layout.navbar.end.profileDropdown.open(); - await click('.dropdown-options .ember-power-select-option:nth-child(1)'); + await click('[data-test-profile-dropdown-profile-link]'); assert.equal( currentURL(), '/settings/tokens', @@ -82,7 +82,7 @@ module('Acceptance | global header', function (hooks) { ); await Layout.navbar.end.profileDropdown.open(); - await click('.dropdown-options .ember-power-select-option:nth-child(2)'); + await click('[data-test-profile-dropdown-sign-out-link]'); assert.equal(window.localStorage.nomadTokenSecret, null, 'Token is wiped'); assert.equal(currentURL(), '/jobs', 'After signout, back on the jobs page'); }); diff --git a/ui/tests/acceptance/policies-test.js b/ui/tests/acceptance/policies-test.js index 73222741c..bc1d839a3 100644 --- a/ui/tests/acceptance/policies-test.js +++ b/ui/tests/acceptance/policies-test.js @@ -19,9 +19,9 @@ module('Acceptance | policies', function (hooks) { assert.expect(4); allScenarios.policiesTestCluster(server); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; - await visit('/policies'); - assert.dom('[data-test-gutter-link="policies"]').exists(); - assert.equal(currentURL(), '/policies'); + await visit('/access-control/policies'); + assert.dom('[data-test-gutter-link="access-control"]').exists(); + assert.equal(currentURL(), '/access-control/policies'); assert .dom('[data-test-policy-row]') .exists({ count: server.db.policies.length }); @@ -34,9 +34,9 @@ module('Acceptance | policies', function (hooks) { test('Prevents policies access if you lack a management token', async function (assert) { allScenarios.policiesTestCluster(server); window.localStorage.nomadTokenSecret = server.db.tokens[1].secretId; - await visit('/policies'); + await visit('/access-control/policies'); assert.equal(currentURL(), '/jobs'); - assert.dom('[data-test-gutter-link="policies"]').doesNotExist(); + assert.dom('[data-test-gutter-link="access-control"]').doesNotExist(); // Reset Token window.localStorage.nomadTokenSecret = null; }); @@ -44,51 +44,80 @@ module('Acceptance | policies', function (hooks) { test('Modifying an existing policy', 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}`); + await visit('/access-control/policies'); + await click('[data-test-policy-row]:first-child a'); + // Table sorts by name by default + let firstPolicy = server.db.policies.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; + assert.equal(currentURL(), `/access-control/policies/${firstPolicy.name}`); assert.dom('[data-test-policy-editor]').exists(); - assert.dom('[data-test-title]').includesText(server.db.policies[0].name); - await click('button[type="submit"]'); + assert.dom('[data-test-title]').includesText(firstPolicy.name); + await click('button[data-test-save-policy]'); assert.dom('.flash-message.alert-success').exists(); assert.equal( currentURL(), - `/policies/${server.db.policies[0].name}`, + `/access-control/policies/${firstPolicy.name}`, 'remain on page after save' ); // Reset Token window.localStorage.nomadTokenSecret = null; }); + test('Creating a test token', async function (assert) { + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/policies'); + await click('[data-test-policy-name="Variable-Maker"]'); + assert.equal(currentURL(), '/access-control/policies/Variable-Maker'); + await click('[data-test-create-test-token]'); + assert.dom('.flash-message.alert-success').exists(); + assert + .dom('[data-test-token-name="Example Token for Variable-Maker"]') + .exists('Test token is created and visible'); + const newTokenRow = [ + ...findAll('[data-test-token-name="Example Token for Variable-Maker"]'), + ][0].parentElement; + const newTokenDeleteButton = newTokenRow.querySelector( + '[data-test-delete-token-button]' + ); + await click(newTokenDeleteButton); + assert + .dom('[data-test-token-name="Example Token for Variable-Maker"]') + .doesNotExist('Token is deleted'); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + test('Creating a new policy', async function (assert) { assert.expect(7); allScenarios.policiesTestCluster(server); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; - await visit('/policies'); + await visit('/access-control/policies'); await click('[data-test-create-policy]'); - assert.equal(currentURL(), '/policies/new'); + assert.equal(currentURL(), '/access-control/policies/new'); await typeIn('[data-test-policy-name-input]', 'My Fun Policy'); - await click('button[type="submit"]'); + await click('button[data-test-save-policy]'); assert .dom('.flash-message.alert-critical') .exists('Doesnt let you save a bad name'); - assert.equal(currentURL(), '/policies/new'); + assert.equal(currentURL(), '/access-control/policies/new'); document.querySelector('[data-test-policy-name-input]').value = ''; // clear await typeIn('[data-test-policy-name-input]', 'My-Fun-Policy'); - await click('button[type="submit"]'); + await click('button[data-test-save-policy]'); assert.dom('.flash-message.alert-success').exists(); assert.equal( currentURL(), - '/policies/My-Fun-Policy', + '/access-control/policies/My-Fun-Policy', 'redirected to the now-created policy' ); - await visit('/policies'); + await visit('/access-control/policies'); const newPolicy = [...findAll('[data-test-policy-name]')].filter((a) => a.textContent.includes('My-Fun-Policy') )[0]; assert.ok(newPolicy, 'Policy is in the list'); await click(newPolicy); - assert.equal(currentURL(), '/policies/My-Fun-Policy'); + assert.equal(currentURL(), '/access-control/policies/My-Fun-Policy'); await percySnapshot(assert); // Reset Token window.localStorage.nomadTokenSecret = null; @@ -97,20 +126,44 @@ module('Acceptance | policies', function (hooks) { test('Deleting a policy', async function (assert) { allScenarios.policiesTestCluster(server); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; - await visit('/policies'); - const firstPolicyName = server.db.policies[0].name; - const firstPolicyRow = [...findAll('[data-test-policy-name]')].filter( + await visit('/access-control/policies'); + let firstPolicy = server.db.policies.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; + + const firstPolicyName = firstPolicy.name; + const firstPolicyLink = [...findAll('[data-test-policy-name]')].filter( (row) => row.textContent.includes(firstPolicyName) )[0]; - await click(firstPolicyRow); - assert.equal(currentURL(), `/policies/${firstPolicyName}`); - await click('[data-test-delete-button] button'); - assert.dom('[data-test-confirm-button]').exists(); - await click('[data-test-confirm-button]'); + await click(firstPolicyLink); + assert.equal(currentURL(), `/access-control/policies/${firstPolicyName}`); + await click('[data-test-delete-policy]'); assert.dom('.flash-message.alert-success').exists(); - assert.equal(currentURL(), '/policies'); + assert.equal(currentURL(), '/access-control/policies'); assert.dom(`[data-test-policy-name="${firstPolicyName}"]`).doesNotExist(); // Reset Token window.localStorage.nomadTokenSecret = null; }); + + test('Policies Index', async function (assert) { + allScenarios.policiesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/policies'); + // Table contains every policy in db + assert + .dom('[data-test-policy-row]') + .exists({ count: server.db.policies.length }); + + assert.dom('[data-test-empty-policies-list-headline]').doesNotExist(); + + // Deleting all policies results in a message + const policyRows = findAll('[data-test-policy-row]'); + for (const row of policyRows) { + const deleteButton = row.querySelector('[data-test-delete-policy]'); + await click(deleteButton); + } + assert.dom('[data-test-empty-policies-list-headline]').exists(); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); }); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js index 39dd64f1e..d565ec667 100644 --- a/ui/tests/acceptance/regions-test.js +++ b/ui/tests/acceptance/regions-test.js @@ -194,29 +194,34 @@ module('Acceptance | regions (many)', function (hooks) { await JobsList.jobs.objectAt(0).clickRow(); await Layout.gutter.visitClients(); await Layout.gutter.visitServers(); - const [ - , - , - , - // License request - // Token/policies request - // Search feature detection - regionsRequest, - defaultRegionRequest, - ...appRequests - ] = server.pretender.handledRequests; + + const regionsRequest = server.pretender.handledRequests.find((req) => + req.responseURL.includes('/v1/regions') + ); + const licenseRequest = server.pretender.handledRequests.find((req) => + req.responseURL.includes('/v1/operator/license') + ); + const appRequests = server.pretender.handledRequests.filter( + (req) => + !req.responseURL.includes('/v1/regions') && + !req.responseURL.includes('/v1/operator/license') + ); assert.notOk( regionsRequest.url.includes('region='), 'The regions request is made without a region qp' ); assert.notOk( - defaultRegionRequest.url.includes('region='), + licenseRequest.url.includes('region='), 'The default region request is made without a region qp' ); appRequests.forEach((req) => { - if (req.url === '/v1/agent/self') { + if ( + req.url === '/v1/agent/self' || + req.url === '/v1/acl/token/self' || + req.url === '/v1/agent/members' + ) { assert.notOk(req.url.includes('region='), `(no region) ${req.url}`); } else { assert.ok(req.url.includes(`region=${region}`), req.url); diff --git a/ui/tests/acceptance/roles-test.js b/ui/tests/acceptance/roles-test.js new file mode 100644 index 000000000..5a62990a9 --- /dev/null +++ b/ui/tests/acceptance/roles-test.js @@ -0,0 +1,295 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { findAll, fillIn, find, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import { allScenarios } from '../../mirage/scenarios/default'; +import Tokens from 'nomad-ui/tests/pages/settings/tokens'; +import AccessControl from 'nomad-ui/tests/pages/access-control'; +import percySnapshot from '@percy/ember'; + +module('Acceptance | roles', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function () { + window.localStorage.clear(); + window.sessionStorage.clear(); + allScenarios.rolesTestCluster(server); + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + await AccessControl.visitRoles(); + }); + + hooks.afterEach(async function () { + await Tokens.visit(); + await Tokens.clear(); + }); + + test('Roles index, general', async function (assert) { + assert.expect(3); + await a11yAudit(assert); + + assert.equal(currentURL(), '/access-control/roles'); + + assert + .dom('[data-test-role-row]') + .exists({ count: server.db.roles.length }); + + await percySnapshot(assert); + }); + + test('Roles index: deletion', async function (assert) { + // Delete every role + assert + .dom('[data-test-empty-role-list-headline]') + .doesNotExist('no empty state'); + const roleRows = findAll('[data-test-role-row]'); + for (const row of roleRows) { + const deleteButton = row.querySelector('[data-test-delete-role]'); + await click(deleteButton); + } + // there should be as many success messages as there were roles + assert + .dom('.flash-message.alert-success') + .exists({ count: roleRows.length }); + + assert.dom('[data-test-empty-role-list-headline]').exists('empty state'); + }); + + test('Roles have policies lists', async function (assert) { + const role = server.db.roles.findBy((r) => r.name === 'reader'); + const roleRow = find(`[data-test-role-row="${role.name}"]`); + const rolePoliciesCell = roleRow.querySelector('[data-test-role-policies]'); + const policiesCellTags = rolePoliciesCell + .querySelector('.tag-group') + .querySelectorAll('span'); + assert.equal(policiesCellTags.length, 2); + assert.equal(policiesCellTags[0].textContent.trim(), 'client-reader'); + assert.equal(policiesCellTags[1].textContent.trim(), 'job-reader'); + + await click(policiesCellTags[0].querySelector('a')); + assert.equal(currentURL(), '/access-control/policies/client-reader'); + assert.dom('[data-test-title]').containsText('client-reader'); + }); + + test('Edit Role: Name and Description', async function (assert) { + assert.expect(8); + const role = server.db.roles.findBy((r) => r.name === 'reader'); + await click('[data-test-role-name="reader"] a'); + assert.equal(currentURL(), `/access-control/roles/${role.id}`); + + assert.dom('[data-test-role-name-input]').hasValue(role.name); + assert.dom('[data-test-role-description-input]').hasValue(role.description); + assert.dom('[data-test-role-policies]').exists(); + + // Modify the name and description + await fillIn('[data-test-role-name-input]', 'reader-edited'); + await fillIn('[data-test-role-description-input]', 'edited description'); + await click('button[data-test-save-role]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal( + currentURL(), + `/access-control/roles/${role.name}`, + 'remain on page after save' + ); + await percySnapshot(assert); + + // Go back to the roles index + await AccessControl.visitRoles(); + let readerRoleRow = find('[data-test-role-row="reader-edited"]'); + assert.dom(readerRoleRow).exists(); + assert.equal( + readerRoleRow + .querySelector('[data-test-role-description]') + .textContent.trim(), + 'edited description' + ); + }); + + test('Edit Role: Policies', async function (assert) { + const role = server.db.roles.findBy((r) => r.name === 'reader'); + await click('[data-test-role-name="reader"] a'); + assert.equal(currentURL(), `/access-control/roles/${role.id}`); + + // Policies table is sortable + + const nameCells = findAll('[data-test-policy-name]'); + const nameCellText = nameCells.map((cell) => cell.textContent.trim()); + const sortedNameCellText = nameCellText.slice().sort(); + assert.deepEqual( + nameCellText, + sortedNameCellText, + 'Policy names are sorted alphabetically' + ); + + // Click on the second thead tr th to reverse + assert + .dom('table[data-test-role-policies] thead tr th:nth-child(2)') + .hasAttribute('aria-sort', 'ascending'); + // await click('table[data-test-role-policies] thead tr th:nth-child(2)'); + // above didnt work, another way? + await click('[data-test-role-policies] thead tr th:nth-child(2) button'); + assert + .dom('table[data-test-role-policies] thead tr th:nth-child(2)') + .hasAttribute('aria-sort', 'descending'); + + const reversedNameCells = findAll('[data-test-policy-name]'); + const reversedNameCellText = reversedNameCells.map((cell) => + cell.textContent.trim() + ); + const reversedSortedNameCellText = nameCellText.slice().sort().reverse(); + + assert.deepEqual( + reversedNameCellText, + reversedSortedNameCellText, + 'Names are reversed alphabetically after click' + ); + + // Make sure the correct policies are checked + const rolePolicies = role.policyIds; + // All possible policies are shown + const allPolicies = server.db.policies; + assert.equal( + findAll('[data-test-role-policies] tbody tr').length, + allPolicies.length, + 'all policies are shown' + ); + + const checkedPolicyRows = findAll( + '[data-test-role-policies] tbody tr input:checked' + ); + + assert.equal( + checkedPolicyRows.length, + rolePolicies.length, + 'correct number of policies are checked' + ); + + const checkedPolicyNames = checkedPolicyRows.map((row) => + row + .closest('tr') + .querySelector('[data-test-policy-name]') + .textContent.trim() + ); + + assert.deepEqual( + checkedPolicyNames.sort(), + rolePolicies.sort(), + 'All policies belonging to this role are checked' + ); + + // Try de-selecting all policies and saving + checkedPolicyRows.forEach((row) => row.click()); + await click('button[data-test-save-role]'); + assert + .dom('.flash-message.alert-critical') + .exists('Doesnt let you save with no policies selected'); + + // Check all policies + findAll('[data-test-role-policies] tbody tr input').forEach((row) => + row.click() + ); + await click('button[data-test-save-role]'); + assert.dom('.flash-message.alert-success').exists(); + + await AccessControl.visitRoles(); + const readerRoleRow = find('[data-test-role-row="reader"]'); + const readerRolePolicies = readerRoleRow + .querySelector('[data-test-role-policies]') + .querySelectorAll('span'); + assert.equal( + readerRolePolicies.length, + allPolicies.length, + 'all policies are attached to the role at index level' + ); + }); + + test('Edit Role: Tokens', async function (assert) { + assert.expect(10); + const role = server.db.roles.findBy((r) => r.name === 'reader'); + + await click('[data-test-role-name="reader"] a'); + assert.equal(currentURL(), `/access-control/roles/${role.id}`); + assert.dom('table.tokens').exists(); + + // "Reader" role has a single token with it applied by default + assert.dom('[data-test-role-token-row]').exists({ count: 1 }); + + // Delete it; should get a nice No Tokens message + await click('[data-test-delete-token-button]'); + assert.dom('.flash-message.alert-success').exists(); + assert.dom('[data-test-role-token-row]').doesNotExist(); + assert.dom('[data-test-empty-role-list-headline]').exists(); + // Create two test tokens + await click('[data-test-create-test-token]'); + assert.dom('[data-test-empty-role-list-headline]').doesNotExist(); + await click('[data-test-create-test-token]'); + assert + .dom('[data-test-role-token-row]') + .exists({ count: 2 }, 'Test tokens are included on the page'); + assert + .dom('[data-test-role-token-row]:last-child [data-test-token-name]') + .hasText(`Example Token for ${role.name}`); + + await percySnapshot(assert); + + await AccessControl.visitTokens(); + assert + .dom('[data-test-token-name="Example Token for reader"]') + .exists( + { count: 2 }, + 'The two newly-created tokens are listed on the tokens index page' + ); + }); + test('Edit Role: Deletion', async function (assert) { + const role = server.db.roles.findBy((r) => r.name === 'reader'); + await click('[data-test-role-name="reader"] a'); + assert.equal(currentURL(), `/access-control/roles/${role.id}`); + await click('[data-test-delete-role]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal(currentURL(), '/access-control/roles'); + assert.dom('[data-test-role-row="reader"]').doesNotExist(); + }); + test('New Role', async function (assert) { + await click('[data-test-create-role]'); + assert.equal(currentURL(), '/access-control/roles/new'); + await fillIn('[data-test-role-name-input]', 'test-role'); + await click('button[data-test-save-role]'); + assert + .dom('.flash-message.alert-critical') + .exists('Cannnot save with no policies selected'); + + // Select a policy + await click('[data-test-role-policies] tbody tr input'); + await click('button[data-test-save-role]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal(currentURL(), '/access-control/roles/1'); // default id created via mirage + await AccessControl.visitRoles(); + assert.dom('[data-test-role-row="test-role"]').exists(); + + // Now, try deleting all policies then doing this again. There'll be a warning on the roles/new page. + await AccessControl.visitPolicies(); + const policyRows = findAll('[data-test-policy-row]'); + for (const row of policyRows) { + const deleteButton = row.querySelector('[data-test-delete-policy]'); + await click(deleteButton); + } + assert.dom('[data-test-empty-policies-list-headline]').exists(); + await AccessControl.visitRoles(); + await click('[data-test-create-role]'); + assert.dom('.empty-message').exists(); + assert + .dom('.empty-message-body') + .containsText('At least one Policy is required to create a Role'); + }); +}); diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index e6ce82838..83832a169 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -4,7 +4,14 @@ */ /* eslint-disable qunit/require-expect */ -import { currentURL, find, findAll, visit, click } from '@ember/test-helpers'; +import { + currentURL, + find, + findAll, + visit, + click, + fillIn, +} from '@ember/test-helpers'; import { module, skip, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -14,6 +21,7 @@ import Jobs from 'nomad-ui/tests/pages/jobs/list'; import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; import ClientDetail from 'nomad-ui/tests/pages/clients/detail'; import Layout from 'nomad-ui/tests/pages/layout'; +import AccessControl from 'nomad-ui/tests/pages/access-control'; import percySnapshot from '@percy/ember'; import faker from 'nomad-ui/mirage/faker'; import moment from 'moment'; @@ -28,6 +36,7 @@ let job; let node; let managementToken; let clientToken; + module('Acceptance | tokens', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -61,7 +70,7 @@ module('Acceptance | tokens', function (hooks) { null, 'No token secret set' ); - assert.ok(document.title.includes('Authorization')); + assert.ok(document.title.includes('Sign In')); await Tokens.secret(secretId).submit(); assert.equal( @@ -567,7 +576,6 @@ module('Acceptance | tokens', function (hooks) { assert.dom('.dropdown-options').exists('Dropdown options are shown'); await selectChoose('[data-test-select-jwt]', 'JWT-Regional'); - console.log(currentURL()); assert.equal( currentURL(), '/settings/tokens?jwtAuthMethod=JWT-Regional', @@ -586,21 +594,24 @@ module('Acceptance | tokens', function (hooks) { ); }); - test('Tokens are shown on the policies index page', async function (assert) { + test('Tokens are shown on the Access Control Policies index page', async function (assert) { allScenarios.policiesTestCluster(server); + let firstPolicy = server.db.policies.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; // Create an expired token server.create('token', { name: 'Expired Token', id: 'just-expired', - policyIds: [server.db.policies[0].name], + policyIds: [firstPolicy.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(); + await visit('/access-control/policies'); + assert.dom('[data-test-policy-total-tokens]').exists(); const expectedFirstPolicyTokens = server.db.tokens.filter((token) => { - return token.policyIds.includes(server.db.policies[0].name); + return token.policyIds.includes(firstPolicy.name); }); assert .dom('[data-test-policy-total-tokens]') @@ -611,22 +622,25 @@ module('Acceptance | tokens', function (hooks) { test('Tokens are shown on a policy page', async function (assert) { allScenarios.policiesTestCluster(server); + let firstPolicy = server.db.policies.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; + // Create an expired token server.create('token', { name: 'Expired Token', id: 'just-expired', - policyIds: [server.db.policies[0].name], + policyIds: [firstPolicy.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}`); + await visit('/access-control/policies'); + await click('[data-test-policy-name]'); + assert.equal(currentURL(), `/access-control/policies/${firstPolicy.name}`); const expectedFirstPolicyTokens = server.db.tokens.filter((token) => { - return token.policyIds.includes(server.db.policies[0].name); + return token.policyIds.includes(firstPolicy.name); }); assert @@ -635,14 +649,25 @@ module('Acceptance | tokens', function (hooks) { { count: expectedFirstPolicyTokens.length }, 'Expected number of tokens are shown' ); - assert.dom('[data-test-token-expiration-time]').hasText('10 minutes ago'); + + const expiredTokenRow = [...findAll('[data-test-policy-token-row]')].find( + (a) => a.textContent.includes('Expired Token') + ); + + assert.dom(expiredTokenRow).exists(); + assert + .dom(expiredTokenRow.querySelector('[data-test-token-expiration-time]')) + .hasText('10 minutes ago'); window.localStorage.nomadTokenSecret = null; }); - test('Tokens Deletion', async function (assert) { + test('Tokens Deletion from Policy page', async function (assert) { allScenarios.policiesTestCluster(server); - const testPolicy = server.db.policies[0]; + let testPolicy = server.db.policies.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; + const existingTokens = server.db.tokens.filter((t) => t.policyIds.includes(testPolicy.name) ); @@ -654,28 +679,22 @@ module('Acceptance | tokens', function (hooks) { }); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; - await visit('/policies'); + await visit('/access-control/policies'); - await click('[data-test-policy-row]:first-child'); - assert.equal(currentURL(), `/policies/${testPolicy.name}`); + await click('[data-test-policy-name]:first-child'); + assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`); assert .dom('[data-test-policy-token-row]') .exists( { count: existingTokens.length + 1 }, '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]') @@ -687,18 +706,21 @@ module('Acceptance | tokens', function (hooks) { window.localStorage.nomadTokenSecret = null; }); - test('Test Token Creation', async function (assert) { + test('Test Token Creation from Policy Page', async function (assert) { allScenarios.policiesTestCluster(server); - const testPolicy = server.db.policies[0]; + let testPolicy = server.db.policies.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; + const existingTokens = server.db.tokens.filter((t) => t.policyIds.includes(testPolicy.name) ); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; - await visit('/policies'); + await visit('/access-control/policies'); - await click('[data-test-policy-row]:first-child'); - assert.equal(currentURL(), `/policies/${testPolicy.name}`); + await click('[data-test-policy-name]'); + assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`); assert .dom('[data-test-policy-token-row]') @@ -730,4 +752,534 @@ module('Acceptance | tokens', function (hooks) { requestHeaders[name.toUpperCase()] ); } + + module('Roles', function (hooks) { + // Set up a token with a role + hooks.beforeEach(function () { + window.localStorage.clear(); + window.sessionStorage.clear(); + faker.seed(1); + allScenarios.rolesTestCluster(server); + }); + + test('Policies are derived from role', async function (assert) { + assert.expect(19); + + await Tokens.visit(); + + let token; + + // User with 1 role, containing 1 policy, and no direct policies + token = server.db.tokens.findBy( + (t) => t.name === 'High Level Role Token' + ); + await Tokens.secret(token.secretId).submit(); + + assert.dom('[data-test-token-role]').exists({ count: 1 }); + assert.dom('[data-test-role-name]').hasText('high-level'); + assert.dom('[data-test-role-policies] li').exists({ count: 1 }); + assert.dom('[data-test-role-policies] li').hasText('job-writer'); + + assert.dom('[data-test-token-policy]').exists({ count: 1 }); + assert.dom('[data-test-policy-name]').hasText('job-writer'); + + await Tokens.clear(); + + // User with 1 role, containing 2 policies, and a direct policy + token = server.db.tokens.findBy( + (t) => t.name === 'Policy And Role Token' + ); + await Tokens.secret(token.secretId).submit(); + + assert.dom('[data-test-token-role]').exists({ count: 1 }); + assert.dom('[data-test-role-name]').hasText('reader'); + assert.dom('[data-test-role-policies] li').exists({ count: 2 }); + let policyLinks = findAll('[data-test-role-policies] li'); + assert.dom(policyLinks[0]).hasText('client-reader'); + assert.dom(policyLinks[1]).hasText('job-reader'); + + assert.dom('[data-test-token-policy]').exists({ count: 3 }); + let policyBlocks = findAll('[data-test-policy-name]'); + assert.dom(policyBlocks[0]).hasText('operator'); + assert.dom(policyBlocks[1]).hasText('client-reader'); + assert.dom(policyBlocks[2]).hasText('job-reader'); + + await percySnapshot(assert); + + await Tokens.clear(); + + // User with 2 roles, each containing 1 policy, and one of the policies is also directly on their token + token = server.db.tokens.findBy( + (t) => t.name === 'Multi Role And Policy Token' + ); + await Tokens.secret(token.secretId).submit(); + + assert.equal(token.roleIds.length, 2); + assert.equal(token.policyIds.length, 1); + + assert.dom('[data-test-token-role]').exists({ count: 2 }); + assert.dom('[data-test-token-policy]').exists({ count: 2 }); + }); + + test('Token priveleges are derived from role', async function (assert) { + // First, check that a node reader can read nodes if the policy to do so only exists at their role level + await visit('/clients'); + // Expect to see some nodes + let nodes = server.db.nodes; + assert.dom('[data-test-client-node-row]').exists({ count: nodes.length }); + + // Head back and sign in as Clientless Role Token + await Tokens.visit(); + let token = server.db.tokens.findBy( + (t) => t.name === 'Clientless Role Token' + ); + await Tokens.secret(token.secretId).submit(); + + await visit('/clients'); + // Expect no rows, and a denied message + assert.dom('[data-test-client-node-row]').doesNotExist(); + assert.dom('[data-test-error]').exists(); + + // Pop over to the jobs page and make sure the Run button is disabled + await visit('/jobs'); + assert.dom('[data-test-run-job]').hasTagName('button'); + assert.dom('[data-test-run-job]').isDisabled(); + + // Sign out, and sign back in as a high-level role token + await Tokens.visit(); + await Tokens.clear(); + token = server.db.tokens.findBy( + (t) => t.name === 'High Level Role Token' + ); + await Tokens.secret(token.secretId).submit(); + + await visit('/jobs'); + // Expect the Run button/link to work now + assert.dom('[data-test-run-job]').hasTagName('a'); + assert.dom('[data-test-run-job]').hasAttribute('href', '/ui/jobs/run'); + }); + }); + + module('Access Control Tokens section', function (hooks) { + hooks.beforeEach(async function () { + window.localStorage.clear(); + window.sessionStorage.clear(); + faker.seed(1); + allScenarios.rolesTestCluster(server); + await Tokens.visit(); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + await AccessControl.visitTokens(); + }); + + hooks.afterEach(async function () { + await Tokens.visit(); + await Tokens.clear(); + }); + + test('Tokens index, general', async function (assert) { + assert.equal(currentURL(), '/access-control/tokens'); + // Number of token rows equivalent to number in db + assert + .dom('[data-test-token-row]') + .exists({ count: server.db.tokens.length }); + + await percySnapshot(assert); + }); + + test('Tokens index, management token handling', async function (assert) { + // two management tokens, one of which is yours; yours cannot be deleted or clicked into. + assert.dom('[data-test-token-type="management"]').exists({ count: 2 }); + const managementToken = server.db.tokens.findBy( + (t) => t.type === 'management' + ); + const managementTokenRow = [...findAll('[data-test-token-row]')].find( + (row) => row.textContent.includes(managementToken.name) + ); + const otherManagerRow = [...findAll('[data-test-token-row]')].find( + (row) => + row.textContent.includes('management') && + !row.textContent.includes(managementToken.name) + ); + assert + .dom(managementTokenRow.querySelector('[data-test-token-name] a')) + .doesNotExist('Cannot click into and edit your own token'); + assert + .dom(otherManagerRow.querySelector('[data-test-token-name] a')) + .exists('Can click into and edit another manager token'); + assert + .dom( + managementTokenRow.querySelector('[data-test-delete-token] button') + ) + .isDisabled('Cannot delete your own token'); + assert + .dom(otherManagerRow.querySelector('[data-test-delete-token] button')) + .isNotDisabled('Can delete another manager token'); + }); + + test('Tokens index, table sorting', async function (assert) { + const nameCells = findAll('[data-test-token-name]'); + const nameCellText = nameCells.map((cell) => cell.textContent.trim()); + const sortedNameCellText = nameCellText.slice().sort(); + assert.deepEqual( + nameCellText, + sortedNameCellText, + 'Names are sorted alphabetically' + ); + + // Click on the first thead tr th to reverse + assert + .dom('table.acl-table thead tr th') + .hasAttribute('aria-sort', 'ascending'); + await click('table.acl-table thead tr th button'); + assert + .dom('table.acl-table thead tr th') + .hasAttribute('aria-sort', 'descending'); + + const reversedNameCells = findAll('[data-test-token-name]'); + const reversedNameCellText = reversedNameCells.map((cell) => + cell.textContent.trim() + ); + const reversedSortedNameCellText = nameCellText.slice().sort().reverse(); + + assert.deepEqual( + reversedNameCellText, + reversedSortedNameCellText, + 'Names are reversed alphabetically' + ); + }); + + test('Tokens index, deletion', async function (assert) { + const numberOfTokens = server.db.tokens.length; + assert + .dom('[data-test-token-row]') + .exists( + { count: numberOfTokens }, + 'Number of tokens matches number in db' + ); + const tokenToDelete = server.db.tokens.findBy((t) => t.type === 'client'); + const tokenRowToDelete = [...findAll('[data-test-token-row]')].find( + (row) => row.textContent.includes(tokenToDelete.name) + ); + await click( + tokenRowToDelete.querySelector('[data-test-delete-token] button') + ); + assert.dom('.flash-message.alert-success').exists(); + assert + .dom('[data-test-token-row]') + .exists( + { count: numberOfTokens - 1 }, + 'Number of token rows decreased after deletion' + ); + + const nameCells = findAll('[data-test-token-name]'); + const nameCellText = nameCells.map((cell) => cell.textContent.trim()); + assert.notOk( + nameCellText.includes(tokenToDelete.name), + 'Deleted token name not found among name cells' + ); + }); + + test('Tokens index, clicking into a token page', async function (assert) { + const tokenToClick = server.db.tokens.findBy((t) => t.type === 'client'); + const tokenRowToClick = [...findAll('[data-test-token-row]')].find( + (row) => row.textContent.includes(tokenToClick.name) + ); + await click(tokenRowToClick.querySelector('[data-test-token-name] a')); + assert.equal(currentURL(), `/access-control/tokens/${tokenToClick.id}`); + assert.dom('[data-test-token-name-input]').hasValue(tokenToClick.name); + }); + + test('Tokens index, roles and policies attached to a token show up as links', async function (assert) { + // Staying on the index page, Rows should have a Roles column with either "No Roles" or a bunch of links to roles. Ditto policies. + const tokenWithRolesAndPolicies = server.db.tokens.findBy( + (t) => t.name === 'Multi Role And Policy Token' + ); + const tokenRowWithRolesAndPolicies = [ + ...findAll('[data-test-token-row]'), + ].find((row) => row.textContent.includes(tokenWithRolesAndPolicies.name)); + const rolesCell = tokenRowWithRolesAndPolicies.querySelector( + '[data-test-token-roles]' + ); + const policiesCell = tokenRowWithRolesAndPolicies.querySelector( + '[data-test-token-policies]' + ); + assert.dom(rolesCell).exists(); + assert.dom(policiesCell).exists(); + + const rolesCellTags = rolesCell + .querySelector('.tag-group') + .querySelectorAll('span'); + const policiesCellTags = policiesCell + .querySelector('.tag-group') + .querySelectorAll('span'); + assert.equal(rolesCellTags.length, 2); + assert.equal(policiesCellTags.length, 1); + + const policyLessToken = server.db.tokens.findBy( + (t) => t.name === 'High Level Role Token' + ); + const policyLessTokenRow = [...findAll('[data-test-token-row]')].find( + (row) => row.textContent.includes(policyLessToken.name) + ); + const rolesCell2 = policyLessTokenRow.querySelector( + '[data-test-token-roles]' + ); + const policiesCell2 = policyLessTokenRow.querySelector( + '[data-test-token-policies]' + ); + assert.dom(rolesCell2).exists(); + assert.dom(policiesCell2).exists(); + + const rolesCellTags2 = rolesCell2 + .querySelector('.tag-group') + .querySelectorAll('span'); + const policiesCellTags2 = policiesCell2 + .querySelector('.tag-group') + .querySelectorAll('span'); + assert.equal(rolesCellTags2.length, 1); + assert.equal(policiesCellTags2.length, 0); + }); + + test('Token page, general', async function (assert) { + const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); + await visit(`/access-control/tokens/${token.id}`); + assert.dom('[data-test-token-name-input]').hasValue(token.name); + assert.dom('[data-test-token-accessor]').hasValue(token.accessorId); + assert.dom('[data-test-token-secret]').hasValue(token.secretId); + assert.dom('[data-test-token-type="client"]').isChecked(); + assert.dom('[data-test-token-type="management"]').isNotChecked(); + + assert.dom('.expiration-time').hasText('Token expires in an hour'); + + assert.dom('[data-test-token-roles]').exists(); + assert.dom('[data-test-token-policies]').exists(); + + // All possible policies are shown + const allPolicies = server.db.policies; + const allPolicyRows = findAll('[data-test-token-policies] tbody tr'); + assert.equal( + allPolicyRows.length, + allPolicies.length, + 'All policies are shown' + ); + + // The policies/roles belonging to this token are checked + const tokenPolicies = token.policyIds; + + const checkedPolicyRows = findAll( + '[data-test-token-policies] tbody tr input:checked' + ); + + assert.equal( + checkedPolicyRows.length, + tokenPolicies.length, + 'All policies belonging to this token are checked' + ); + + const checkedPolicyNames = checkedPolicyRows.map((row) => + row + .closest('tr') + .querySelector('[data-test-policy-name]') + .textContent.trim() + ); + assert.deepEqual( + checkedPolicyNames.sort(), + tokenPolicies.sort(), + 'All policies belonging to this token are checked' + ); + + const allRoles = server.db.roles; + const allRoleRows = findAll('[data-test-token-roles] tbody tr'); + assert.equal(allRoleRows.length, allRoles.length, 'All roles are shown'); + + const tokenRoles = token.roleIds; + + const checkedRoleRows = findAll( + '[data-test-token-roles] tbody tr input:checked' + ); + + assert.equal( + checkedRoleRows.length, + tokenRoles.length, + 'All roles belonging to this token are checked' + ); + + const checkedRoleNames = checkedRoleRows.map((row) => + row + .closest('tr') + .querySelector('[data-test-role-name]') + .textContent.trim() + ); + + assert.deepEqual( + checkedRoleNames.sort(), + tokenRoles.sort(), + 'All roles belonging to this token are checked' + ); + }); + test('Token name can be edited', async function (assert) { + const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); + await visit(`/access-control/tokens/${token.id}`); + assert.dom('[data-test-token-name-input]').hasValue(token.name); + await fillIn('[data-test-token-name-input]', 'Mud-Token'); + await click('[data-test-token-save]'); + assert.dom('.flash-message.alert-success').exists(); + await AccessControl.visitTokens(); + assert.dom('[data-test-token-name="Mud-Token"]').exists({ count: 1 }); + }); + + test('Token policies and roles can be edited', async function (assert) { + const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); + await visit(`/access-control/tokens/${token.id}`); + + // The policies/roles belonging to this token are checked + const tokenPolicies = token.policyIds; + + const checkedPolicyRows = findAll( + '[data-test-token-policies] tbody tr input:checked' + ); + + assert.equal( + checkedPolicyRows.length, + tokenPolicies.length, + 'All policies belonging to this token are checked' + ); + + const checkedPolicyNames = checkedPolicyRows.map((row) => + row + .closest('tr') + .querySelector('[data-test-policy-name]') + .textContent.trim() + ); + assert.deepEqual( + checkedPolicyNames.sort(), + tokenPolicies.sort(), + 'All policies belonging to this token are checked' + ); + + // Try unchecking ALL checked roles and policies and saving + // First, find all checked ones + const checkedPolicies = findAll( + '[data-test-token-policies] tbody tr input:checked' + ); + const checkedRoles = findAll( + '[data-test-token-roles] tbody tr input:checked' + ); + // Then uncheck them + checkedPolicies.forEach((policy) => { + policy.click(); + }); + checkedRoles.forEach((role) => { + role.click(); + }); + await click('[data-test-token-save]'); + assert.dom('.flash-message.alert-critical').exists(); + + // Try selecting a single role + await click('[data-test-token-roles] tbody tr input'); + await click('[data-test-token-save]'); + assert.dom('.flash-message.alert-success').exists(); + + await percySnapshot(assert); + + await AccessControl.visitTokens(); + // Policies cell for our clay token should read "No Policies" + const clayToken = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); + const clayTokenRow = [...findAll('[data-test-token-row]')].find((row) => + row.textContent.includes(clayToken.name) + ); + const policiesCell = clayTokenRow.querySelector( + '[data-test-token-policies]' + ); + assert.dom(policiesCell).exists(); + assert.dom(policiesCell).hasText('No Policies'); + + // Roles cell should have 1 tag + const rolesCell = clayTokenRow.querySelector('[data-test-token-roles]'); + const rolesCellTags = rolesCell + .querySelector('.tag-group') + .querySelectorAll('span'); + assert.equal(rolesCellTags.length, 1); + }); + test('Token can be deleted', async function (assert) { + const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); + await visit(`/access-control/tokens/${token.id}`); + await click('[data-test-delete-token]'); + assert.dom('.flash-message.alert-success').exists(); + await AccessControl.visitTokens(); + assert.dom('[data-test-token-name="cl4y-t0k3n"]').doesNotExist(); + }); + test('New Token creation', async function (assert) { + await click('[data-test-create-token]'); + assert.equal(currentURL(), '/access-control/tokens/new'); + await fillIn('[data-test-token-name-input]', 'Timeless Token'); + await click('[data-test-token-save]'); + assert.dom('.flash-message.alert-success').exists(); + await AccessControl.visitTokens(); + assert + .dom('[data-test-token-name="Timeless Token"]') + .exists({ count: 1 }); + const newTokenRow = [...findAll('[data-test-token-row]')].find((row) => + row.textContent.includes('Timeless Token') + ); + const newTokenExpirationCell = newTokenRow.querySelector( + '[data-test-token-expiration-time]' + ); + assert.dom(newTokenExpirationCell).hasText('Never'); + + // Now create one with a TTL + await click('[data-test-create-token]'); + assert.equal(currentURL(), '/access-control/tokens/new'); + await fillIn('[data-test-token-name-input]', 'TTL Token'); + // Select the "8 hours" radio within the .expiration-time div + await click('.expiration-time input[value="8h"]'); + await click('[data-test-token-save]'); + assert.dom('.flash-message.alert-success').exists(); + await AccessControl.visitTokens(); + assert.dom('[data-test-token-name="TTL Token"]').exists({ count: 1 }); + const ttlTokenRow = [...findAll('[data-test-token-row]')].find((row) => + row.textContent.includes('TTL Token') + ); + const ttlTokenExpirationCell = ttlTokenRow.querySelector( + '[data-test-token-expiration-time]' + ); + assert.dom(ttlTokenExpirationCell).hasText('in 8 hours'); + + // Now create one with an expiration time + await click('[data-test-create-token]'); + assert.equal(currentURL(), '/access-control/tokens/new'); + await fillIn('[data-test-token-name-input]', 'Expiring Token'); + // select the Custom radio button + await click('.expiration-time input[value="custom"]'); + assert + .dom('[data-test-token-expiration-time-input]') + .exists('HTML datetime-local picker exists'); + await percySnapshot(assert); + // select a date/time for 100 minutes into the future in GMT + const soon = new Date(); + soon.setMinutes(soon.getMinutes() + 100); + var tzoffset = new Date().getTimezoneOffset() * 60000; //offset in milliseconds + var soonString = new Date(soon - tzoffset).toISOString().slice(0, -1); + await fillIn('[data-test-token-expiration-time-input]', soonString); + await click('[data-test-token-save]'); + assert.dom('.flash-message.alert-success').exists(); + await AccessControl.visitTokens(); + assert + .dom('[data-test-token-name="Expiring Token"]') + .exists({ count: 1 }); + const expiringTokenRow = [...findAll('[data-test-token-row]')].find( + (row) => row.textContent.includes('Expiring Token') + ); + const expiringTokenExpirationCell = expiringTokenRow.querySelector( + '[data-test-token-expiration-time]' + ); + assert + .dom(expiringTokenExpirationCell) + .hasText('in 2 hours', 'Expiration time is relativized and rounded'); + }); + }); }); diff --git a/ui/tests/acceptance/variables-test.js b/ui/tests/acceptance/variables-test.js index fb38aade0..ce1986d70 100644 --- a/ui/tests/acceptance/variables-test.js +++ b/ui/tests/acceptance/variables-test.js @@ -71,7 +71,7 @@ module('Acceptance | variables', function (hooks) { const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; server.db.variables.update({ namespace: 'default' }); - const policy = server.db.policies.find('Variable Maker'); + const policy = server.db.policies.find('Variable-Maker'); policy.rulesJSON.Namespaces[0].Variables.Paths.find( (path) => path.PathSpec === '*' ).Capabilities = ['list', 'read', 'destroy']; @@ -452,7 +452,7 @@ module('Acceptance | variables', function (hooks) { server.createList('variable', 3); const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; - const policy = server.db.policies.find('Variable Maker'); + const policy = server.db.policies.find('Variable-Maker'); policy.rulesJSON.Namespaces[0].Variables.Paths.find( (path) => path.PathSpec === '*' ).Capabilities = ['list']; @@ -580,7 +580,7 @@ module('Acceptance | variables', function (hooks) { server.createList('variable', 3); const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; - const policy = server.db.policies.find('Variable Maker'); + const policy = server.db.policies.find('Variable-Maker'); policy.rulesJSON.Namespaces[0].Variables.Paths.find( (path) => path.PathSpec === '*' ).Capabilities = ['list', 'read', 'write']; @@ -634,7 +634,7 @@ module('Acceptance | variables', function (hooks) { server.createList('variable', 3); const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; - const policy = server.db.policies.find('Variable Maker'); + const policy = server.db.policies.find('Variable-Maker'); policy.rulesJSON.Namespaces[0].Variables.Paths.find( (path) => path.PathSpec === '*' ).Capabilities = ['list', 'read']; @@ -763,7 +763,7 @@ module('Acceptance | variables', function (hooks) { server.createList('variable', 3); const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; - const policy = server.db.policies.find('Variable Maker'); + const policy = server.db.policies.find('Variable-Maker'); policy.rulesJSON.Namespaces[0].Variables.Paths.find( (path) => path.PathSpec === '*' ).Capabilities = ['list', 'read', 'destroy']; @@ -799,7 +799,7 @@ module('Acceptance | variables', function (hooks) { server.createList('variable', 3); const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; - const policy = server.db.policies.find('Variable Maker'); + const policy = server.db.policies.find('Variable-Maker'); policy.rulesJSON.Namespaces[0].Variables.Paths.find( (path) => path.PathSpec === '*' ).Capabilities = ['list', 'read']; diff --git a/ui/tests/pages/access-control.js b/ui/tests/pages/access-control.js new file mode 100644 index 000000000..932127cc1 --- /dev/null +++ b/ui/tests/pages/access-control.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { create, visitable } from 'ember-cli-page-object'; + +export default create({ + visit: visitable('/access-control'), + visitTokens: visitable('/access-control/tokens'), + visitPolicies: visitable('/access-control/policies'), + visitRoles: visitable('/access-control/roles'), +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 8e5b4ffd7..73b8345ba 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2840,6 +2840,16 @@ ember-cli-version-checker "^5.1.2" semver "^7.3.5" +"@ember/test-waiters@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@ember/test-waiters/-/test-waiters-3.0.2.tgz#5b950c580a1891ed1d4ee64f9c6bacf49a15ea6f" + integrity sha512-H8Q3Xy9rlqhDKnQpwt2pzAYDouww4TZIGSI1pZJhM7mQIGufQKuB0ijzn/yugA6Z+bNdjYp1HioP8Y4hn2zazQ== + dependencies: + calculate-cache-key-for-tree "^2.0.0" + ember-cli-babel "^7.26.6" + ember-cli-version-checker "^5.1.2" + semver "^7.3.5" + "@embroider/addon-shim@^1.0.0", "@embroider/addon-shim@^1.2.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@embroider/addon-shim/-/addon-shim-1.5.0.tgz#639b8b394336a5ae26dd3e24ffc3d34d864ac5ce" @@ -2848,7 +2858,7 @@ "@embroider/shared-internals" "^1.5.0" semver "^7.3.5" -"@embroider/addon-shim@^1.5.0", "@embroider/addon-shim@^1.8.4": +"@embroider/addon-shim@^1.8.4": version "1.8.4" resolved "https://registry.yarnpkg.com/@embroider/addon-shim/-/addon-shim-1.8.4.tgz#0e7f32c5506bf0f3eb0840506e31c36c7053763c" integrity sha512-sFhfWC0vI18KxVenmswQ/ShIvBg4juL8ubI+Q3NTSdkCTeaPQ/DIOUF6oR5DCQ8eO/TkIaw+kdG3FkTY6yNJqA== @@ -3432,35 +3442,36 @@ resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-1.1.0.tgz#d6dbc7574774b238114582410e8fee0dc3532bdf" integrity sha512-rR7tJoSwJ2eooOpYGxGGW95sLq6GXUaS1UtWvN7pei6n2/okYvCGld9vsUTvkl2migxbkszsycwtMf/GEc1k1A== -"@hashicorp/design-system-components@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-2.6.0.tgz#578cfed9f05d659c49b1bb23093d5df81b600200" - integrity sha512-mfCTc3JuNME0pVUxxdrcGjFVRnHtkacWEJZyTUByYaM6lerxXQzztuVTEI/eDhH594ytGjLjoPhRm85YYfoGuA== +"@hashicorp/design-system-components@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-2.12.0.tgz#295b910c1673d7f2c8bc62be9f07c585788f2df3" + integrity sha512-ewUWfyavTRVVcwKHigICdEIcQeDwLYXt4S/m+xGKyjowRGTfOIcU6tvg5eIB0YVtbhyA1AHEQi80+NhK867Kvg== dependencies: "@ember/render-modifiers" "^2.0.5" - "@hashicorp/design-system-tokens" "^1.5.0" - "@hashicorp/ember-flight-icons" "^3.0.4" + "@ember/test-waiters" "^3.0.2" + "@hashicorp/design-system-tokens" "^1.8.0" + "@hashicorp/ember-flight-icons" "^3.1.2" dialog-polyfill "^0.5.6" ember-a11y-refocus "^3.0.2" - ember-auto-import "^2.6.0" + ember-auto-import "^2.6.3" ember-cached-decorator-polyfill "^0.1.4" ember-cli-babel "^7.26.11" + ember-cli-clipboard "^1.0.0" ember-cli-htmlbars "^6.2.0" ember-cli-sass "^10.0.1" ember-composable-helpers "^4.5.0" - ember-focus-trap "^1.0.1" - ember-keyboard "^8.1.0" - ember-named-blocks-polyfill "^0.2.5" + ember-focus-trap "^1.0.2" + ember-keyboard "^8.2.0" ember-stargate "^0.4.3" - ember-style-modifier "^0.8.0" - ember-truth-helpers "^3.0.0" - sass "^1.58.3" + ember-style-modifier "^3.0.1" + ember-truth-helpers "^3.1.1" + sass "^1.62.1" tippy.js "^6.3.7" -"@hashicorp/design-system-tokens@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.5.0.tgz#e2a5ff96ed4e8b03f3b3258e93ef5eb115479402" - integrity sha512-Th/UOl73XZsPG7ypBrgVR7ZSKV9gfES1nC/E5kqEN0AOSBhlX2JaE2kFFprPYoe+zwaJ6FjASztWKBSK2h7+0A== +"@hashicorp/design-system-tokens@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.8.0.tgz#8734bc46fbdaf72b694927ba7352694d0da3e8e1" + integrity sha512-miRHSodtBJ0mkBkRpppW857U79lk2vIwNTv7bPmIbX1SQJONFsWQaOXJOKGAHEAxxWpGn0M98xnqo3Eol9Y6Eg== "@hashicorp/ember-flight-icons@^3.0.4": version "3.0.4" @@ -3472,11 +3483,26 @@ ember-cli-babel "^7.26.11" ember-cli-htmlbars "^6.1.0" +"@hashicorp/ember-flight-icons@^3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.1.2.tgz#39982cae2d1ba6a25cd3f8aa336cae674b2c463c" + integrity sha512-aroJ4xd/+6/HTTJnK7KCGNh77Eei8aDpQquEeqBkTT+TK5+C8y043J8joTUAOXq1brIaOwGtd9WuWPpJbb/Csw== + dependencies: + "@hashicorp/flight-icons" "^2.19.0" + ember-auto-import "^2.6.3" + ember-cli-babel "^7.26.11" + ember-cli-htmlbars "^6.2.0" + "@hashicorp/flight-icons@^2.13.0": version "2.13.0" resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.13.0.tgz#5ffa5edc3aa96e8574e57ed8ff049ac652febca0" integrity sha512-nWZ20v+r3c35OOUMhV+BdT34AHwqNELB59ZcnWaElqbJ4nkppQA9Xr/bT/wGx1yhftwZaDtpRayBWdJCT9zy6g== +"@hashicorp/flight-icons@^2.19.0": + version "2.19.0" + resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.19.0.tgz#4088574887232bb50a3c1d6e5044456c90b88e40" + integrity sha512-FzEHAOLSQMS5yJorF5H3xP4BKfpIUFRnQgkFl6i1RmvwpOJQgeoz9w/QqWvjh+H/DhFomeC6OxHGgD6rZL7phw== + "@hashicorp/structure-icons@^1.3.0": version "1.9.2" resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.9.2.tgz#c75f955b2eec414ecb92f3926c79b4ca01731d3c" @@ -10133,7 +10159,7 @@ ember-assign-helper@^0.3.0: ember-cli-babel "^7.19.0" ember-cli-htmlbars "^4.3.1" -ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0, ember-auto-import@^2.4.2, ember-auto-import@^2.6.0: +ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0, ember-auto-import@^2.4.2, ember-auto-import@^2.5.0, ember-auto-import@^2.6.0, ember-auto-import@^2.6.3: version "2.4.0" resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.4.0.tgz#91c4797f08315728086e35af954cb60bd23c14bc" integrity sha512-BwF6iTaoSmT2vJ9NEHEGRBCh2+qp+Nlaz/Q7roqNSxl5oL5iMRwenPnHhOoBPTYZvPhcV/KgXR5e+pBQ107plQ== @@ -10881,14 +10907,14 @@ ember-cli@~3.28.5: workerpool "^6.1.4" yam "^1.0.0" -ember-click-outside@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ember-click-outside/-/ember-click-outside-3.0.0.tgz#a7271345c5960b5dfe1e45a7f7245d1cf8f383dc" - integrity sha512-X2hLE9Set/tQ9KAEUxfGzCTUgJu/g2sKG+t2ghk/EDz8zF+Y/DPtlxeyZTR6NEPsUbzu3Pqe9gWJUxwaiXC0wg== +ember-click-outside@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ember-click-outside/-/ember-click-outside-5.0.1.tgz#e7df866cf03d940c73741effa0766e175213c7e3" + integrity sha512-RilHTCQvD/5d9pZf6H7MbmBWlVl68nhvn1BPLtfpt9iCNyhtnh5SgwIWGHkJRuTz+DooN6hqTe4Wmq8Zk6kYDw== dependencies: ember-cli-babel "^7.26.6" ember-cli-htmlbars "^5.7.1" - ember-modifier "^2.1.0 || ^3.0.0" + ember-modifier "^3.2.0" ember-compatibility-helpers@^1.1.1, ember-compatibility-helpers@^1.1.2, ember-compatibility-helpers@^1.2.5: version "1.2.6" @@ -11076,10 +11102,10 @@ ember-fetch@^8.1.1: node-fetch "^2.6.1" whatwg-fetch "^3.6.2" -ember-focus-trap@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ember-focus-trap/-/ember-focus-trap-1.0.1.tgz#a99565f6ce55d500b92a0965e79e3ad04219f157" - integrity sha512-ZUyq5ZkIuXp+ng9rCMkqBh36/V95PltL7iljStkma4+651xlAy3Z84L9WOu/uOJyVpNUxii8RJBbAySHV6c+RQ== +ember-focus-trap@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ember-focus-trap/-/ember-focus-trap-1.1.0.tgz#e3c47c6e916e838af3884b43e2794e87088d2bac" + integrity sha512-KxbCKpAJaBVZm+bW4tHPoBJAZThmxa6pI+WQusL+bj0RtAnGUNkWsVy6UBMZ5QqTQzf4EvGHkCVACVp5lbAWMQ== dependencies: "@embroider/addon-shim" "^1.0.0" focus-trap "^6.7.1" @@ -11141,12 +11167,12 @@ ember-inline-svg@^1.0.1: svgo "~1.2.2" walk-sync "~2.0.2" -ember-keyboard@^8.1.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/ember-keyboard/-/ember-keyboard-8.2.0.tgz#d11fa7f0443606b7c1850bbd8253274a00046e11" - integrity sha512-h2kuS2irtIyvNbAMkGDlDTB4TPXwgmC6Nu9bIuGWoCjkGdgJbUg0VegfyRJ1TlxbIHlAelbqVpE8UhfgY5wEag== +ember-keyboard@^8.2.0: + version "8.2.1" + resolved "https://registry.yarnpkg.com/ember-keyboard/-/ember-keyboard-8.2.1.tgz#945a8a71068d81c06ad26851008ef81061db2a59" + integrity sha512-wT9xpt3GKsiodGZoifKU4OyeRjXWlmKV9ZHHsp6wJBwMFpl4wWPjTNdINxivk2qg/WFNIh8nUiwuG4+soWXPdw== dependencies: - "@embroider/addon-shim" "^1.5.0" + "@embroider/addon-shim" "^1.8.4" ember-destroyable-polyfill "^2.0.3" ember-modifier "^2.1.2 || ^3.1.0 || ^4.0.0" ember-modifier-manager-polyfill "^1.2.0" @@ -11188,7 +11214,7 @@ ember-modifier-manager-polyfill@^1.2.0: ember-cli-version-checker "^2.1.2" ember-compatibility-helpers "^1.2.0" -ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0, ember-modifier@^3.2.7: +ember-modifier@3.2.7, ember-modifier@^3.0.0, ember-modifier@^3.2.0, ember-modifier@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.2.7.tgz#f2d35b7c867cbfc549e1acd8d8903c5ecd02ea4b" integrity sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA== @@ -11208,6 +11234,15 @@ ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0, ember-cli-normalize-entity-name "^1.0.0" ember-cli-string-utils "^1.1.0" +"ember-modifier@^3.2.7 || ^4.0.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-4.1.0.tgz#cb91efbf8ca4ff4a1a859767afa42dddba5a2bbd" + integrity sha512-YFCNpEYj6jdyy3EjslRb2ehNiDvaOrXTilR9+ngq+iUqSHYto2zKV0rleiA1XJQ27ELM1q8RihT29U6Lq5EyqQ== + dependencies: + "@embroider/addon-shim" "^1.8.4" + ember-cli-normalize-entity-name "^1.0.0" + ember-cli-string-utils "^1.1.0" + ember-moment@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/ember-moment/-/ember-moment-9.0.1.tgz#fcf06cb8ef07c8d0108820c1639778590d613b38" @@ -11219,14 +11254,6 @@ ember-moment@^9.0.1: moment "^2.29.1" moment-timezone "^0.5.33" -ember-named-blocks-polyfill@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/ember-named-blocks-polyfill/-/ember-named-blocks-polyfill-0.2.5.tgz#d5841406277026a221f479c815cfbac6cdcaeecb" - integrity sha512-OVMxzkfqJrEvmiky7gFzmuTaImCGm7DOudHWTdMBPO7E+dQSunrcRsJMgO9ZZ56suqBIz/yXbEURrmGS+avHxA== - dependencies: - ember-cli-babel "^7.19.0" - ember-cli-version-checker "^5.1.1" - ember-on-resize-modifier@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/ember-on-resize-modifier/-/ember-on-resize-modifier-1.0.0.tgz#b4e12dc023b4d608d7b0f4fa0100722fb860cdd4" @@ -11447,13 +11474,14 @@ ember-style-modifier@^0.7.0: ember-cli-babel "^7.26.6" ember-modifier "^3.0.0" -ember-style-modifier@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/ember-style-modifier/-/ember-style-modifier-0.8.0.tgz#ef46b3f288e63e3d850418ea8dc6f7b12edde721" - integrity sha512-I7M+oZ+poYYOP7n521rYv7kkYZbxotL8VbtHYxLQ3tasRZYQJ21qfu3vVjydSjwyE3w7EZRgKngBoMhKSAEZnw== +ember-style-modifier@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ember-style-modifier/-/ember-style-modifier-3.0.1.tgz#96aaaa2b713108725b81d8b934ec445ece6b89c3" + integrity sha512-WHRVIiqY/dpwDtVWlnHW0P4Z+Jha8QEwfaQdIF2ckJL77ZKdjbV2j1XZymS0Nzj61EGx5BM+YEsGL16r3hLv2A== dependencies: - ember-cli-babel "^7.26.6" - ember-modifier "^3.2.7" + ember-auto-import "^2.5.0" + ember-cli-babel "^7.26.11" + ember-modifier "^3.2.7 || ^4.0.0" ember-template-lint@^3.15.0: version "3.16.0" @@ -11525,6 +11553,13 @@ ember-tracked-storage-polyfill@1.0.0: dependencies: ember-cli-babel "^7.22.1" +ember-truth-helpers@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/ember-truth-helpers/-/ember-truth-helpers-3.1.1.tgz#434715926d72bcc63b8a115dec09745fda4474dc" + integrity sha512-FHwJAx77aA5q27EhdaaiBFuy9No+8yaWNT5A7zs0sIFCmf14GbcLn69vJEp6mW7vkITezizGAWhw7gL0Wbk7DA== + dependencies: + ember-cli-babel "^7.22.1" + "ember-usable@https://github.com/pzuraq/ember-usable#0d03a50": version "0.0.0" resolved "https://github.com/pzuraq/ember-usable#0d03a500a2f49041a4ddff0bb05b077c3907ed7d" @@ -18638,10 +18673,10 @@ sass@^1.17.3: dependencies: chokidar ">=3.0.0 <4.0.0" -sass@^1.58.3: - version "1.63.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.63.3.tgz#527746aa43bf2e4eac1ab424f67f6f18a081061a" - integrity sha512-ySdXN+DVpfwq49jG1+hmtDslYqpS7SkOR5GpF6o2bmb1RL/xS+wvPmegMvMywyfsmAV6p7TgwXYGrCZIFFbAHg== +sass@^1.62.1: + version "1.67.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.67.0.tgz#fed84d74b9cd708db603b1380d6dc1f71bb24f6f" + integrity sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" diff --git a/website/content/api-docs/acl/roles.mdx b/website/content/api-docs/acl/roles.mdx index d99aa9efa..7e2e2ca58 100644 --- a/website/content/api-docs/acl/roles.mdx +++ b/website/content/api-docs/acl/roles.mdx @@ -79,7 +79,7 @@ $ curl \ } ``` -## Update Token +## Update Role This endpoint updates an existing ACL Role. The request is always forwarded to the authoritative region.