[ui] ACL Roles in the UI, plus Role, Policy and Token management (#17770) (#18599)

* Rename pages to include roles

* Models and adapters

* [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

* de-log
This commit is contained in:
Phil Renaud 2023-09-27 17:02:48 -04:00 committed by GitHub
parent 3cc387749e
commit bfba4f5e13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 4094 additions and 472 deletions

3
.changelog/17770.txt Normal file
View File

@ -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
```

View File

@ -363,11 +363,14 @@ 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 {
if a.ExpirationTime != nil {
if !existing.ExpirationTime.Equal(*a.ExpirationTime) {
mErr.Errors = append(mErr.Errors, errors.New("cannot update expiration time"))
}
}
}
return mErr.ErrorOrNil()
}

17
ui/app/abilities/role.js Normal file
View File

@ -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;
}

22
ui/app/adapters/role.js Normal file
View File

@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
// @ts-check
import { default as ApplicationAdapter, namespace } from './application';
import classic from 'ember-classic-decorator';
import { singularize } from 'ember-inflector';
@classic
export default class RoleAdapter extends ApplicationAdapter {
namespace = namespace + '/acl';
urlForCreateRecord(modelName) {
let baseUrl = this.buildURL(modelName);
return singularize(baseUrl);
}
urlForDeleteRecord(id) {
return this.urlForUpdateRecord(id, 'role');
}
}

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -7,9 +7,9 @@
{{#if (can "write variable")}}
{{#with (editable-variable-link @path existingPaths=@existingPaths namespace=@namespace) as |link|}}
{{#if link.model}}
<LinkTo @route={{link.route}} @model={{link.model}} @query={{link.query}}>{{@path}}</LinkTo>
<Hds::Link::Inline @route={{link.route}} @model={{link.model}} @query={{link.query}}>{{@path}}</Hds::Link::Inline>
{{else}}
<LinkTo @route={{link.route}} @query={{link.query}}>{{@path}}</LinkTo>
<Hds::Link::Inline @route={{link.route}} @query={{link.query}}>{{@path}}</Hds::Link::Inline>
{{/if}}
{{/with}}
{{else}}

View File

@ -34,7 +34,7 @@
mode="ruby"
content=@policy.rules
onUpdate=this.updatePolicyRules
autofocus=(not @policy.isNew)
autofocus=false
extraKeys=(hash Cmd-Enter=this.save)
}} />
</div>
@ -55,12 +55,12 @@
<footer>
{{#if (can "update policy")}}
<button
class="button is-primary"
type="submit"
>
Save Policy
</button>
<Hds::Button
@text="Save Policy"
@type="submit"
data-test-save-policy
{{on "click" this.save}}
/>
{{/if}}
</footer>
</form>

View File

@ -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({

View File

@ -4,22 +4,16 @@
~}}
{{#if this.token.selfToken}}
<PowerSelect
data-test-header-profile-dropdown
<Hds::Dropdown @color="secondary" class="profile-dropdown"
{{keyboard-shortcut menuLevel=true pattern=(array "g" "p") }}
@options={{this.profileOptions}}
@onChange={{action (queue
(fn (mut this.profileSelection))
this.profileSelection.action
)}}
@dropdownClass="dropdown-options"
@matchTriggerWidth={{false}}
@selected={{get this.profileSelection "key"}}
class="profile-dropdown navbar-item"
as |option|>
<span class="ember-power-select-prefix">Profile</span>
<span class="dropdown-label" data-test-dropdown-option={{option.key}}>{{option.label}}</span>
</PowerSelect>
as |dd|>
<dd.ToggleIcon @color="secondary" @icon="user-circle" @text="user menu" @size="small" data-test-header-profile-dropdown />
<dd.Title @text="Signed In" />
<dd.Description @text={{this.token.selfToken.name}} />
<dd.Separator />
<dd.Interactive @route="settings.tokens" @text="Profile" data-test-profile-dropdown-profile-link />
<dd.Interactive {{on "click" this.signOut}} @text="Sign Out" @color="critical" data-test-profile-dropdown-sign-out-link />
</Hds::Dropdown>
{{else}}
<LinkTo data-test-header-signin-link @route="settings.tokens" class="navbar-item" {{keyboard-shortcut menuLevel=true pattern=(array "g" "p") }}>
Sign In

View File

@ -4,27 +4,17 @@
*/
// @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: () => {
@action
signOut() {
this.token.setProperties({
secret: undefined,
});
@ -33,9 +23,5 @@ export default class ProfileNavbarItemComponent extends Component {
this.store.unloadAll();
this.token.reset();
this.router.transitionTo('jobs.index');
},
},
];
profileSelection = this.profileOptions[0];
}
}

View File

@ -0,0 +1,78 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form class="acl-form" autocomplete="off" {{on "submit" this.save}}>
<label>
<span>
Role Name
</span>
<Input
data-test-role-name-input
@type="text"
@value={{@role.name}}
class="input"
{{autofocus ignore=(not @role.isNew)}}
/>
</label>
<div>
<label>
<span>
Description (optional)
</span>
<Input
data-test-role-description-input
@value={{@role.description}}
class="input"
/>
</label>
</div>
<div>
<label>
Policies
</label>
<Hds::Table @caption="A list of policies available to this role" class="acl-table"
@model={{@policies}}
@columns={{array
(hash key="selected" width="80px")
(hash key="name" label="Name" isSortable=true)
(hash key="description" label="Description")
(hash key="definition" label="View Policy Definition")
}}
@sortBy="name"
data-test-role-policies
>
<:body as |B|>
<B.Tr>
<B.Td class="selection-checkbox">
<label>
<input type="checkbox"
checked={{find-by "name" B.data.name @role.policies}}
{{on "change" (action this.updateRolePolicies B.data)}}
/>
</label>
</B.Td>
<B.Td data-test-policy-name>{{B.data.name}}</B.Td>
<B.Td>{{B.data.description}}</B.Td>
<B.Td>
<LinkTo @route="access-control.policies.policy" @model={{B.data.name}}>
View Policy Definition
</LinkTo>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</div>
<footer>
{{#if (can "update role")}}
<Hds::Button @text="Save Role" @color="primary"
{{on "click" this.save}}
data-test-save-role
/>
{{/if}}
</footer>
</form>

View File

@ -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,
});
}
}
}

View File

@ -0,0 +1,239 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form class="acl-form" autocomplete="off" {{on "submit" this.save}}>
<label>
<Hds::Form::Label>
Token Name
</Hds::Form::Label>
<Input
data-test-token-name-input
@type="text"
@value={{@token.name}}
class="input"
{{autofocus ignore=(not @token.isNew)}}
/>
</label>
<div class="expiration-time">
{{#if @token.isNew}}
<Hds::Form::Label>
Expiration time
</Hds::Form::Label>
{{!-- Radio to select between 1, 4, 8, 24, or never --}}
<Hds::Form::Radio::Group @layout="horizontal" @name="expiration-time" {{on "change" this.updateTokenExpirationTTL}} as |G|>
<G.Radio::Field
@id="10m"
@value="10m"
as |F|>
<F.Label>10 minutes</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="8h"
@value="8h"
as |F|>
<F.Label>8 hours</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="24h"
@value="24h"
as |F|>
<F.Label>24 hours</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="never"
@value="never"
checked={{eq @token.expirationTTL "never"}}
as |F|>
<F.Label>Never</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="custom"
@value="custom"
as |F|>
<F.Label>Custom</F.Label>
</G.Radio::Field>
</Hds::Form::Radio::Group>
{{#if @token.expirationTime}}
<Hds::Form::TextInput::Field
data-test-token-expiration-time-input
@type="datetime-local"
@id="token-expiration-time"
{{on "change" this.updateTokenExpirationTime}}>
</Hds::Form::TextInput::Field>
{{/if}}
{{else}}
<Hds::Form::Label>
{{#if @token.expirationTime}}
Token {{#if @token.isExpired}}expired{{else}}expires{{/if}}
<Tooltip @text={{@token.expirationTime}} @isFullText={{true}}>
<span data-test-token-expiration-time class="{{if @token.isExpired "has-text-danger"}}">{{moment-from-now @token.expirationTime interval=1000}}</span>
</Tooltip>
{{else}}
Token never expires
{{/if}}
</Hds::Form::Label>
{{/if}}
</div>
{{#unless @token.isNew}}
<div>
<Hds::Form::MaskedInput::Field @isMasked={{false}} @hasCopyButton={{true}} @value={{@token.accessor}} readonly data-test-token-accessor as |F|>
<F.Label>Token Accessor</F.Label>
</Hds::Form::MaskedInput::Field>
</div>
<div>
<Hds::Form::MaskedInput::Field @hasCopyButton={{true}} @value={{@token.secret}} readonly data-test-token-secret as |F|>
<F.Label>Token Secret</F.Label>
</Hds::Form::MaskedInput::Field>
</div>
{{/unless}}
<div>
<Hds::Form::Radio::Group @layout="horizontal" @name="method-demo1" {{on "change" this.updateTokenType}} as |G|>
<G.Legend>Client or Management token?</G.Legend>
<G.HelperText>See <Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-tokens#token-types">Token types documentation</Hds::Link::Inline> for more information.</G.HelperText>
<G.Radio::Field
@id="client"
checked={{eq @token.type "client"}}
data-test-token-type="client"
as |F|>
<F.Label>Client</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="management"
checked={{eq @token.type "management"}}
data-test-token-type="management"
as |F|>
<F.Label>Management</F.Label>
</G.Radio::Field>
</Hds::Form::Radio::Group>
</div>
{{#if (eq @token.type "client")}}
<div data-test-token-policies>
<label>
Policies
</label>
{{#if @policies.length}}
<Hds::Table @caption="A list of policies available to this token" class="acl-table"
@model={{@policies}}
@columns={{array
(hash key="selected" width="80px")
(hash key="name" label="Name" isSortable=true)
(hash key="description" label="Description")
(hash key="definition" label="View Policy Definition")
}}
@sortBy="name"
>
<:body as |B|>
<B.Tr>
<B.Td class="selection-checkbox">
<label>
<input type="checkbox"
checked={{find-by "name" B.data.name @token.policies}}
{{on "change" (action this.updateTokenPolicies B.data)}}
/>
</label>
</B.Td>
<B.Td data-test-policy-name>{{B.data.name}}</B.Td>
<B.Td>{{B.data.description}}</B.Td>
<B.Td>
<LinkTo @route="access-control.policies.policy" @model={{B.data.name}}>
View Policy Definition
</LinkTo>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<div class="empty-message">
<h3 data-test-empty-role-list-headline class="empty-message-headline">
No Policies
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}
</div>
<div data-test-token-roles>
<label>
Roles
</label>
{{#if @roles.length}}
<Hds::Table @caption="A list of roles available to this token" class="acl-table"
@model={{@roles}}
@columns={{array
(hash key="selected" width="80px")
(hash key="name" label="Name" isSortable=true)
(hash key="description" label="Description")
(hash key="policies" label="Policies")
(hash key="definition" label="View Role Info")
}}
@sortBy="name"
>
<:body as |B|>
<B.Tr>
<B.Td class="selection-checkbox">
<label>
<input type="checkbox"
checked={{find-by "name" B.data.name @token.roles}}
{{on "change" (action this.updateTokenRoles B.data)}}
/>
</label>
</B.Td>
<B.Td data-test-role-name>{{B.data.name}}</B.Td>
<B.Td>{{B.data.description}}</B.Td>
<B.Td>
<div class="tag-group">
{{#each B.data.policies as |policy|}}
{{#if policy.name}}
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="access-control.policies.policy" @model="{{policy.name}}" />
{{/if}}
{{else}}
Role contains no policies
{{/each}}
</div>
</B.Td>
<B.Td>
<LinkTo @route="access-control.roles.role" @model={{B.data.id}}>
View Role Info
</LinkTo>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<div class="empty-message">
<h3 data-test-empty-role-list-headline class="empty-message-headline">
No Roles
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.roles.new">creating a new role</LinkTo>
</p>
</div>
{{/if}}
</div>
{{else}}
<p>Management-type tokens have access to all permissions.</p>
{{/if}}
<footer>
{{#if (can "update token")}}
<Hds::Button @text="Save Token" @color="primary"
data-test-token-save
{{on "click" this.save}}
/>
{{/if}}
</footer>
</form>

View File

@ -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,
});
}
}
}

View File

@ -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;
}

View File

@ -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="<TOKEN_NAME>" -policy="${this.policy.name}" -type=client -ttl=<8h>`;
return `nomad acl token create -name="<TOKEN_NAME>" -policy="${this.policy.name}" -type=client -ttl=8h`;
}
@action
onDeletePrompt() {
this.isDeleting = true;
}
@action
onDeleteCancel() {
this.isDeleting = false;
}
@task(function* () {
try {
yield this.policy.deleteRecord();
yield this.policy.save();
// Cleanup: Remove references from roles and tokens
this.store.peekAll('role').forEach((role) => {
role.policies.removeObject(this.policy);
});
this.store.peekAll('token').forEach((token) => {
token.policies.removeObject(this.policy);
});
if (this.store.peekRecord('policy', this.policy.id)) {
this.store.unloadRecord(this.policy);
}
this.notifications.add({
title: 'Policy Deleted',
color: 'success',
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('policies');
this.router.transitionTo('access-control.policies');
} catch (err) {
this.notifications.add({
title: `Error deleting Policy ${this.policy.name}`,
@ -92,12 +86,12 @@ export default class PoliciesPolicyController extends Controller {
},
});
} catch (err) {
this.error = {
title: 'Error creating new token',
description: err,
};
throw err;
this.notifications.add({
title: 'Error creating test token',
message: err,
color: 'critical',
sticky: true,
});
}
})
createTestToken;
@ -112,12 +106,12 @@ export default class PoliciesPolicyController extends Controller {
color: 'success',
});
} catch (err) {
this.error = {
this.notifications.add({
title: 'Error deleting token',
description: err,
};
throw err;
message: err,
color: 'critical',
sticky: true,
});
}
})
deleteToken;

