From bf1e15e60b5ee2826070be33517b86b8130ae38b Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Thu, 29 Jun 2023 10:24:40 -0400 Subject: [PATCH 1/5] Rename pages to include roles --- ui/app/templates/components/gutter-menu.hbs | 2 +- ui/app/templates/policies.hbs | 2 +- ui/app/templates/policies/index.hbs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 9c666e82249..f2c1407f506 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -144,7 +144,7 @@ @activeClass="is-active" data-test-gutter-link="policies" > - Policies + Policies and Roles {{/if}} diff --git a/ui/app/templates/policies.hbs b/ui/app/templates/policies.hbs index 1f4c8c4465d..a389a9fa0b0 100644 --- a/ui/app/templates/policies.hbs +++ b/ui/app/templates/policies.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - + {{outlet}} \ No newline at end of file diff --git a/ui/app/templates/policies/index.hbs b/ui/app/templates/policies/index.hbs index fcf4e71ff06..de3ae7b186c 100644 --- a/ui/app/templates/policies/index.hbs +++ b/ui/app/templates/policies/index.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -{{page-title "Policies"}} +{{page-title "Policies and Roles"}}
From 72654952487423894bb2f52392d8e7998e310299 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Thu, 29 Jun 2023 11:08:48 -0400 Subject: [PATCH 2/5] Models and adapters --- ui/app/adapters/role.js | 11 +++++++++++ ui/app/models/role.js | 14 ++++++++++++++ ui/app/models/token.js | 1 + ui/app/serializers/token.js | 2 ++ 4 files changed, 28 insertions(+) create mode 100644 ui/app/adapters/role.js create mode 100644 ui/app/models/role.js diff --git a/ui/app/adapters/role.js b/ui/app/adapters/role.js new file mode 100644 index 00000000000..5a044dfec93 --- /dev/null +++ b/ui/app/adapters/role.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// @ts-check +import { default as ApplicationAdapter, namespace } from './application'; + +export default class RoleAdapter extends ApplicationAdapter { + namespace = namespace + '/acl'; +} diff --git a/ui/app/models/role.js b/ui/app/models/role.js new file mode 100644 index 00000000000..7e9ccb3fc44 --- /dev/null +++ b/ui/app/models/role.js @@ -0,0 +1,14 @@ +/** + * 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; +} diff --git a/ui/app/models/token.js b/ui/app/models/token.js index d2b390879f9..7861666a36d 100644 --- a/ui/app/models/token.js +++ b/ui/app/models/token.js @@ -15,6 +15,7 @@ export default class Token extends Model { @attr('date') createTime; @attr('string') type; @hasMany('policy') policies; + @hasMany('role') roles; @attr() policyNames; @attr('date') expirationTime; diff --git a/ui/app/serializers/token.js b/ui/app/serializers/token.js index 7ae46b51826..48119cf9671 100644 --- a/ui/app/serializers/token.js +++ b/ui/app/serializers/token.js @@ -18,6 +18,8 @@ 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); return super.normalize(typeHash, hash); } } From 64514dc7918011668b9e5531f43bbfa848618e71 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Wed, 27 Sep 2023 13:23:26 -0400 Subject: [PATCH 3/5] [ui] Any policy checks in the UI now check for roles' policies as well as token policies (#18346) * combinedPolicies as a concept * Classic decorator on role adapter * We added a new request for roles, so the test based on a specific order of requests got fickle fast * Mirage roles cluster scaffolded * Acceptance test for roles and policies on the login page * Update mirage mock for nodes fetch to account for role policies / empty token.policies * Roles-derived policies checks * [ui] Access Control with Roles and Tokens (#18413) * top level policies routes moved into access control * A few more routes and name cleanup * Delog and test fixes to account for new url prefix and document titles * Overview page * Tokens and Roles routes * Tokens helios table * Add a role * Hacky role page and deletion * New policy keyboard shortcut and roles breadcrumb nav * If you leave New Role but havent made any changes, remove the newly-created record from store * Roles index list and general role route crud * Roles index actually links to roles now * Helios button styles for new roles and policies * Handle when you try to create a new role without having any policies * Token editing generally * Create Token functionality * Cant delete self-token but management token editing and deleting is fine * Upgrading helios caused codemirror to explode, shimmed * Policies table fix * without bang-element condition, modifier would refire over and over * Token TTL or Time setting * time will take you on * Mirage hooks for create and list roles * Ensure policy names only use allow characters in mirage mocks * Mirage mocked roles and policies in the default cluster * log and lintfix * chromedriver to 2.1.2 * unused unit tests removed * Nice profile dropdown * With the HDS accordion, rename our internal component scss ref * design revisions after discussion * Tooltip on deleted-policy tokens * Two-step button peripheral isDeleting gcode removed * Never to null on token save * copywrite headers added and empty routefiles removed * acceptance test fixes for policies endpoint * Route for updating a token * Policies testfixes * Ember on-click-outside modifier upgraded with general ember-modifier upgrade * Test adjustments to account for new profile header dropdown * Test adjustments for tokens via policy pages * Removed an unused route * Access Control index page tests * a11y tests * Tokens index acceptance tests generally * Lintfix * Token edit page tests * Token editing tests * New token expiration tests * Roles Index tests * Role editing policies tests * A complete set of Access Control Roles tests * Policies test * Be more specific about which row to check for expiration time * Nil check on expirationTime equality * Management tokens shouldnt show No Roles/Policies, give them their own designation * Route guard on selftoken, conditional columns, and afterModel at parent to prevent orphaned policies on tokens/roles from stopping a new save * Policy unloading on delete and other todos plus autofocus conditionally re-enabled * Invalid policies non-links now a concept for Roles index * HDS style links to make job.variables.alert links look like links again * Mirage finding looks weird so making model async in hash even though redundant * Drop rsvp * RSVP wasnt the problem, cached lookups were * remove old todo comments --- .github/workflows/ember-test-audit.yml | 4 +- nomad/structs/acl.go | 7 +- ui/app/abilities/role.js | 17 + ui/app/adapters/role.js | 13 +- ui/app/adapters/token.js | 15 +- ui/app/components/access-control-subnav.js | 13 + ui/app/components/editable-variable-link.hbs | 4 +- ui/app/components/policy-editor.hbs | 16 +- ui/app/components/policy-editor.js | 19 +- ui/app/components/profile-navbar-item.hbs | 26 +- ui/app/components/profile-navbar-item.js | 36 +- ui/app/components/role-editor.hbs | 78 +++ ui/app/components/role-editor.js | 77 +++ ui/app/components/token-editor.hbs | 239 +++++++ ui/app/components/token-editor.js | 120 ++++ .../access-control/policies/index.js | 94 +++ .../{ => access-control}/policies/policy.js | 58 +- .../controllers/access-control/roles/index.js | 88 +++ .../controllers/access-control/roles/role.js | 108 +++ .../access-control/tokens/index.js | 47 ++ .../access-control/tokens/token.js | 42 ++ ui/app/controllers/policies/index.js | 24 - ui/app/models/role.js | 1 + ui/app/models/token.js | 18 + ui/app/modifiers/code-mirror.js | 11 +- ui/app/router.js | 23 +- ui/app/routes/access-control.js | 77 +++ .../{ => access-control}/policies/new.js | 6 +- .../{ => access-control}/policies/policy.js | 2 +- ui/app/routes/access-control/roles/new.js | 37 ++ ui/app/routes/access-control/roles/role.js | 40 ++ ui/app/routes/access-control/tokens/new.js | 39 ++ ui/app/routes/access-control/tokens/token.js | 46 ++ ui/app/routes/application.js | 2 +- ui/app/routes/policies.js | 34 - ui/app/serializers/role.js | 34 + ui/app/serializers/token.js | 27 + ui/app/services/token.js | 19 +- ui/app/styles/components.scss | 3 +- ui/app/styles/components/access-control.scss | 84 +++ ...accordion.scss => accordion-internal.scss} | 0 ui/app/styles/components/page-layout.scss | 2 +- ui/app/styles/components/policies.scss | 8 +- ui/app/styles/core/navbar.scss | 31 +- ui/app/templates/access-control.hbs | 12 + ui/app/templates/access-control/index.hbs | 44 ++ ui/app/templates/access-control/policies.hbs | 8 + .../access-control/policies/index.hbs | 82 +++ .../{ => access-control}/policies/new.hbs | 2 +- .../{ => access-control}/policies/policy.hbs | 37 +- ui/app/templates/access-control/roles.hbs | 8 + .../templates/access-control/roles/index.hbs | 102 +++ ui/app/templates/access-control/roles/new.hbs | 27 + .../templates/access-control/roles/role.hbs | 123 ++++ ui/app/templates/access-control/tokens.hbs | 8 + .../templates/access-control/tokens/index.hbs | 149 +++++ .../templates/access-control/tokens/new.hbs | 17 + .../templates/access-control/tokens/token.hbs | 24 + .../components/access-control-subnav.hbs | 13 + ui/app/templates/components/gutter-menu.hbs | 10 +- ui/app/templates/jobs/job/variables.hbs | 2 +- ui/app/templates/policies.hbs | 9 - ui/app/templates/policies/index.hbs | 76 --- ui/app/templates/settings/tokens.hbs | 39 +- ui/mirage/config.js | 142 +++- ui/mirage/factories/policy.js | 10 +- ui/mirage/factories/token.js | 273 ++++---- ui/mirage/models/token.js | 11 + ui/mirage/scenarios/default.js | 309 +++++++++ ui/mirage/serializers/role.js | 29 + ui/mirage/serializers/token.js | 25 +- ui/package.json | 4 +- ui/tests/acceptance/access-control-test.js | 150 +++++ ui/tests/acceptance/global-header-test.js | 4 +- ui/tests/acceptance/policies-test.js | 109 +++- ui/tests/acceptance/regions-test.js | 31 +- ui/tests/acceptance/roles-test.js | 295 +++++++++ ui/tests/acceptance/token-test.js | 616 +++++++++++++++++- ui/tests/acceptance/variables-test.js | 12 +- ui/tests/pages/access-control.js | 13 + ui/yarn.lock | 139 ++-- website/content/api-docs/acl/roles.mdx | 2 +- 82 files changed, 4055 insertions(+), 600 deletions(-) create mode 100644 ui/app/abilities/role.js create mode 100644 ui/app/components/access-control-subnav.js create mode 100644 ui/app/components/role-editor.hbs create mode 100644 ui/app/components/role-editor.js create mode 100644 ui/app/components/token-editor.hbs create mode 100644 ui/app/components/token-editor.js create mode 100644 ui/app/controllers/access-control/policies/index.js rename ui/app/controllers/{ => access-control}/policies/policy.js (72%) create mode 100644 ui/app/controllers/access-control/roles/index.js create mode 100644 ui/app/controllers/access-control/roles/role.js create mode 100644 ui/app/controllers/access-control/tokens/index.js create mode 100644 ui/app/controllers/access-control/tokens/token.js delete mode 100644 ui/app/controllers/policies/index.js create mode 100644 ui/app/routes/access-control.js rename ui/app/routes/{ => access-control}/policies/new.js (91%) rename ui/app/routes/{ => access-control}/policies/policy.js (90%) create mode 100644 ui/app/routes/access-control/roles/new.js create mode 100644 ui/app/routes/access-control/roles/role.js create mode 100644 ui/app/routes/access-control/tokens/new.js create mode 100644 ui/app/routes/access-control/tokens/token.js delete mode 100644 ui/app/routes/policies.js create mode 100644 ui/app/serializers/role.js create mode 100644 ui/app/styles/components/access-control.scss rename ui/app/styles/components/{accordion.scss => accordion-internal.scss} (100%) create mode 100644 ui/app/templates/access-control.hbs create mode 100644 ui/app/templates/access-control/index.hbs create mode 100644 ui/app/templates/access-control/policies.hbs create mode 100644 ui/app/templates/access-control/policies/index.hbs rename ui/app/templates/{ => access-control}/policies/new.hbs (75%) rename ui/app/templates/{ => access-control}/policies/policy.hbs (75%) create mode 100644 ui/app/templates/access-control/roles.hbs create mode 100644 ui/app/templates/access-control/roles/index.hbs create mode 100644 ui/app/templates/access-control/roles/new.hbs create mode 100644 ui/app/templates/access-control/roles/role.hbs create mode 100644 ui/app/templates/access-control/tokens.hbs create mode 100644 ui/app/templates/access-control/tokens/index.hbs create mode 100644 ui/app/templates/access-control/tokens/new.hbs create mode 100644 ui/app/templates/access-control/tokens/token.hbs create mode 100644 ui/app/templates/components/access-control-subnav.hbs delete mode 100644 ui/app/templates/policies.hbs delete mode 100644 ui/app/templates/policies/index.hbs create mode 100644 ui/mirage/models/token.js create mode 100644 ui/mirage/serializers/role.js create mode 100644 ui/tests/acceptance/access-control-test.js create mode 100644 ui/tests/acceptance/roles-test.js create mode 100644 ui/tests/pages/access-control.js diff --git a/.github/workflows/ember-test-audit.yml b/.github/workflows/ember-test-audit.yml index 67aa066a668..b71d5fcfc8c 100644 --- a/.github/workflows/ember-test-audit.yml +++ b/.github/workflows/ember-test-audit.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.pull_request.base.sha }} - - uses: nanasess/setup-chromedriver@6fb8f5ffa6b7dc11e631ff695fbd2fec0b04bb52 # v2.1.1 + - uses: nanasess/setup-chromedriver@69cc01d772a1595b8aee87d52f53e71b3904d9d0 # v2.1.2 - name: Use Node.js uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 with: @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - uses: nanasess/setup-chromedriver@6fb8f5ffa6b7dc11e631ff695fbd2fec0b04bb52 # v2.1.1 + - uses: nanasess/setup-chromedriver@69cc01d772a1595b8aee87d52f53e71b3904d9d0 # v2.1.2 - name: Use Node.js uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 with: diff --git a/nomad/structs/acl.go b/nomad/structs/acl.go index 246f1f7d128..669cf5fdcbf 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 00000000000..d691adefc2b --- /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 index 5a044dfec93..c9e27fc3301 100644 --- a/ui/app/adapters/role.js +++ b/ui/app/adapters/role.js @@ -5,7 +5,18 @@ // @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 510d8d7bfbb..c4d2db9009f 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 00000000000..9f15fc113a6 --- /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 20210e3cd82..20e9ea3d5ed 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 e214aa6d2a1..7e2b08f50e1 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 @@
{{#if (can "update policy")}} - + {{/if}}
\ No newline at end of file diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js index b16bfcdfb4d..2a6ffa36892 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 17d049b1ad1..919d4d6d463 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 addbfa9b12b..60bd353ef2f 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, - }); - - // 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'); - }, - }, - ]; + @action + signOut() { + this.token.setProperties({ + secret: undefined, + }); - 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 00000000000..e34eb5ecaed --- /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 00000000000..2fcd48d69a6 --- /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 00000000000..273f90c0fa5 --- /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 00000000000..5c5fb86b9d1 --- /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 00000000000..322dcb6bec9 --- /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 6e042de4a1d..523d6485104 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>`; - } - - @action - onDeletePrompt() { - this.isDeleting = true; - } - - @action - onDeleteCancel() { - this.isDeleting = false; + return `nomad acl token create -name="" -policy="${this.policy.name}" -type=client -ttl=8h`; } - @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 00000000000..4807b149096 --- /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 00000000000..a2c2189faa5 --- /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 00000000000..1150feabaa5 --- /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 00000000000..2527fa5928b --- /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/controllers/policies/index.js b/ui/app/controllers/policies/index.js deleted file mode 100644 index 2a12c3e898f..00000000000 --- a/ui/app/controllers/policies/index.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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'; - -export default class PoliciesIndexController extends Controller { - @service router; - 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('policies.policy', policy.name); - } -} diff --git a/ui/app/models/role.js b/ui/app/models/role.js index 7e9ccb3fc44..431f67d860f 100644 --- a/ui/app/models/role.js +++ b/ui/app/models/role.js @@ -11,4 +11,5 @@ 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 7861666a36d..700afdd9520 100644 --- a/ui/app/models/token.js +++ b/ui/app/models/token.js @@ -19,9 +19,27 @@ export default class Token extends Model { @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 348c9247ac6..69a77d3dc28 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 d9e3c6c9e0b..cb3d93b0d9c 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 00000000000..6980bdadc9c --- /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 3ab389eb565..eaf0199e362 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 874bde5616c..6fb5faca747 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 00000000000..84b41483dc6 --- /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 00000000000..e5fe1bc72d2 --- /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 00000000000..979b84526da --- /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 00000000000..24ef10a67bd --- /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 c601935d52a..b2cc7effaa1 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/routes/policies.js b/ui/app/routes/policies.js deleted file mode 100644 index 4ac1421421f..00000000000 --- a/ui/app/routes/policies.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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 { hash } from 'rsvp'; - -export default class PoliciesRoute extends Route.extend( - withForbiddenState, - WithModelErrorHandling -) { - @service can; - @service store; - @service router; - - beforeModel() { - if (this.can.cannot('list policies')) { - this.router.transitionTo('/jobs'); - } - } - - async model() { - return await hash({ - policies: this.store.query('policy', { reload: true }), - tokens: - this.can.can('list tokens') && - this.store.query('token', { reload: true }), - }); - } -} diff --git a/ui/app/serializers/role.js b/ui/app/serializers/role.js new file mode 100644 index 00000000000..2b1aa057cf0 --- /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 48119cf9671..818d53a424b 100644 --- a/ui/app/serializers/token.js +++ b/ui/app/serializers/token.js @@ -20,6 +20,33 @@ export default class TokenSerializer extends ApplicationSerializer { 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 c034b43aebf..102fcf61bcb 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 aab1b61d9ee..367fda3047a 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ -@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 00000000000..dca06d97918 --- /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 dd14e5ae405..6d709283a3b 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 824e51f5a1b..873337b4d9a 100644 --- a/ui/app/styles/components/policies.scss +++ b/ui/app/styles/components/policies.scss @@ -3,6 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// 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 e0ed8299fd2..e1587cab9c0 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; - - &: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; + 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); + + &.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 00000000000..d970551d6f9 --- /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 00000000000..e2d3fb531d8 --- /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 00000000000..ad1807ef0e7 --- /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 00000000000..8f985b4d212 --- /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 a02e9deb930..a2c92105be4 100644 --- a/ui/app/templates/policies/new.hbs +++ b/ui/app/templates/access-control/policies/new.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - + {{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 d46847e7182..4799a79ea62 100644 --- a/ui/app/templates/policies/policy.hbs +++ b/ui/app/templates/access-control/policies/policy.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - + {{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 00000000000..5ad0534371e --- /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 00000000000..de16e0f5b47 --- /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 00000000000..c8d623ccb0c --- /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 00000000000..777743c6668 --- /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 00000000000..f1987c3aa7e --- /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 00000000000..76f0ed113cb --- /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 00000000000..85c54292a5a --- /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 00000000000..d2d7ef66722 --- /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 00000000000..cb0bdcbc939 --- /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 f2c1407f506..a5210e8cbae 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -135,16 +135,16 @@
  • - Policies and Roles + Access Control
  • {{/if}} diff --git a/ui/app/templates/jobs/job/variables.hbs b/ui/app/templates/jobs/job/variables.hbs index 180bcc7defe..583ca81b2d1 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/policies.hbs b/ui/app/templates/policies.hbs deleted file mode 100644 index a389a9fa0b0..00000000000 --- a/ui/app/templates/policies.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - - - - {{outlet}} - \ No newline at end of file diff --git a/ui/app/templates/policies/index.hbs b/ui/app/templates/policies/index.hbs deleted file mode 100644 index de3ae7b186c..00000000000 --- a/ui/app/templates/policies/index.hbs +++ /dev/null @@ -1,76 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -{{page-title "Policies and Roles"}} -
      -
      -
      -
      - {{#if (can "write policy")}} - - Create Policy - - {{else}} - - {{/if}} - -
      -
      -
      - {{#if this.policies.length}} - - - Policy Name - {{#if (can "list token")}} - Tokens - {{/if}} - - - - - {{row.model.name}} - - {{#if (can "list token")}} - - - {{row.model.tokens.length}} - {{#if (filter-by "isExpired" row.model.tokens)}} - ({{get (filter-by "isExpired" row.model.tokens) "length"}} expired) - {{/if}} - - - {{/if}} - - - - {{else}} -
      -

      - No Policies -

      -

      - Get started by creating a new policy -

      -
      - {{/if}} -
      diff --git a/ui/app/templates/settings/tokens.hbs b/ui/app/templates/settings/tokens.hbs index 303b1bef693..ef43ed65632 100644 --- a/ui/app/templates/settings/tokens.hbs +++ b/ui/app/templates/settings/tokens.hbs @@ -3,12 +3,18 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -{{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 c78d3e7e121..c7701a3608d 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,63 @@ 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]; + } + // const expirationTime = ExpirationTTL + // ? new Date(Date.now() + parseDuration(ExpirationTTL)) + // : null; + + // 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; + console.log('finally', expirationTime, ExpirationTime, ExpirationTTL); + 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 +612,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 +619,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 +637,83 @@ 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 + console.log('alltok', server.schema.tokens); + 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 a40e20fd6d1..c975668c805 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 52266ac09ca..a4d893e2dcc 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 }); - } - }); + policyIds.forEach((policy) => { + const dbPolicy = server.db.policies.find(policy); + if (!dbPolicy) { + server.create('policy', { id: policy }); + } + }); - token.update({ policyIds }); + 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"] + // 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" -} - `, + node { + policy = "read" + } + `, - rulesJSON: { - Namespaces: [ - { - Name: '*', - Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], - Variables: { - Paths: [ - { - Capabilities: ['write', 'read', 'destroy', 'list'], - PathSpec: '*', - }, - ], + 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"] + ], + }, + }; + 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-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"] + 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" -} - `, + node { + policy = "read" + } + `, - rulesJSON: { - Namespaces: [ - { - Name: '*', - Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], - Variables: { - Paths: [ - { - Capabilities: ['list'], - PathSpec: '*', - }, - ], + 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-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/*', - }, - ], + { + 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), - }); + ], + }, + }; + 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 00000000000..c3ea6d51dc6 --- /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 2e21482e71d..60316b85dd4 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 00000000000..eb906c0349a --- /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 1fba2e187df..c799341c185 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 9072a8beac6..247a225b040 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 00000000000..82e00eb5ff2 --- /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 daf971a6883..64be9a2310a 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 f440c7a8e90..99804c932c2 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 13c527d2bd5..d461d597bde 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 00000000000..5a62990a97e --- /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 bf4aa5af8fe..8b104c2809d 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,23 @@ 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') ); - + console.log('doomed', doomedTokenRow); 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 +707,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 +753,535 @@ 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'); + console.log('tokenAccessor', token, token.accessorId); + 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 8d818dbcb93..f4d9f83a664 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 00000000000..932127cc1a3 --- /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 9c7ba9e739d..b6183f46c0a 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 d99aa9efafb..7e2e2ca5807 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. From ff31deb7d589d454a8bd9e6384888f9998bbcbcf Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Wed, 27 Sep 2023 13:29:23 -0400 Subject: [PATCH 4/5] de-log --- ui/mirage/config.js | 5 ----- ui/tests/acceptance/token-test.js | 2 -- 2 files changed, 7 deletions(-) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index c7701a3608d..36e44d88059 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -498,9 +498,6 @@ export default function () { }; return value * unitMap[unit]; } - // const expirationTime = ExpirationTTL - // ? new Date(Date.now() + parseDuration(ExpirationTTL)) - // : null; // If there's an expirationTime, use that. Otherwise, use the TTL. const expirationTime = ExpirationTime @@ -508,7 +505,6 @@ export default function () { : ExpirationTTL ? new Date(Date.now() + parseDuration(ExpirationTTL)) : null; - console.log('finally', expirationTime, ExpirationTime, ExpirationTTL); return server.create('token', { name: Name, @@ -673,7 +669,6 @@ export default function () { const { id } = request.params; // Also update any tokens whose policyIDs include this policy - console.log('alltok', server.schema.tokens); const tokens = server.schema.tokens.where((token) => token.roleIds?.includes(id)) || []; tokens.models.forEach((token) => { diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index 8b104c2809d..258d4b7e384 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -692,7 +692,6 @@ module('Acceptance | tokens', function (hooks) { const doomedTokenRow = [...findAll('[data-test-policy-token-row]')].find( (a) => a.textContent.includes('Doomed Token') ); - console.log('doomed', doomedTokenRow); assert.dom(doomedTokenRow).exists(); await click(doomedTokenRow.querySelector('button')); @@ -1047,7 +1046,6 @@ module('Acceptance | tokens', function (hooks) { test('Token page, general', async function (assert) { const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); - console.log('tokenAccessor', token, token.accessorId); 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); From 8750591a0e5c0baf8acca77549744d8cc441db7f Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Wed, 27 Sep 2023 14:52:30 -0400 Subject: [PATCH 5/5] Changelog --- .changelog/17770.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/17770.txt diff --git a/.changelog/17770.txt b/.changelog/17770.txt new file mode 100644 index 00000000000..b282f6a0944 --- /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 +```