View File

@ -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;
}

View File

@ -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="<TOKEN_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;
}

View File

@ -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');
}
}

View File

@ -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;
}

15
ui/app/models/role.js Normal file
View File

@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
// @ts-check
import Model from '@ember-data/model';
import { attr, hasMany } from '@ember-data/model';
export default class Role extends Model {
@attr('string') name;
@attr('string') description;
@hasMany('policy', { defaultValue: () => [] }) policies;
@attr() policyNames;
}

View File

@ -15,12 +15,31 @@ export default class Token extends Model {
@attr('date') createTime;
@attr('string') type;
@hasMany('policy') policies;
@hasMany('role') roles;
@attr() policyNames;
@attr('date') expirationTime;
// Note on verbatim: updating a token requires passing in its expiration time, where
// the API performs an equality check. However, we want to display it as a nicely
// formatted date in the UI. @attr('date') does this for us, but it strips the
// nanoseconds. Thus, our serializer retains the original value in a separate field
// that gets used on PUT requests when needed.
@attr() expirationTimeVerbatim;
@attr() expirationTTL;
@alias('id') accessor;
get isExpired() {
return this.expirationTime && this.expirationTime < new Date();
}
/**
* Combined policies directly on the token, and policies inferred from token's role[s]
*/
get combinedPolicies() {
return [
...this.policies.toArray(),
...this.roles.map((role) => role.policies.toArray()).flat(),
].uniq();
}
}

View File

@ -25,9 +25,16 @@ export default class CodeMirrorModifier extends Modifier {
}
}
didInstall() {
element = null;
args = {};
modify(element, positional, named) {
if (!this.element) {
this.element = element;
this.args = { positional, named };
this._setup();
}
}
didUpdateArguments() {
this._editor.setOption('lineWrapping', this.args.named.lineWrapping);

View File

@ -111,13 +111,26 @@ Router.map(function () {
});
});
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
if (config['ember-cli-mirage']) {
this.route('oidc-mock');

View File

@ -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);
}
});
});
}
}

View File

@ -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) {

View File

@ -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
) {

View File

@ -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();
}
}
}
}

View File

@ -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,
});
}
}

View File

@ -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();
}
}
}
}

View File

@ -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,
});
}
}

View File

@ -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()

View File

@ -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;
}
}

View File

@ -18,6 +18,35 @@ export default class TokenSerializer extends ApplicationSerializer {
normalize(typeHash, hash) {
hash.PolicyIDs = hash.Policies;
hash.PolicyNames = copy(hash.Policies);
hash.Roles = hash.Roles || [];
hash.RoleIDs = hash.Roles.map((role) => role.ID);
hash.ExpirationTimeVerbatim = hash.ExpirationTime;
return super.normalize(typeHash, hash);
}
serialize(snapshot, options) {
const hash = super.serialize(snapshot, options);
if (snapshot.id) {
hash.AccessorID = snapshot.id;
// If expirationTimeNanos exists, use that when saving expirationTime for equality check reasons;
// see note in token model.
hash.ExpirationTime = hash.ExpirationTimeVerbatim || hash.ExpirationTime;
}
delete hash.CreateTime;
delete hash.SecretID;
hash.Policies = hash.PolicyIDs || [];
delete hash.PolicyIDs;
delete hash.PolicyNames;
hash.Roles =
(hash.RoleIDs || []).map((id) => {
return { ID: id };
}) || [];
delete hash.RoleIDs;
return hash;
}
}

View File

@ -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];

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: MPL-2.0
*/
@import './components/accordion';
@import './components/accordion-internal';
@import './components/badge-nomad-internal';
@import './components/boxed-section';
@import './components/codemirror';
@ -59,3 +59,4 @@
@import './components/policies';
@import './components/metadata-editor';
@import './components/job-status-panel';
@import './components/access-control';

View File

@ -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;
}

View File

@ -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 {

View File

@ -3,6 +3,8 @@
* SPDX-License-Identifier: MPL-2.0
*/
// TODO: merge this in with access-control.scss, consider getting rid of policies table specific stuff
table.policies {
tr {
cursor: pointer;
@ -50,7 +52,8 @@ table.policies {
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;
}

View File

@ -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;
z-index: $z-gutter;
button.hds-dropdown-toggle-icon {
border-color: var(--token-color-palette-neutral-200);
background-color: transparent;
border: none !important;
height: auto;
box-shadow: none !important;
color: var(--token-color-surface-primary);
&:focus {
background-color: #21a572;
&.hds-dropdown-toggle-icon--is-open {
background-color: var(--token-color-surface-primary);
color: var(--token-color-foreground-primary);
}
.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;
}
}

View File

@ -0,0 +1,12 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "Access Control"}}
<Breadcrumb @crumb={{hash label="Access Control" args=(array "access-control")}} />
<PageLayout>
<AccessControlSubnav @client={{this.model}} />
{{outlet}}
</PageLayout>

View File

@ -0,0 +1,44 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<section class="section access-control-overview">
<section class="intro">
<p>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.</p>
<footer>
<Hds::Link::Standalone @icon="docs-link" @text="ACL System Fundamentals" @iconPosition="trailing" @href="https://developer.hashicorp.com/nomad/tutorials/access-control/access-control" />
<Hds::Link::Standalone @icon="docs-link" @text="ACL Policy Concepts" @iconPosition="trailing" @href="https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-policies" />
</footer>
</section>
<div class="section-cards">
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-tokens-card>
<LinkTo
@route="access-control.tokens"
>
{{this.model.tokens.length}} {{pluralize "Token" this.model.tokens.length}}
</LinkTo>
<p>User access tokens are associated with one or more policies or roles to grant specific capabilities.</p>
<Hds::Button @text="Create Token" @color="secondary" @iconPosition="leading" @icon="plus" @route="access-control.tokens.new" />
</Hds::Card::Container>
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-roles-card>
<LinkTo
@route="access-control.roles"
>
{{this.model.roles.length}} {{pluralize "Role" this.model.roles.length}}
</LinkTo>
<p>Roles group one or more Policies into higher-level sets of permissions.</p>
<Hds::Button @text="Create Role" @color="secondary" @iconPosition="leading" @icon="plus" @route="access-control.roles.new" />
</Hds::Card::Container>
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-policies-card>
<LinkTo
@route="access-control.policies"
>
{{this.model.policies.length}} {{pluralize "Policy" this.model.policies.length}}
</LinkTo>
<p>Sets of rules defining the capabilities granted to adhering tokens.</p>
<Hds::Button @text="Create Policy" @color="secondary" @iconPosition="leading" @icon="plus" @route="access-control.policies.new" />
</Hds::Card::Container>
</div>
</section>
{{outlet}}

View File

@ -0,0 +1,8 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "Policies"}}
<Breadcrumb @crumb={{hash label="Policies" args=(array "access-control.policies")}} />
{{outlet}}

View File

@ -0,0 +1,82 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<section class="section">
<header class="acl-explainer">
<p>
ACL Policies are sets of rules defining the capabilities granted to adhering tokens. You can create, modify, and delete them here.
</p>
<div>
{{#if (can "write policy")}}
<Hds::Button
@text="Create Policy"
@icon="plus"
@route="access-control.policies.new"
{{keyboard-shortcut
pattern=(array "n" "p")
action=(action this.goToNewPolicy)
label="Create Policy"
}}
data-test-create-policy
/>
{{else}}
<Hds::Button
@text="Create Policy"
@icon="plus"
disabled
data-test-disabled-create-policy
/>
{{/if}}
</div>
</header>
{{#if this.policies.length}}
<Hds::Table @caption="A list of policies for this cluster" class="acl-table"
@model={{this.policies}}
@columns={{this.columns}}
@sortBy="name"
>
<:body as |B|>
<B.Tr
{{keyboard-shortcut
enumerated=true
action=(action "openPolicy" B.data)
}}
data-test-policy-row
>
<B.Td>
<LinkTo data-test-policy-name={{B.data.name}} @route="access-control.policies.policy" @model={{B.data.name}}>{{B.data.name}}</LinkTo></B.Td>
<B.Td>{{B.data.description}}</B.Td>
{{#if (can "list token")}}
<B.Td>
<span data-test-policy-total-tokens>{{B.data.tokens.length}}</span>
{{#if (filter-by "isExpired" B.data.tokens)}}
<span data-test-policy-expired-tokens class="number-expired">({{get (filter-by "isExpired" B.data.tokens) "length"}} expired)</span>
{{/if}}
</B.Td>
{{/if}}
{{#if (can "destroy policy")}}
<B.Td>
<Hds::Button @text="Delete" @size="small" @color="critical"
{{on "click" (perform this.deletePolicy B.data)}}
data-test-delete-policy
/>
</B.Td>
{{/if}}
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<div class="empty-message">
<h3 data-test-empty-policies-list-headline class="empty-message-headline">
No Policies
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}
</section>

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: MPL-2.0
~}}
<Breadcrumb @crumb={{hash label="New" args=(array "policies.new")}} />
<Breadcrumb @crumb={{hash label="New" args=(array "access-control.policies.new")}} />
{{page-title "Create Policy"}}
<section class="section">
<h1 class="title with-flex" data-test-title>

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: MPL-2.0
~}}
<Breadcrumb @crumb={{hash label=this.policy.name args=(array "policies.policy" this.policy.name)}} />
<Breadcrumb @crumb={{hash label=this.policy.name args=(array "access-control.policies.policy" this.policy.name)}} />
{{page-title "Policy"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
@ -12,16 +12,9 @@
</div>
{{#if (can "destroy policy")}}
<div>
<TwoStepButton
data-test-delete-button
@idleText="Delete policy"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{this.deletePolicy.isRunning}}
@onConfirm={{perform this.deletePolicy}}
@onPrompt={{this.onDeletePrompt}}
@onCancel={{this.onDeleteCancel}}
<Hds::Button @text="Delete Policy" @color="critical"
data-test-delete-policy
{{on "click" (perform this.deletePolicy)}}
/>
</div>
{{/if}}
@ -90,7 +83,7 @@
</t.head>
<t.body as |row|>
<tr data-test-policy-token-row>
<td data-test-token-name>
<td data-test-token-name={{row.model.name}}>
<Tooltip @text={{row.model.id}}>
{{row.model.name}}
</Tooltip>
@ -104,24 +97,14 @@
<span data-test-token-expiration-time class="{{if row.model.isExpired "has-text-danger"}}">{{moment-from-now row.model.expirationTime interval=1000}}</span>
</Tooltip>
{{else}}
<span class="has-text-grey">Never</span>
<span>Never</span>
{{/if}}
</td>
{{#if (can "destroy token")}}
<td class="is-200px">
<TwoStepButton
<Hds::Button @text="Delete Token" @color="critical"
data-test-delete-token-button
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{row.model.isPendingDeletion}}
@onConfirm={{perform this.deleteToken row.model}}
@inlineText={{true}}
@classes={{hash
idleButton="is-danger is-outlined"
confirmButton="is-danger"
}}
{{on "click" (perform this.deleteToken row.model)}}
/>
</td>
{{/if}}
@ -141,5 +124,3 @@
{{/if}}
</section>
{{outlet}}

View File

@ -0,0 +1,8 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "Roles"}}
<Breadcrumb @crumb={{hash label="Roles" args=(array "access-control.roles")}} />
{{outlet}}

View File

@ -0,0 +1,102 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<section class="section">
<header class="acl-explainer">
<p>
ACL Roles group one or more Policies into higher-level sets of permissions. A user token can have any number of roles or policies.
</p>
<div>
{{#if (can "write role")}}
<Hds::Button
@text="Create Role"
@icon="plus"
@route="access-control.roles.new"
{{keyboard-shortcut
pattern=(array "n" "r")
action=(action this.goToNewRole)
label="Create Role"
}}
data-test-create-role
/>
{{else}}
<Hds::Button
@text="Create Role"
@icon="plus"
disabled
data-test-disabled-create-role
/>
{{/if}}
</div>
</header>
{{#if this.roles.length}}
<Hds::Table @caption="A list of roles for this cluster" class="acl-table"
@model={{this.roles}}
@columns={{this.columns}}
@sortBy="name"
>
<:body as |B|>
<B.Tr
{{keyboard-shortcut
enumerated=true
action=(action "openRole" B.data)
}}
data-test-role-row={{B.data.name}}
>
<B.Td data-test-role-name={{B.data.name}}>
<LinkTo @route="access-control.roles.role" @model={{B.data.id}}>{{B.data.name}}</LinkTo></B.Td>
<B.Td data-test-role-description>{{B.data.description}}</B.Td>
{{#if (can "list token")}}
<B.Td>
<span data-test-role-total-tokens>{{B.data.tokens.length}}</span>
{{#if (filter-by "isExpired" B.data.tokens)}}
<span data-test-role-expired-tokens class="number-expired">({{get (filter-by "isExpired" B.data.tokens) "length"}} expired)</span>
{{/if}}
</B.Td>
{{/if}}
{{#if (can "list policy")}}
<B.Td data-test-role-policies>
<div class="tag-group">
{{#each B.data.policyNames as |policyName|}}
{{#let (find-by "name" policyName this.model.policies) as |policy|}}
{{#if policy}}
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="access-control.policies.policy" @model="{{policy.name}}" />
{{else}}
<Hds::Tag
{{hds-tooltip "This policy has been deleted"}}
@text="{{policyName}}"
/>
{{/if}}
{{/let}}
{{else}}
No Policies
{{/each}}
</div>
</B.Td>
{{/if}}
{{#if (can "destroy role")}}
<B.Td>
<Hds::Button @text="Delete" @size="small" @color="critical"
{{on "click" (perform this.deleteRole B.data)}}
data-test-delete-role
/>
</B.Td>
{{/if}}
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<div class="empty-message">
<h3 data-test-empty-role-list-headline class="empty-message-headline">
No Roles
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.roles.new">creating a new role</LinkTo>
</p>
</div>
{{/if}}
</section>

View File

@ -0,0 +1,27 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="New" args=(array "access-control.roles.new")}} />
{{page-title "Create Role"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
Create Role
</h1>
{{#if this.model.policies.length}}
<RoleEditor
@role={{this.model.role}}
@policies={{this.model.policies}}
/>
{{else}}
<div class="empty-message">
<h3 class="empty-message-headline">
No Policies
</h3>
<p class="empty-message-body">
At least one Policy is required to create a Role; <LinkTo @route="access-control.policies.new">create a new policy</LinkTo>
</p>
</div>
{{/if}}
</section>

View File

@ -0,0 +1,123 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label=this.role.name args=(array "access-control.roles.role" this.role.id)}} />
{{page-title "Role"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
<div>
Edit Role
</div>
{{#if (can "destroy role")}}
<Hds::Button @text="Delete Role" @color="critical"
{{on "click" (perform this.deleteRole)}}
data-test-delete-role
/>
{{/if}}
</h1>
<RoleEditor
@role={{this.role}}
@policies={{this.policies}}
/>
{{#if (can "list token")}}
<hr />
<h2 class="title">
Tokens
</h2>
{{#if (can "write token")}}
<div class="token-operations">
<div class="boxed-section">
<div class="boxed-section-head">
<h3>Create a Test Token</h3>
</div>
<div class="boxed-section-body">
<p class="is-info">Create a test token that expires in 10 minutes for testing purposes.</p>
<label>
<Hds::Button @text="Create Test Token" @color="secondary"
data-test-create-test-token
class="create-test-token"
{{on "click" (perform this.createTestToken)}}
/>
</label>
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
<h3>Create Tokens from the Nomad CLI</h3>
</div>
<div class="boxed-section-body">
<p>When you're ready to create more tokens, you can do so via the <a class="external-link" href="https://developer.hashicorp.com/nomad/docs/commands" target="_blank" rel="noopener noreferrer">Nomad CLI <FlightIcon @name="external-link" /></a> with the following:
<pre>
<code>{{this.newTokenString}}</code>
<CopyButton
data-test-copy-button
@clipboardText={{this.newTokenString}}
@compact={{true}}
>
</CopyButton>
</pre>
</p>
</div>
</div>
</div>
{{/if}}
{{#if this.tokens.length}}
<ListTable
@source={{this.tokens}}
@class="tokens no-mobile-condense" as |t|>
<t.head>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
{{#if (can "destroy token")}}
<th>Delete</th>
{{/if}}
</t.head>
<t.body as |row|>
<tr data-test-role-token-row>
<td data-test-token-name>
<Tooltip @text={{row.model.id}}>
{{row.model.name}}
</Tooltip>
</td>
<td>
{{moment-from-now row.model.createTime interval=1000}}
</td>
<td>
{{#if row.model.expirationTime}}
<Tooltip @text={{row.model.expirationTime}}>
<span data-test-token-expiration-time class="{{if row.model.isExpired "has-text-danger"}}">{{moment-from-now row.model.expirationTime interval=1000}}</span>
</Tooltip>
{{else}}
<span>Never</span>
{{/if}}
</td>
{{#if (can "destroy token")}}
<td class="is-200px">
<Hds::Button @text="Delete Token" @color="critical"
data-test-delete-token-button
{{on "click" (perform this.deleteToken row.model)}}
/>
</td>
{{/if}}
</tr>
</t.body>
</ListTable>
{{else}}
<div class="empty-message">
<h3 data-test-empty-role-list-headline class="empty-message-headline">
No Tokens
</h3>
<p class="empty-message-body">
No tokens are using this role.
</p>
</div>
{{/if}}
{{/if}}
</section>

View File

@ -0,0 +1,8 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "Tokens"}}
<Breadcrumb @crumb={{hash label="Tokens" args=(array "access-control.tokens")}} />
{{outlet}}

View File

@ -0,0 +1,149 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<section class="section">
<header class="acl-explainer">
<p>
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.
</p>
<div>
{{#if (can "write token")}}
<Hds::Button
@text="Create Token"
@icon="plus"
@route="access-control.tokens.new"
{{keyboard-shortcut
pattern=(array "n" "t")
action=(action this.goToNewToken)
label="Create Token"
}}
data-test-create-token
/>
{{else}}
<Hds::Button
@text="Create Token"
@icon="plus"
disabled
data-test-disabled-create-token
/>
{{/if}}
</div>
</header>
{{#if this.model.tokens.length}}
<Hds::Table @caption="A list of tokens for this cluster" class="acl-table"
@model={{this.model.tokens}}
@columns={{array
(hash key="name" label="Name" isSortable=true)
(hash key="type" label="Type" isSortable=true)
(hash key="createTime" label="Created" isSortable=true)
(hash key="expirationTime" label="Expires" isSortable=true)
(hash key="roles" label="Roles")
(hash key="policies" label="Policies")
(hash key="delete" label="Delete")
}}
@sortBy="name"
>
<:body as |B|>
<B.Tr
{{keyboard-shortcut
enumerated=true
action=(action "openToken" B.data)
}}
data-test-token-row
>
<B.Td data-test-token-name={{B.data.name}}>
{{#if (eq B.data.id this.selfToken.id)}}
<strong>{{B.data.name}}</strong>
{{else}}
<LinkTo @route="access-control.tokens.token" @model={{B.data.id}}>
{{B.data.name}}
</LinkTo>
{{/if}}
</B.Td>
<B.Td data-test-token-type={{B.data.type}}>{{B.data.type}}</B.Td>
<B.Td>{{moment-from-now B.data.createTime interval=1000}}</B.Td>
<B.Td>
{{#if B.data.expirationTime}}
<Tooltip @text={{B.data.expirationTime}}>
<span data-test-token-expiration-time class="{{if B.data.isExpired "has-text-danger"}}">{{moment-from-now B.data.expirationTime interval=1000}}</span>
</Tooltip>
{{else}}
<span data-test-token-expiration-time>Never</span>
{{/if}}
</B.Td>
<B.Td data-test-token-roles>
<div class="tag-group">
{{!--
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}}
<Hds::Tag @color="primary" @text="{{role.name}}" @route="access-control.roles.role" @model="{{role.id}}" />
{{/if}}
{{else}}
{{#if (eq B.data.type "management")}}
Management Access
{{else}}
No Roles
{{/if}}
{{/each}}
</div>
</B.Td>
<B.Td data-test-token-policies>
<div class="tag-group">
{{#each B.data.policyNames as |policyName|}}
{{#let (find-by "name" policyName this.model.policies) as |policy|}}
{{#if policy}}
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="access-control.policies.policy" @model="{{policy.name}}" />
{{else}}
<Hds::Tag
{{hds-tooltip "This policy has been deleted"}}
@text="{{policyName}}"
/>
{{/if}}
{{/let}}
{{else}}
{{#if (eq B.data.type "management")}}
Management Access
{{else}}
No Policies
{{/if}}
{{/each}}
</div>
</B.Td>
{{#if (can "destroy token")}}
<B.Td data-test-delete-token>
{{#if (eq B.data.id this.selfToken.id)}}
<Tooltip @text="Can't delete your own token" @isFullText={{true}}>
<Hds::Button @text="Delete" disabled @size="small" @color="critical" />
</Tooltip>
{{else}}
<Hds::Button @text="Delete" @size="small" @color="critical"
{{on "click" (perform this.deleteToken B.data)}}
/>
{{/if}}
</B.Td>
{{/if}}
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<div class="empty-message">
<h3 data-test-empty-policies-list-headline class="empty-message-headline">
No Tokens
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}
</section>

View File

@ -0,0 +1,17 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="New" args=(array "access-control.tokens.new")}} />
{{page-title "Create Token"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
Create Token
</h1>
<TokenEditor
@token={{this.model.token}}
@roles={{this.model.roles}}
@policies={{this.model.policies}}
/>
</section>

View File

@ -0,0 +1,24 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label=this.activeToken.name args=(array "access-control.tokens.token" this.activeToken.id)}} />
{{page-title "Token"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
<div>
Edit Token
</div>
{{#if (can "destroy token")}}
<Hds::Button @text="Delete Token" @color="critical"
{{on "click" (perform this.deleteToken)}}
data-test-delete-token
/>
{{/if}}
</h1>
<TokenEditor
@token={{this.activeToken}}
@policies={{this.policies}}
@roles={{this.roles}}
/>
</section>

View File

@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
<ul>
<li><LinkTo @route="access-control.index" @activeClass="is-active">Overview</LinkTo></li>
<li><LinkTo @route="access-control.tokens" @activeClass="is-active">Tokens</LinkTo></li>
<li><LinkTo @route="access-control.roles" @activeClass="is-active">Roles</LinkTo></li>
<li><LinkTo @route="access-control.policies" @activeClass="is-active">Policies</LinkTo></li>
</ul>
</div>

View File

@ -135,16 +135,16 @@
<li
{{keyboard-shortcut
menuLevel=true
pattern=(array "g" "l")
action=(action this.transitionTo 'policies')
pattern=(array "g" "a")
action=(action this.transitionTo 'access-control')
}}
>
<LinkTo
@route="policies"
@route="access-control"
@activeClass="is-active"
data-test-gutter-link="policies"
data-test-gutter-link="access-control"
>
Policies
Access Control
</LinkTo>
</li>
{{/if}}

View File

@ -12,7 +12,7 @@
<Hds::Alert @type="inline" @color="highlight" as |A|>
<A.Title>Automatic Access to Variables</A.Title>
<A.Description>
<p>Tasks in this job can have <a href="https://developer.hashicorp.com/nomad/docs/concepts/variables#task-access-to-variables" target="_blank" rel="noopener noreferrer">automatic access to Nomad Variables</a>.</p>
<p>Tasks in this job can have <Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/docs/concepts/variables#task-access-to-variables" target="_blank" rel="noopener noreferrer">automatic access to Nomad Variables</Hds::Link::Inline>.</p>
<ul>
<li data-test-variables-intro-all-jobs>Use
<code>

View File

@ -3,12 +3,18 @@
SPDX-License-Identifier: MPL-2.0
~}}
{{page-title "Authorization"}}
{{page-title (if this.tokenRecord "Profile" "Sign In")}}
<section class="section authorization-page">
{{#if this.isValidatingToken}}
<LoadingSpinner />
{{else}}
<h1 class="title">Authorization and access control</h1>
<h1 class="title">
{{#if this.tokenRecord}}
Profile
{{else}}
Sign In
{{/if}}
</h1>
<div class="status-notifications {{if this.canSignIn "is-half"}}">
@ -159,6 +165,31 @@
<button data-test-token-clear class="button is-primary" {{action "clearTokenProperties"}} type="button">Sign Out</button>
</div>
</div>
{{#if this.tokenRecord.roles.length}}
<h3 class="title is-4">Roles</h3>
{{#each this.tokenRecord.roles as |role|}}
<div data-test-token-role class="boxed-section">
<div data-test-role-name class="boxed-section-head">
{{role.name}}
</div>
<div class="boxed-section-body">
{{#if role.description}}
<p class="content">
{{role.description}}
</p>
{{/if}}
<div data-test-role-policies>
<h4 class="title is-5">Policies</h4>
{{#each role.policies as |policy|}}
<li><a href="#{{policy.name}}">{{policy.name}}</a></li>
{{/each}}
</div>
</div>
</div>
{{/each}}
{{/if}}
<h3 class="title is-4">Policies</h3>
{{#if (eq this.tokenRecord.type "management")}}
<div data-test-token-management-message class="boxed-section">
@ -167,8 +198,8 @@
</div>
</div>
{{else}}
{{#each this.tokenRecord.policies as |policy|}}
<div data-test-token-policy class="boxed-section">
{{#each this.tokenRecord.combinedPolicies as |policy|}}
<div id="{{policy.name}}" data-test-token-policy class="boxed-section">
<div data-test-policy-name class="boxed-section-head">
{{policy.name}}
</div>

View File

@ -299,8 +299,16 @@ export default function () {
});
if (token) {
const { policyIds } = token;
const policies = server.db.policies.find(policyIds);
const policyIds = token.policyIds || [];
const roleIds = token.roleIds || [];
const roles = server.db.roles.find(roleIds);
const rolePolicyIds = roles.map((role) => role.policyIds).flat();
const policies = server.db.policies.find([
...policyIds,
...rolePolicyIds,
]);
const hasReadPolicy = policies.find(
(p) =>
p.rulesJSON.Node?.Policy === 'read' ||
@ -476,16 +484,59 @@ export default function () {
});
this.post('/acl/token', function (schema, request) {
const { Name, Policies, Type } = JSON.parse(request.requestBody);
const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse(
request.requestBody
);
function parseDuration(duration) {
const [_, value, unit] = duration.match(/(\d+)(\w)/);
const unitMap = {
s: 1000,
m: 1000 * 60,
h: 1000 * 60 * 60,
d: 1000 * 60 * 60 * 24,
};
return value * unitMap[unit];
}
// If there's an expirationTime, use that. Otherwise, use the TTL.
const expirationTime = ExpirationTime
? new Date(ExpirationTime)
: ExpirationTTL
? new Date(Date.now() + parseDuration(ExpirationTTL))
: null;
return server.create('token', {
name: Name,
policyIds: Policies,
type: Type,
id: faker.random.uuid(),
expirationTime,
createTime: new Date().toISOString(),
});
});
this.post('/acl/token/:id', function (schema, request) {
// If both Policies and Roles arrays are empty, return an error
const { Policies, Roles } = JSON.parse(request.requestBody);
if (!Policies.length && !Roles.length) {
return new Response(
500,
{},
'Either Policies or Roles must be specified'
);
}
return new Response(
200,
{},
{
id: request.params.id,
Policies,
Roles,
}
);
});
this.get('/acl/token/self', function ({ tokens }, req) {
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });
@ -557,7 +608,6 @@ export default function () {
const policy = policies.findBy({ name: req.params.id });
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });
if (req.params.id === 'anonymous') {
if (policy) {
return this.serialize(policy);
@ -565,13 +615,15 @@ export default function () {
return new Response(404, {}, null);
}
}
// Return the policy only if the token that matches the request header
// includes the policy or if the token that matches the request header
// is of type management
if (
tokenForSecret &&
(tokenForSecret.policies.includes(policy) ||
tokenForSecret.roles.models.any((role) =>
role.policies.includes(policy)
) ||
tokenForSecret.type === 'management')
) {
return this.serialize(policy);
@ -581,21 +633,82 @@ export default function () {
return new Response(403, {}, null);
});
this.get('/acl/roles', function ({ roles }, req) {
return this.serialize(roles.all());
});
this.get('/acl/role/:id', function ({ roles }, req) {
const role = roles.findBy({ id: req.params.id });
return this.serialize(role);
});
this.post('/acl/role', function (schema, request) {
const { Name, Description } = JSON.parse(request.requestBody);
return server.create('role', {
name: Name,
description: Description,
});
});
this.put('/acl/role/:id', function (schema, request) {
const { Policies } = JSON.parse(request.requestBody);
if (!Policies.length) {
return new Response(500, {}, 'Policies must be specified');
}
return new Response(
200,
{},
{
id: request.params.id,
Policies,
}
);
});
this.delete('/acl/role/:id', function (schema, request) {
const { id } = request.params;
// Also update any tokens whose policyIDs include this policy
const tokens =
server.schema.tokens.where((token) => token.roleIds?.includes(id)) || [];
tokens.models.forEach((token) => {
token.update({
roleIds: token.roleIds.filter((roleId) => roleId !== id),
});
});
server.db.roles.remove(id);
return '';
});
this.get('/acl/policies', function ({ policies }, req) {
return this.serialize(policies.all());
});
this.delete('/acl/policy/:id', function (schema, request) {
const { id } = request.params;
schema.tokens
.all()
.models.filter((token) => token.policyIds.includes(id))
.forEach((token) => {
// 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((pid) => pid !== id),
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 '';
});

View File

@ -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"

View File

@ -19,10 +19,14 @@ export default Factory.extend({
oneTimeSecret: () => faker.random.uuid(),
afterCreate(token, server) {
if (token.policyIds && token.policyIds.length) return;
// 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())
.map(() => faker.hacker.verb().replace(/\s/g, '-'))
.uniq();
policyIds.forEach((policy) => {
@ -37,7 +41,7 @@ export default Factory.extend({
// Create a special policy with variables rules in place
if (token.id === '53cur3-v4r14bl35') {
const variableMakerPolicy = {
id: 'Variable Maker',
id: 'Variable-Maker',
rules: `
# Allow read only access to the default namespace
namespace "*" {
@ -78,7 +82,7 @@ node {
}
if (token.id === 'f3w3r-53cur3-v4r14bl35') {
const variableViewerPolicy = {
id: 'Variable Viewer',
id: 'Variable-Viewer',
rules: `
# Allow read only access to the default namespace
namespace "*" {
@ -175,5 +179,6 @@ node {
expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000),
});
}
}
},
});

11
ui/mirage/models/token.js Normal file
View File

@ -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(),
});

View File

@ -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' });

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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",

View File

@ -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'
);
});
});

View File

@ -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');
});

View File

@ -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;
});
});

View File

@ -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);

View File

@ -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');
});
});

View File

@ -4,7 +4,14 @@
*/
/* eslint-disable qunit/require-expect */
import { currentURL, find, findAll, visit, click } from '@ember/test-helpers';
import {
currentURL,
find,
findAll,
visit,
click,
fillIn,
} from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
@ -14,6 +21,7 @@ import Jobs from 'nomad-ui/tests/pages/jobs/list';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Layout from 'nomad-ui/tests/pages/layout';
import AccessControl from 'nomad-ui/tests/pages/access-control';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
import moment from 'moment';
@ -28,6 +36,7 @@ let job;
let node;
let managementToken;
let clientToken;
module('Acceptance | tokens', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@ -61,7 +70,7 @@ module('Acceptance | tokens', function (hooks) {
null,
'No token secret set'
);
assert.ok(document.title.includes('Authorization'));
assert.ok(document.title.includes('Sign In'));
await Tokens.secret(secretId).submit();
assert.equal(
@ -567,7 +576,6 @@ module('Acceptance | tokens', function (hooks) {
assert.dom('.dropdown-options').exists('Dropdown options are shown');
await selectChoose('[data-test-select-jwt]', 'JWT-Regional');
console.log(currentURL());
assert.equal(
currentURL(),
'/settings/tokens?jwtAuthMethod=JWT-Regional',
@ -586,21 +594,24 @@ module('Acceptance | tokens', function (hooks) {
);
});
test('Tokens are shown on the policies index page', async function (assert) {
test('Tokens are shown on the Access Control Policies index page', async function (assert) {
allScenarios.policiesTestCluster(server);
let firstPolicy = server.db.policies.sort((a, b) => {
return a.name.localeCompare(b.name);
})[0];
// Create an expired token
server.create('token', {
name: 'Expired Token',
id: 'just-expired',
policyIds: [server.db.policies[0].name],
policyIds: [firstPolicy.name],
expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/policies');
assert.dom('[data-test-policy-token-count]').exists();
await visit('/access-control/policies');
assert.dom('[data-test-policy-total-tokens]').exists();
const expectedFirstPolicyTokens = server.db.tokens.filter((token) => {
return token.policyIds.includes(server.db.policies[0].name);
return token.policyIds.includes(firstPolicy.name);
});
assert
.dom('[data-test-policy-total-tokens]')
@ -611,22 +622,25 @@ module('Acceptance | tokens', function (hooks) {
test('Tokens are shown on a policy page', async function (assert) {
allScenarios.policiesTestCluster(server);
let firstPolicy = server.db.policies.sort((a, b) => {
return a.name.localeCompare(b.name);
})[0];
// Create an expired token
server.create('token', {
name: 'Expired Token',
id: 'just-expired',
policyIds: [server.db.policies[0].name],
policyIds: [firstPolicy.name],
expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/policies');
await click('[data-test-policy-row]:first-child');
assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`);
await visit('/access-control/policies');
await click('[data-test-policy-name]');
assert.equal(currentURL(), `/access-control/policies/${firstPolicy.name}`);
const expectedFirstPolicyTokens = server.db.tokens.filter((token) => {
return token.policyIds.includes(server.db.policies[0].name);
return token.policyIds.includes(firstPolicy.name);
});
assert
@ -635,14 +649,25 @@ module('Acceptance | tokens', function (hooks) {
{ count: expectedFirstPolicyTokens.length },
'Expected number of tokens are shown'
);
assert.dom('[data-test-token-expiration-time]').hasText('10 minutes ago');
const expiredTokenRow = [...findAll('[data-test-policy-token-row]')].find(
(a) => a.textContent.includes('Expired Token')
);
assert.dom(expiredTokenRow).exists();
assert
.dom(expiredTokenRow.querySelector('[data-test-token-expiration-time]'))
.hasText('10 minutes ago');
window.localStorage.nomadTokenSecret = null;
});
test('Tokens Deletion', async function (assert) {
test('Tokens Deletion from Policy page', async function (assert) {
allScenarios.policiesTestCluster(server);
const testPolicy = server.db.policies[0];
let testPolicy = server.db.policies.sort((a, b) => {
return a.name.localeCompare(b.name);
})[0];
const existingTokens = server.db.tokens.filter((t) =>
t.policyIds.includes(testPolicy.name)
);
@ -654,28 +679,22 @@ module('Acceptance | tokens', function (hooks) {
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/policies');
await visit('/access-control/policies');
await click('[data-test-policy-row]:first-child');
assert.equal(currentURL(), `/policies/${testPolicy.name}`);
await click('[data-test-policy-name]:first-child');
assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`);
assert
.dom('[data-test-policy-token-row]')
.exists(
{ count: existingTokens.length + 1 },
'Expected number of tokens are shown'
);
const doomedTokenRow = [...findAll('[data-test-policy-token-row]')].find(
(a) => a.textContent.includes('Doomed Token')
);
assert.dom(doomedTokenRow).exists();
await click(doomedTokenRow.querySelector('button'));
assert
.dom(doomedTokenRow.querySelector('[data-test-confirm-button]'))
.exists();
await click(doomedTokenRow.querySelector('[data-test-confirm-button]'));
assert.dom('.flash-message.alert-success').exists();
assert
.dom('[data-test-policy-token-row]')
@ -687,18 +706,21 @@ module('Acceptance | tokens', function (hooks) {
window.localStorage.nomadTokenSecret = null;
});
test('Test Token Creation', async function (assert) {
test('Test Token Creation from Policy Page', async function (assert) {
allScenarios.policiesTestCluster(server);
const testPolicy = server.db.policies[0];
let testPolicy = server.db.policies.sort((a, b) => {
return a.name.localeCompare(b.name);
})[0];
const existingTokens = server.db.tokens.filter((t) =>
t.policyIds.includes(testPolicy.name)
);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/policies');
await visit('/access-control/policies');
await click('[data-test-policy-row]:first-child');
assert.equal(currentURL(), `/policies/${testPolicy.name}`);
await click('[data-test-policy-name]');
assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`);
assert
.dom('[data-test-policy-token-row]')
@ -730,4 +752,534 @@ module('Acceptance | tokens', function (hooks) {
requestHeaders[name.toUpperCase()]
);
}
module('Roles', function (hooks) {
// Set up a token with a role
hooks.beforeEach(function () {
window.localStorage.clear();
window.sessionStorage.clear();
faker.seed(1);
allScenarios.rolesTestCluster(server);
});
test('Policies are derived from role', async function (assert) {
assert.expect(19);
await Tokens.visit();
let token;
// User with 1 role, containing 1 policy, and no direct policies
token = server.db.tokens.findBy(
(t) => t.name === 'High Level Role Token'
);
await Tokens.secret(token.secretId).submit();
assert.dom('[data-test-token-role]').exists({ count: 1 });
assert.dom('[data-test-role-name]').hasText('high-level');
assert.dom('[data-test-role-policies] li').exists({ count: 1 });
assert.dom('[data-test-role-policies] li').hasText('job-writer');
assert.dom('[data-test-token-policy]').exists({ count: 1 });
assert.dom('[data-test-policy-name]').hasText('job-writer');
await Tokens.clear();
// User with 1 role, containing 2 policies, and a direct policy
token = server.db.tokens.findBy(
(t) => t.name === 'Policy And Role Token'
);
await Tokens.secret(token.secretId).submit();
assert.dom('[data-test-token-role]').exists({ count: 1 });
assert.dom('[data-test-role-name]').hasText('reader');
assert.dom('[data-test-role-policies] li').exists({ count: 2 });
let policyLinks = findAll('[data-test-role-policies] li');
assert.dom(policyLinks[0]).hasText('client-reader');
assert.dom(policyLinks[1]).hasText('job-reader');
assert.dom('[data-test-token-policy]').exists({ count: 3 });
let policyBlocks = findAll('[data-test-policy-name]');
assert.dom(policyBlocks[0]).hasText('operator');
assert.dom(policyBlocks[1]).hasText('client-reader');
assert.dom(policyBlocks[2]).hasText('job-reader');
await percySnapshot(assert);
await Tokens.clear();
// User with 2 roles, each containing 1 policy, and one of the policies is also directly on their token
token = server.db.tokens.findBy(
(t) => t.name === 'Multi Role And Policy Token'
);
await Tokens.secret(token.secretId).submit();
assert.equal(token.roleIds.length, 2);
assert.equal(token.policyIds.length, 1);
assert.dom('[data-test-token-role]').exists({ count: 2 });
assert.dom('[data-test-token-policy]').exists({ count: 2 });
});
test('Token priveleges are derived from role', async function (assert) {
// First, check that a node reader can read nodes if the policy to do so only exists at their role level
await visit('/clients');
// Expect to see some nodes
let nodes = server.db.nodes;
assert.dom('[data-test-client-node-row]').exists({ count: nodes.length });
// Head back and sign in as Clientless Role Token
await Tokens.visit();
let token = server.db.tokens.findBy(
(t) => t.name === 'Clientless Role Token'
);
await Tokens.secret(token.secretId).submit();
await visit('/clients');
// Expect no rows, and a denied message
assert.dom('[data-test-client-node-row]').doesNotExist();
assert.dom('[data-test-error]').exists();
// Pop over to the jobs page and make sure the Run button is disabled
await visit('/jobs');
assert.dom('[data-test-run-job]').hasTagName('button');
assert.dom('[data-test-run-job]').isDisabled();
// Sign out, and sign back in as a high-level role token
await Tokens.visit();
await Tokens.clear();
token = server.db.tokens.findBy(
(t) => t.name === 'High Level Role Token'
);
await Tokens.secret(token.secretId).submit();
await visit('/jobs');
// Expect the Run button/link to work now
assert.dom('[data-test-run-job]').hasTagName('a');
assert.dom('[data-test-run-job]').hasAttribute('href', '/ui/jobs/run');
});
});
module('Access Control Tokens section', function (hooks) {
hooks.beforeEach(async function () {
window.localStorage.clear();
window.sessionStorage.clear();
faker.seed(1);
allScenarios.rolesTestCluster(server);
await Tokens.visit();
const managementToken = server.db.tokens.findBy(
(t) => t.type === 'management'
);
const { secretId } = managementToken;
await Tokens.secret(secretId).submit();
await AccessControl.visitTokens();
});
hooks.afterEach(async function () {
await Tokens.visit();
await Tokens.clear();
});
test('Tokens index, general', async function (assert) {
assert.equal(currentURL(), '/access-control/tokens');
// Number of token rows equivalent to number in db
assert
.dom('[data-test-token-row]')
.exists({ count: server.db.tokens.length });
await percySnapshot(assert);
});
test('Tokens index, management token handling', async function (assert) {
// two management tokens, one of which is yours; yours cannot be deleted or clicked into.
assert.dom('[data-test-token-type="management"]').exists({ count: 2 });
const managementToken = server.db.tokens.findBy(
(t) => t.type === 'management'
);
const managementTokenRow = [...findAll('[data-test-token-row]')].find(
(row) => row.textContent.includes(managementToken.name)
);
const otherManagerRow = [...findAll('[data-test-token-row]')].find(
(row) =>
row.textContent.includes('management') &&
!row.textContent.includes(managementToken.name)
);
assert
.dom(managementTokenRow.querySelector('[data-test-token-name] a'))
.doesNotExist('Cannot click into and edit your own token');
assert
.dom(otherManagerRow.querySelector('[data-test-token-name] a'))
.exists('Can click into and edit another manager token');
assert
.dom(
managementTokenRow.querySelector('[data-test-delete-token] button')
)
.isDisabled('Cannot delete your own token');
assert
.dom(otherManagerRow.querySelector('[data-test-delete-token] button'))
.isNotDisabled('Can delete another manager token');
});
test('Tokens index, table sorting', async function (assert) {
const nameCells = findAll('[data-test-token-name]');
const nameCellText = nameCells.map((cell) => cell.textContent.trim());
const sortedNameCellText = nameCellText.slice().sort();
assert.deepEqual(
nameCellText,
sortedNameCellText,
'Names are sorted alphabetically'
);
// Click on the first thead tr th to reverse
assert
.dom('table.acl-table thead tr th')
.hasAttribute('aria-sort', 'ascending');
await click('table.acl-table thead tr th button');
assert
.dom('table.acl-table thead tr th')
.hasAttribute('aria-sort', 'descending');
const reversedNameCells = findAll('[data-test-token-name]');
const reversedNameCellText = reversedNameCells.map((cell) =>
cell.textContent.trim()
);
const reversedSortedNameCellText = nameCellText.slice().sort().reverse();
assert.deepEqual(
reversedNameCellText,
reversedSortedNameCellText,
'Names are reversed alphabetically'
);
});
test('Tokens index, deletion', async function (assert) {
const numberOfTokens = server.db.tokens.length;
assert
.dom('[data-test-token-row]')
.exists(
{ count: numberOfTokens },
'Number of tokens matches number in db'
);
const tokenToDelete = server.db.tokens.findBy((t) => t.type === 'client');
const tokenRowToDelete = [...findAll('[data-test-token-row]')].find(
(row) => row.textContent.includes(tokenToDelete.name)
);
await click(
tokenRowToDelete.querySelector('[data-test-delete-token] button')
);
assert.dom('.flash-message.alert-success').exists();
assert
.dom('[data-test-token-row]')
.exists(
{ count: numberOfTokens - 1 },
'Number of token rows decreased after deletion'
);
const nameCells = findAll('[data-test-token-name]');
const nameCellText = nameCells.map((cell) => cell.textContent.trim());
assert.notOk(
nameCellText.includes(tokenToDelete.name),
'Deleted token name not found among name cells'
);
});
test('Tokens index, clicking into a token page', async function (assert) {
const tokenToClick = server.db.tokens.findBy((t) => t.type === 'client');
const tokenRowToClick = [...findAll('[data-test-token-row]')].find(
(row) => row.textContent.includes(tokenToClick.name)
);
await click(tokenRowToClick.querySelector('[data-test-token-name] a'));
assert.equal(currentURL(), `/access-control/tokens/${tokenToClick.id}`);
assert.dom('[data-test-token-name-input]').hasValue(tokenToClick.name);
});
test('Tokens index, roles and policies attached to a token show up as links', async function (assert) {
// Staying on the index page, Rows should have a Roles column with either "No Roles" or a bunch of links to roles. Ditto policies.
const tokenWithRolesAndPolicies = server.db.tokens.findBy(
(t) => t.name === 'Multi Role And Policy Token'
);
const tokenRowWithRolesAndPolicies = [
...findAll('[data-test-token-row]'),
].find((row) => row.textContent.includes(tokenWithRolesAndPolicies.name));
const rolesCell = tokenRowWithRolesAndPolicies.querySelector(
'[data-test-token-roles]'
);
const policiesCell = tokenRowWithRolesAndPolicies.querySelector(
'[data-test-token-policies]'
);
assert.dom(rolesCell).exists();
assert.dom(policiesCell).exists();
const rolesCellTags = rolesCell
.querySelector('.tag-group')
.querySelectorAll('span');
const policiesCellTags = policiesCell
.querySelector('.tag-group')
.querySelectorAll('span');
assert.equal(rolesCellTags.length, 2);
assert.equal(policiesCellTags.length, 1);
const policyLessToken = server.db.tokens.findBy(
(t) => t.name === 'High Level Role Token'
);
const policyLessTokenRow = [...findAll('[data-test-token-row]')].find(
(row) => row.textContent.includes(policyLessToken.name)
);
const rolesCell2 = policyLessTokenRow.querySelector(
'[data-test-token-roles]'
);
const policiesCell2 = policyLessTokenRow.querySelector(
'[data-test-token-policies]'
);
assert.dom(rolesCell2).exists();
assert.dom(policiesCell2).exists();
const rolesCellTags2 = rolesCell2
.querySelector('.tag-group')
.querySelectorAll('span');
const policiesCellTags2 = policiesCell2
.querySelector('.tag-group')
.querySelectorAll('span');
assert.equal(rolesCellTags2.length, 1);
assert.equal(policiesCellTags2.length, 0);
});
test('Token page, general', async function (assert) {
const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
await visit(`/access-control/tokens/${token.id}`);
assert.dom('[data-test-token-name-input]').hasValue(token.name);
assert.dom('[data-test-token-accessor]').hasValue(token.accessorId);
assert.dom('[data-test-token-secret]').hasValue(token.secretId);
assert.dom('[data-test-token-type="client"]').isChecked();
assert.dom('[data-test-token-type="management"]').isNotChecked();
assert.dom('.expiration-time').hasText('Token expires in an hour');
assert.dom('[data-test-token-roles]').exists();
assert.dom('[data-test-token-policies]').exists();
// All possible policies are shown
const allPolicies = server.db.policies;
const allPolicyRows = findAll('[data-test-token-policies] tbody tr');
assert.equal(
allPolicyRows.length,
allPolicies.length,
'All policies are shown'
);
// The policies/roles belonging to this token are checked
const tokenPolicies = token.policyIds;
const checkedPolicyRows = findAll(
'[data-test-token-policies] tbody tr input:checked'
);
assert.equal(
checkedPolicyRows.length,
tokenPolicies.length,
'All policies belonging to this token are checked'
);
const checkedPolicyNames = checkedPolicyRows.map((row) =>
row
.closest('tr')
.querySelector('[data-test-policy-name]')
.textContent.trim()
);
assert.deepEqual(
checkedPolicyNames.sort(),
tokenPolicies.sort(),
'All policies belonging to this token are checked'
);
const allRoles = server.db.roles;
const allRoleRows = findAll('[data-test-token-roles] tbody tr');
assert.equal(allRoleRows.length, allRoles.length, 'All roles are shown');
const tokenRoles = token.roleIds;
const checkedRoleRows = findAll(
'[data-test-token-roles] tbody tr input:checked'
);
assert.equal(
checkedRoleRows.length,
tokenRoles.length,
'All roles belonging to this token are checked'
);
const checkedRoleNames = checkedRoleRows.map((row) =>
row
.closest('tr')
.querySelector('[data-test-role-name]')
.textContent.trim()
);
assert.deepEqual(
checkedRoleNames.sort(),
tokenRoles.sort(),
'All roles belonging to this token are checked'
);
});
test('Token name can be edited', async function (assert) {
const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
await visit(`/access-control/tokens/${token.id}`);
assert.dom('[data-test-token-name-input]').hasValue(token.name);
await fillIn('[data-test-token-name-input]', 'Mud-Token');
await click('[data-test-token-save]');
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitTokens();
assert.dom('[data-test-token-name="Mud-Token"]').exists({ count: 1 });
});
test('Token policies and roles can be edited', async function (assert) {
const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
await visit(`/access-control/tokens/${token.id}`);
// The policies/roles belonging to this token are checked
const tokenPolicies = token.policyIds;
const checkedPolicyRows = findAll(
'[data-test-token-policies] tbody tr input:checked'
);
assert.equal(
checkedPolicyRows.length,
tokenPolicies.length,
'All policies belonging to this token are checked'
);
const checkedPolicyNames = checkedPolicyRows.map((row) =>
row
.closest('tr')
.querySelector('[data-test-policy-name]')
.textContent.trim()
);
assert.deepEqual(
checkedPolicyNames.sort(),
tokenPolicies.sort(),
'All policies belonging to this token are checked'
);
// Try unchecking ALL checked roles and policies and saving
// First, find all checked ones
const checkedPolicies = findAll(
'[data-test-token-policies] tbody tr input:checked'
);
const checkedRoles = findAll(
'[data-test-token-roles] tbody tr input:checked'
);
// Then uncheck them
checkedPolicies.forEach((policy) => {
policy.click();
});
checkedRoles.forEach((role) => {
role.click();
});
await click('[data-test-token-save]');
assert.dom('.flash-message.alert-critical').exists();
// Try selecting a single role
await click('[data-test-token-roles] tbody tr input');
await click('[data-test-token-save]');
assert.dom('.flash-message.alert-success').exists();
await percySnapshot(assert);
await AccessControl.visitTokens();
// Policies cell for our clay token should read "No Policies"
const clayToken = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
const clayTokenRow = [...findAll('[data-test-token-row]')].find((row) =>
row.textContent.includes(clayToken.name)
);
const policiesCell = clayTokenRow.querySelector(
'[data-test-token-policies]'
);
assert.dom(policiesCell).exists();
assert.dom(policiesCell).hasText('No Policies');
// Roles cell should have 1 tag
const rolesCell = clayTokenRow.querySelector('[data-test-token-roles]');
const rolesCellTags = rolesCell
.querySelector('.tag-group')
.querySelectorAll('span');
assert.equal(rolesCellTags.length, 1);
});
test('Token can be deleted', async function (assert) {
const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
await visit(`/access-control/tokens/${token.id}`);
await click('[data-test-delete-token]');
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitTokens();
assert.dom('[data-test-token-name="cl4y-t0k3n"]').doesNotExist();
});
test('New Token creation', async function (assert) {
await click('[data-test-create-token]');
assert.equal(currentURL(), '/access-control/tokens/new');
await fillIn('[data-test-token-name-input]', 'Timeless Token');
await click('[data-test-token-save]');
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitTokens();
assert
.dom('[data-test-token-name="Timeless Token"]')
.exists({ count: 1 });
const newTokenRow = [...findAll('[data-test-token-row]')].find((row) =>
row.textContent.includes('Timeless Token')
);
const newTokenExpirationCell = newTokenRow.querySelector(
'[data-test-token-expiration-time]'
);
assert.dom(newTokenExpirationCell).hasText('Never');
// Now create one with a TTL
await click('[data-test-create-token]');
assert.equal(currentURL(), '/access-control/tokens/new');
await fillIn('[data-test-token-name-input]', 'TTL Token');
// Select the "8 hours" radio within the .expiration-time div
await click('.expiration-time input[value="8h"]');
await click('[data-test-token-save]');
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitTokens();
assert.dom('[data-test-token-name="TTL Token"]').exists({ count: 1 });
const ttlTokenRow = [...findAll('[data-test-token-row]')].find((row) =>
row.textContent.includes('TTL Token')
);
const ttlTokenExpirationCell = ttlTokenRow.querySelector(
'[data-test-token-expiration-time]'
);
assert.dom(ttlTokenExpirationCell).hasText('in 8 hours');
// Now create one with an expiration time
await click('[data-test-create-token]');
assert.equal(currentURL(), '/access-control/tokens/new');
await fillIn('[data-test-token-name-input]', 'Expiring Token');
// select the Custom radio button
await click('.expiration-time input[value="custom"]');
assert
.dom('[data-test-token-expiration-time-input]')
.exists('HTML datetime-local picker exists');
await percySnapshot(assert);
// select a date/time for 100 minutes into the future in GMT
const soon = new Date();
soon.setMinutes(soon.getMinutes() + 100);
var tzoffset = new Date().getTimezoneOffset() * 60000; //offset in milliseconds
var soonString = new Date(soon - tzoffset).toISOString().slice(0, -1);
await fillIn('[data-test-token-expiration-time-input]', soonString);
await click('[data-test-token-save]');
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitTokens();
assert
.dom('[data-test-token-name="Expiring Token"]')
.exists({ count: 1 });
const expiringTokenRow = [...findAll('[data-test-token-row]')].find(
(row) => row.textContent.includes('Expiring Token')
);
const expiringTokenExpirationCell = expiringTokenRow.querySelector(
'[data-test-token-expiration-time]'
);
assert
.dom(expiringTokenExpirationCell)
.hasText('in 2 hours', 'Expiration time is relativized and rounded');
});
});
});

View File

@ -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'];

View File

@ -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'),
});

View File

@ -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"

View File

@ -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.