[ui] Sentinel Policies CRUD UI (#20483)

* Gallery allows picking stuff

* Small fixes

* added sentinel templates

* Can set enforcement level on policies

* Working on the interactive sentinel dev mode

* Very rough development flow on FE

* Changed position in gutter menu

* More sentinel stuff

* PR cleanup: removed testmode, removed unneeded mixins and deps

* Heliosification

* Index-level sentinel policy deletion and page title fixes

* Makes the Canaries sentinel policy real and then comments out the unfinished ones

* rename Access Control to Administration in prep for moving Sentinel Policies and Node Pool admin there

* Sentinel policies moved within the Administration section

* Mirage fixture for sentinel policy endpoints

* Description length check and 500 prevention

* Sync review PR feedback addressed, implied butons on radio cards

* Cull un-used sentinel policies

---------

Co-authored-by: Mike Nomitch <mail@mikenomitch.com>
This commit is contained in:
Phil Renaud
2024-05-22 16:41:50 -04:00
committed by GitHub
parent 4415fabe7d
commit 86c858cdc3
89 changed files with 1138 additions and 231 deletions

3
.changelog/20483.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Added a UI for creating, editing and deleting Sentinel Policies
```

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import AbstractAbility from './abstract';
import { alias, and, or } from '@ember/object/computed';
import { computed } from '@ember/object';
export default class SentinelPolicy extends AbstractAbility {
@alias('hasFeatureAndManagement') canRead;
@alias('hasFeatureAndManagement') canList;
@alias('hasFeatureAndManagement') canWrite;
@alias('hasFeatureAndManagement') canUpdate;
@alias('hasFeatureAndManagement') canDestroy;
@or('bypassAuthorization', 'selfTokenIsManagement')
hasAuthority;
@and('sentinelIsPresent', 'hasAuthority')
hasFeatureAndManagement;
@computed('features.[]')
get sentinelIsPresent() {
return this.featureIsPresent('Sentinel Policies');
}
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { default as ApplicationAdapter } from './application';
import classic from 'ember-classic-decorator';
@classic
export default class SentinelPolicyAdapter extends ApplicationAdapter {
pathForType = () => 'sentinel/policies';
// namespace = namespace + '/acl';
urlForCreateRecord(_modelName, model) {
return this.urlForUpdateRecord(model.attr('name'), 'sentinel/policy');
}
urlForFindRecord(id) {
return '/v1/sentinel/policy/' + id;
}
urlForDeleteRecord(id) {
return '/v1/sentinel/policy/' + id;
}
}

View File

@@ -8,6 +8,6 @@ import { tagName } from '@ember-decorators/component';
import { inject as service } from '@ember/service';
@tagName('')
export default class AccessControlSubnav extends Component {
export default class AdministrationSubnav extends Component {
@service keyboard;
}

View File

@@ -78,7 +78,7 @@ export default class NamespaceEditorComponent extends Component {
if (shouldRedirectAfterSave) {
this.router.transitionTo(
'access-control.namespaces.acl-namespace',
'administration.namespaces.acl-namespace',
this.namespace.name
);
}

View File

@@ -26,12 +26,12 @@
class="policy-editor"
data-test-policy-editor
{{code-mirror
screenReaderLabel="Policy definition"
theme="hashi"
mode="ruby"
content=@policy.rules
onUpdate=this.updatePolicyRules
autofocus=false
screenReaderLabel="Policy definition"
theme="hashi"
mode="ruby"
content=@policy.rules
onUpdate=this.updatePolicyRules
autofocus=false
extraKeys=(hash Cmd-Enter=this.save)
}} />
</div>

View File

@@ -60,7 +60,7 @@ export default class PolicyEditorComponent extends Component {
if (shouldRedirectAfterSave) {
this.router.transitionTo(
'access-control.policies.policy',
'administration.policies.policy',
this.policy.id
);
}

View File

@@ -55,7 +55,7 @@
<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}}>
<LinkTo @route="administration.policies.policy" @model={{B.data.name}}>
View Policy Definition
</LinkTo>
</B.Td>

View File

@@ -73,7 +73,7 @@ export default class RoleEditorComponent extends Component {
});
if (shouldRedirectAfterSave) {
this.router.transitionTo('access-control.roles.role', this.role.id);
this.router.transitionTo('administration.roles.role', this.role.id);
}
} catch (err) {
let message = err.errors?.length

View File

@@ -0,0 +1,90 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form class="acl-form" autocomplete="off" {{on "submit" this.save}}>
{{#if @policy.isNew }}
<Hds::Form::TextInput::Field
@isRequired={{true}}
data-test-policy-name-input
@value={{@policy.name}}
{{on "input" this.updatePolicyName}}
{{autofocus}}
as |F|>
<F.Label>Policy Name</F.Label>
</Hds::Form::TextInput::Field>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">
Policy Definition
</div>
<div class="boxed-section-body is-full-bleed">
<div
class="policy-editor"
data-test-policy-editor
{{code-mirror
screenReaderLabel="Policy definition"
theme="hashi"
mode="ruby"
content=@policy.policy
onUpdate=this.updatePolicy
autofocus=false
extraKeys=(hash Cmd-Enter=this.save)
}} />
</div>
</div>
<div>
<label>
<span>
Description (optional)
</span>
<Input
data-test-policy-description
@value={{@policy.description}}
class="input"
/>
</label>
</div>
<div>
<Hds::Form::Radio::Group @layout="horizontal" @name="method-demo1" {{on "change" this.updatePolicyEnforcementLevel}} as |G|>
<G.Legend>Enforcement Level</G.Legend>
<G.HelperText>See <Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-tokens#token-types">Sentinel Policy documentation</Hds::Link::Inline> for more information.</G.HelperText>
<G.Radio::Field
@id="advisory"
checked={{eq @policy.enforcementLevel "advisory"}}
data-test-token-type="client"
as |F|>
<F.Label>Advisory</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="soft-mandatory"
checked={{eq @policy.enforcementLevel "soft-mandatory"}}
data-test-token-type="soft-mandatory"
as |F|>
<F.Label>Soft Mandatory</F.Label>
</G.Radio::Field>
<G.Radio::Field
@id="hard-mandatory"
checked={{eq @policy.enforcementLevel "hard-mandatory"}}
data-test-token-type="hard-mandatory"
as |F|>
<F.Label>Hard Mandatory</F.Label>
</G.Radio::Field>
</Hds::Form::Radio::Group>
</div>
<footer>
{{#if (can "update sentinel-policy")}}
<Hds::Button
@text="Save Policy"
@type="submit"
data-test-save-policy
{{on "click" this.save}}
/>
{{/if}}
</footer>
</form>

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// @ts-check
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default class SentinelPolicyEditorComponent extends Component {
@service notifications;
@service router;
@service store;
@alias('args.policy') policy;
@action updatePolicy(value) {
this.policy.set('policy', value);
}
@action updatePolicyName({ target: { value } }) {
this.policy.set('name', value);
}
@action updatePolicyEnforcementLevel({ target: { id } }) {
this.policy.set('enforcementLevel', id);
}
@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.policy.name?.match(nameRegex)) {
throw new Error(
`Policy name must be 1-128 characters long and can only contain letters, numbers, and dashes.`
);
}
if (this.policy.description?.length > 256) {
throw new Error(
`Policy description must be under 256 characters long.`
);
}
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
.peekAll('sentinel-policy')
.filter((policy) => policy !== this.policy)
.findBy('name', this.policy.name)
) {
throw new Error(
`A sentinel policy with name ${this.policy.name} already exists.`
);
}
this.policy.set('id', this.policy.name);
await this.policy.save();
this.notifications.add({
title: 'Sentinel Policy Saved',
color: 'success',
});
if (shouldRedirectAfterSave) {
this.router.transitionTo(
'administration.sentinel-policies.policy',
this.policy.name
);
}
} catch (err) {
let message = err.errors?.length
? messageFromAdapterError(err)
: err.message || 'Unknown Error';
this.notifications.add({
title: `Error creating Sentinel Policy ${this.policy.name}`,
message,
color: 'critical',
sticky: true,
});
}
}
}

View File

@@ -145,7 +145,7 @@
<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}}>
<LinkTo @route="administration.policies.policy" @model={{B.data.name}}>
View Policy Definition
</LinkTo>
</B.Td>
@@ -158,7 +158,7 @@
No Policies
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
Get started by <LinkTo @route="administration.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}
@@ -196,7 +196,7 @@
<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}}" />
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="administration.policies.policy" @model="{{policy.name}}" />
{{/if}}
{{else}}
Role contains no policies
@@ -204,7 +204,7 @@
</div>
</B.Td>
<B.Td>
<LinkTo @route="access-control.roles.role" @model={{B.data.id}}>
<LinkTo @route="administration.roles.role" @model={{B.data.id}}>
View Role Info
</LinkTo>
</B.Td>
@@ -217,7 +217,7 @@
No Roles
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.roles.new">creating a new role</LinkTo>
Get started by <LinkTo @route="administration.roles.new">creating a new role</LinkTo>
</p>
</div>
{{/if}}

View File

@@ -105,7 +105,7 @@ export default class TokenEditorComponent extends Component {
if (shouldRedirectAfterSave) {
this.router.transitionTo(
'access-control.tokens.token',
'administration.tokens.token',
this.activeToken.id
);
}

View File

@@ -15,6 +15,6 @@
job <Hds::Link::Inline @route="jobs.job" @model={{concat @job "@" @namespace}} @icon="external-link">{{@job}}</Hds::Link::Inline>
{{else}}
all nomad jobs in this namespace
{{/if}}
{{/if}}
</A.Description>
</Hds::Alert>

View File

@@ -24,7 +24,7 @@ export default class AccessControlNamespacesAclNamespaceController extends Contr
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('access-control.namespaces');
this.router.transitionTo('administration.namespaces');
} catch (err) {
// A failed delete resulted in errors when you then navigated away and back
// to the show page rollbackWithoutChangedAttrs fixes it, but there might

View File

@@ -14,13 +14,13 @@ export default class AccessControlNamespacesIndexController extends Controller {
@action openNamespace(namespace) {
this.router.transitionTo(
'access-control.namespaces.acl-namespace',
'administration.namespaces.acl-namespace',
namespace.name
);
}
@action goToNewNamespace() {
this.router.transitionTo('access-control.namespaces.new');
this.router.transitionTo('administration.namespaces.new');
}
get columns() {

View File

@@ -54,11 +54,11 @@ export default class AccessControlPoliciesIndexController extends Controller {
}
@action openPolicy(policy) {
this.router.transitionTo('access-control.policies.policy', policy.name);
this.router.transitionTo('administration.policies.policy', policy.name);
}
@action goToNewPolicy() {
this.router.transitionTo('access-control.policies.new');
this.router.transitionTo('administration.policies.new');
}
@task(function* (policy) {

View File

@@ -42,7 +42,7 @@ export default class AccessControlPoliciesPolicyController extends Controller {
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('access-control.policies');
this.router.transitionTo('administration.policies');
} catch (err) {
this.notifications.add({
title: `Error deleting Policy ${this.policy.name}`,

View File

@@ -60,11 +60,11 @@ export default class AccessControlRolesIndexController extends Controller {
}
@action openRole(role) {
this.router.transitionTo('access-control.roles.role', role.id);
this.router.transitionTo('administration.roles.role', role.id);
}
@action goToNewRole() {
this.router.transitionTo('access-control.roles.new');
this.router.transitionTo('administration.roles.new');
}
@task(function* (role) {

View File

@@ -32,7 +32,7 @@ export default class AccessControlRolesRoleController extends Controller {
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('access-control.roles');
this.router.transitionTo('administration.roles');
} catch (err) {
this.notifications.add({
title: `Error deleting Role ${this.role.name}`,

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
export default class AdministrationSentinelPoliciesController extends Controller {}

View File

@@ -0,0 +1,27 @@
/**
* 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 { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import TEMPLATES from 'nomad-ui/utils/default-sentinel-policy-templates';
export default class SentinelPoliciesNewGalleryController extends Controller {
@service notifications;
@service router;
@service store;
@tracked selectedTemplate = null;
get templates() {
return TEMPLATES;
}
@action
onChange(e) {
this.selectedTemplate = e.target.id;
}
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
export default class SentinelPoliciesIndexController extends Controller {
@service router;
@service notifications;
@action openPolicy(policy) {
this.router.transitionTo(
'administration.sentinel-policies.policy',
policy.name
);
}
@action goToNewPolicy() {
this.router.transitionTo('administration.sentinel-policies.new');
}
@action goToTemplateGallery() {
this.router.transitionTo('administration.sentinel-policies.gallery');
}
get columns() {
return [
{
key: 'name',
label: 'Name',
isSortable: true,
},
{
key: 'description',
label: 'Description',
},
{
key: 'enforcementLevel',
label: 'Enforcement Level',
isSortable: true,
},
{
key: 'delete',
label: 'Delete',
},
];
}
@task(function* (policy) {
try {
yield policy.deleteRecord();
yield policy.save();
if (this.store.peekRecord('policy', policy.id)) {
this.store.unloadRecord(policy);
}
this.notifications.add({
title: `Sentinel policy ${policy.name} successfully deleted`,
color: 'success',
});
} catch (err) {
this.notifications.add({
title: 'Error deleting policy',
color: 'critical',
sticky: true,
message: err,
});
throw err;
}
})
deletePolicy;
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// @ts-check
import Controller from '@ember/controller';
export default class SentinelPoliciesNewController extends Controller {
queryParams = ['template'];
}

View File

@@ -0,0 +1,46 @@
/**
* 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 { task } from 'ember-concurrency';
import rollbackWithoutChangedAttrs from 'nomad-ui/utils/rollback-without-changed-attrs';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default class SentinelPoliciesPolicyController extends Controller {
@service notifications;
@service router;
@task(function* () {
try {
yield this.model.destroyRecord();
this.notifications.add({
title: 'Policy Deleted',
color: 'success',
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('administration.sentinel-policies.index');
} catch (err) {
// A failed delete resulted in errors when you then navigated away and back
// to the show page rollbackWithoutChangedAttrs fixes it, but there might
// be a more idiomatic way
rollbackWithoutChangedAttrs(this.model);
let message = err.errors?.length
? messageFromAdapterError(err)
: err.message || 'Unknown Error';
this.notifications.add({
title: `Error deleting Policy ${this.model.name}`,
message,
color: 'critical',
sticky: true,
});
}
})
deletePolicy;
}

View File

@@ -38,10 +38,10 @@ export default class AccessControlTokensIndexController extends Controller {
}
@action openToken(token) {
this.router.transitionTo('access-control.tokens.token', token.id);
this.router.transitionTo('administration.tokens.token', token.id);
}
@action goToNewToken() {
this.router.transitionTo('access-control.tokens.new');
this.router.transitionTo('administration.tokens.new');
}
}

View File

@@ -28,7 +28,7 @@ export default class AccessControlTokensTokenController extends Controller {
type: `success`,
destroyOnClick: false,
});
this.router.transitionTo('access-control.tokens');
this.router.transitionTo('administration.tokens');
} catch (err) {
this.notifications.add({
title: `Error deleting Token ${this.activeToken.name}`,

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model from '@ember-data/model';
import { attr } from '@ember-data/model';
export default class SentinelPolicy extends Model {
@attr('string') name;
@attr('string') description;
@attr('string') scope;
@attr('string') enforcementLevel;
@attr('string') policy;
@attr('string') hash;
@attr('number') createIndex;
@attr('number') modifyIndex;
}

View File

@@ -112,7 +112,7 @@ Router.map(function () {
});
});
this.route('access-control', function () {
this.route('administration', function () {
this.route('policies', function () {
this.route('new');
this.route('policy', {
@@ -139,6 +139,11 @@ Router.map(function () {
path: '/:name',
});
});
this.route('sentinel-policies', function () {
this.route('new');
this.route('gallery');
this.route('policy', { path: '/:id' });
});
});
// Mirage-only route for testing OIDC flow
if (config['ember-cli-mirage']) {

View File

@@ -9,7 +9,7 @@ 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(
export default class AdministrationRoute extends Route.extend(
withForbiddenState,
WithModelErrorHandling
) {
@@ -35,6 +35,9 @@ export default class AccessControlRoute extends Route.extend(
roles: this.store.findAll('role', { reload: true }),
tokens: this.store.findAll('token', { reload: true }),
namespaces: this.store.findAll('namespace', { reload: true }),
sentinelPolicies: this.can.can('list sentinel-policy')
? this.store.findAll('sentinel-policy', { reload: true })
: [],
});
}

View File

@@ -12,7 +12,7 @@ export default class AccessControlNamespacesNewRoute extends Route {
beforeModel() {
if (this.can.cannot('write namespace')) {
this.router.transitionTo('/access-control/namespaces');
this.router.transitionTo('/administration/namespaces');
}
}

View File

@@ -90,7 +90,7 @@ export default class AccessControlPoliciesNewRoute extends Route {
beforeModel() {
if (this.can.cannot('write policy')) {
this.router.transitionTo('/access-control/policies');
this.router.transitionTo('/administration/policies');
}
}

View File

@@ -12,7 +12,7 @@ export default class AccessControlRolesNewRoute extends Route {
beforeModel() {
if (this.can.cannot('write role')) {
this.router.transitionTo('/access-control/roles');
this.router.transitionTo('/administration/roles');
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import classic from 'ember-classic-decorator';
@classic
export default class AdministrationSentinelPoliciesRoute extends Route {
@service store;
model() {
return this.store.findAll('sentinel-policy', { reload: true });
}
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import TEMPLATES from 'nomad-ui/utils/default-sentinel-policy-templates';
export default class NewRoute extends Route {
@service store;
queryParams = {
template: {
refreshModel: true,
},
};
model({ template }) {
let policy = '#I always pass\nmain = rule { true }\n';
let name = '';
let description = '';
if (template) {
let matchingTemplate = TEMPLATES.find((t) => t.name == template);
if (matchingTemplate) {
policy = matchingTemplate.policy;
name = matchingTemplate.name;
description = matchingTemplate.description;
}
}
return this.store.createRecord('sentinel-policy', {
name,
policy,
description,
enforcementLevel: 'advisory',
scope: 'submit-job',
});
}
resetController(controller, isExiting) {
if (isExiting) {
// If user didn't save, delete the freshly created model
if (controller.model.isNew) {
controller.model.destroyRecord();
controller.set('template', null);
}
}
}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
export default class PolicyRoute extends Route {
@service store;
async model(params) {
return await this.store.findRecord(
'sentinel-policy',
decodeURIComponent(params.id),
{
reload: true,
}
);
}
}

View File

@@ -12,7 +12,7 @@ export default class AccessControlTokensNewRoute extends Route {
beforeModel() {
if (this.can.cannot('write token')) {
this.router.transitionTo('/access-control/tokens');
this.router.transitionTo('/administration/tokens');
}
}

View File

@@ -19,9 +19,9 @@ export default class AccessControlTokensTokenRoute extends Route.extend(
// Route guard to prevent you from wrecking your current token
beforeModel() {
let id = this.paramsFor('access-control.tokens.token').id;
let id = this.paramsFor('administration.tokens.token').id;
if (this.token.selfToken && this.token.selfToken.id === id) {
this.transitionTo('/access-control/tokens');
this.transitionTo('/administration/tokens');
}
}

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from './application';
import classic from 'ember-classic-decorator';
@classic
export default class SentinelPolicy extends ApplicationSerializer {
primaryKey = 'Name';
}

View File

@@ -17,7 +17,7 @@
.section-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
& > div {
padding: 1rem;
@@ -83,7 +83,6 @@
.policy-editor {
max-height: 600px;
overflow: auto;
}
.namespace-editor-wrapper {

View File

@@ -96,6 +96,16 @@
.radio-group {
padding: 16px 0px;
.hds-form-radio-card--checked {
background-color: var(--token-color-surface-action);
}
.hds-form-radio-card__control-wrapper {
height: 0;
padding: 0;
overflow: hidden;
}
}
.button-group {

View File

@@ -1,12 +0,0 @@
{{!
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,12 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "Administration"}}
<Breadcrumb @crumb={{hash label="Administration" args=(array "administration")}} />
<PageLayout>
<AdministrationSubnav @client={{this.model}} />
{{outlet}}
</PageLayout>

View File

@@ -14,40 +14,51 @@
<div class="section-cards">
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-tokens-card>
<LinkTo
@route="access-control.tokens"
@route="administration.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::Button @text="Create Token" @color="secondary" @iconPosition="leading" @icon="plus" @route="administration.tokens.new" />
</Hds::Card::Container>
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-roles-card>
<LinkTo
@route="access-control.roles"
@route="administration.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::Button @text="Create Role" @color="secondary" @iconPosition="leading" @icon="plus" @route="administration.roles.new" />
</Hds::Card::Container>
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-policies-card>
<LinkTo
@route="access-control.policies"
@route="administration.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::Button @text="Create Policy" @color="secondary" @iconPosition="leading" @icon="plus" @route="administration.policies.new" />
</Hds::Card::Container>
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-namespaces-card>
<LinkTo
@route="access-control.namespaces"
@route="administration.namespaces"
>
{{this.model.namespaces.length}} {{pluralize "Namespace" this.model.namespaces.length}}
</LinkTo>
<p>Namespaces allow jobs and other objects to be segmented from each other.</p>
<Hds::Button @text="Create Namespace" @color="secondary" @iconPosition="leading" @icon="plus" @route="access-control.namespaces.new" />
<Hds::Button @text="Create Namespace" @color="secondary" @iconPosition="leading" @icon="plus" @route="administration.namespaces.new" />
</Hds::Card::Container>
{{#if (can "read sentinel-policy")}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} data-test-sentinel-policies-card>
<LinkTo
@route="administration.sentinel-policies"
>
{{this.model.sentinelPolicies.length}} {{pluralize "Sentinel Policy" this.model.sentinelPolicies.length}}
</LinkTo>
<p>Sentinel Policies allow operators to express rules as code and have those rules automatically enforced when jobs are planned.</p>
<Hds::Button @text="Create Sentinel Policy" @color="secondary" @iconPosition="leading" @icon="plus" @route="administration.sentinel-policies.new" />
</Hds::Card::Container>
{{/if}}
</div>
</section>
{{outlet}}

View File

@@ -4,5 +4,5 @@
~}}
{{page-title "Namespaces"}}
<Breadcrumb @crumb={{hash label="Namespaces" args=(array "access-control.namespaces")}} />
<Breadcrumb @crumb={{hash label="Namespaces" args=(array "administration.namespaces")}} />
{{outlet}}

View File

@@ -2,7 +2,7 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label=this.model.name args=(array "access-control.namespaces.acl-namespace" this.model.name)}} />
<Breadcrumb @crumb={{hash label=this.model.name args=(array "administration.namespaces.acl-namespace" this.model.name)}} />
{{page-title "Namespace"}}
<section class="section">

View File

@@ -13,7 +13,7 @@
<Hds::Button
@text="Create Namespace"
@icon="plus"
@route="access-control.namespaces.new"
@route="administration.namespaces.new"
{{keyboard-shortcut
pattern=(array "n" "n")
action=(action this.goToNewNamespace)
@@ -46,7 +46,7 @@
data-test-namespace-row
>
<B.Td>
<LinkTo data-test-namespace-name={{B.data.name}} @route="access-control.namespaces.acl-namespace" @model={{B.data.name}}>{{B.data.name}}</LinkTo>
<LinkTo data-test-namespace-name={{B.data.name}} @route="administration.namespaces.acl-namespace" @model={{B.data.name}}>{{B.data.name}}</LinkTo>
</B.Td>
<B.Td>{{B.data.description}}</B.Td>
</B.Tr>

View File

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

View File

@@ -4,5 +4,5 @@
~}}
{{page-title "Policies"}}
<Breadcrumb @crumb={{hash label="Policies" args=(array "access-control.policies")}} />
<Breadcrumb @crumb={{hash label="Policies" args=(array "administration.policies")}} />
{{outlet}}

View File

@@ -13,7 +13,7 @@
<Hds::Button
@text="Create Policy"
@icon="plus"
@route="access-control.policies.new"
@route="administration.policies.new"
{{keyboard-shortcut
pattern=(array "n" "p")
action=(action this.goToNewPolicy)
@@ -48,7 +48,7 @@
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>
<LinkTo data-test-policy-name={{B.data.name}} @route="administration.policies.policy" @model={{B.data.name}}>{{B.data.name}}</LinkTo>
</B.Td>
<B.Td>{{B.data.description}}</B.Td>
{{#if (can "list token")}}
@@ -76,7 +76,7 @@
No Policies
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
Get started by <LinkTo @route="administration.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="New" args=(array "access-control.policies.new")}} />
<Breadcrumb @crumb={{hash label="New" args=(array "administration.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: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label=this.policy.name args=(array "access-control.policies.policy" this.policy.name)}} />
<Breadcrumb @crumb={{hash label=this.policy.name args=(array "administration.policies.policy" this.policy.name)}} />
{{page-title "Policy"}}
<section class="section">
<h1 class="title with-flex" data-test-title>

View File

@@ -4,5 +4,5 @@
~}}
{{page-title "Roles"}}
<Breadcrumb @crumb={{hash label="Roles" args=(array "access-control.roles")}} />
<Breadcrumb @crumb={{hash label="Roles" args=(array "administration.roles")}} />
{{outlet}}

View File

@@ -13,7 +13,7 @@
<Hds::Button
@text="Create Role"
@icon="plus"
@route="access-control.roles.new"
@route="administration.roles.new"
{{keyboard-shortcut
pattern=(array "n" "r")
action=(action this.goToNewRole)
@@ -47,7 +47,7 @@
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>
<LinkTo @route="administration.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>
@@ -63,7 +63,7 @@
{{#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}}" />
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="administration.policies.policy" @model="{{policy.name}}" />
{{else}}
<Hds::Tag
{{hds-tooltip "This policy has been deleted"}}
@@ -95,7 +95,7 @@
No Roles
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.roles.new">creating a new role</LinkTo>
Get started by <LinkTo @route="administration.roles.new">creating a new role</LinkTo>
</p>
</div>
{{/if}}

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="New" args=(array "access-control.roles.new")}} />
<Breadcrumb @crumb={{hash label="New" args=(array "administration.roles.new")}} />
{{page-title "Create Role"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
@@ -20,7 +20,7 @@
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>
At least one Policy is required to create a Role; <LinkTo @route="administration.policies.new">create a new policy</LinkTo>
</p>
</div>
{{/if}}

View File

@@ -2,7 +2,7 @@
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)}} />
<Breadcrumb @crumb={{hash label=this.role.name args=(array "administration.roles.role" this.role.id)}} />
{{page-title "Role"}}
<section class="section">
<h1 class="title with-flex" data-test-title>

View File

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

View File

@@ -0,0 +1,35 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="Gallery" args=(array "administration.sentinel-policies.gallery" )}} />
{{page-title "Sentinel Policy Gallery"}}
<section class="section">
<Hds::PageHeader class="variable-title" as |PH|>
<PH.Title>Choose a Template</PH.Title>
<PH.Description>
Select a policy template below. You will have an opportunity to modify the policy before it is submitted.
</PH.Description>
</Hds::PageHeader>
<main class="radio-group" data-test-template-list>
<Hds::Form::RadioCard::Group as |G|>
<G.Legend>Select a Template</G.Legend>
{{#each this.templates as |template|}}
<G.RadioCard class="form-container" @layout="fixed" @maxWidth="30%" @checked={{eq template.name
this.selectedTemplate}} id={{template.name}} data-test-template-card={{template.name}} {{on "change"
this.onChange}} as |R|>
<R.Label data-test-template-label>{{template.displayName}}</R.Label>
<R.Description data-test-template-description>{{template.description}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
</main>
<footer class="buttonset">
<Hds::ButtonSet class="button-group">
<Hds::Button @text="Apply" @route="administration.sentinel-policies.new" @query={{hash template=this.selectedTemplate}}
disabled={{is-equal this.selectedTemplate null}} data-test-apply />
<Hds::Button @text="Cancel" @route="administration.sentinel-policies.new" @color="secondary" data-test-cancel />
</Hds::ButtonSet>
</footer>
</section>

View File

@@ -0,0 +1,78 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<section class="section">
<Hds::PageHeader class="variable-title" as |PH|>
<PH.Title>Sentinel Policies</PH.Title>
<PH.Description>
Nomad integrates with <Hds::Link::Inline @icon="collections" @href="https://developer.hashicorp.com/nomad/tutorials/governance-and-policy/sentinel">HashiCorp Sentinel</Hds::Link::Inline> to allow operators to express policies as code and have those policies automatically enforced. This allows operators to define a "sandbox" and restrict actions to only those compliant with that policy.
</PH.Description>
<PH.Actions>
{{#if (can "write sentinel-policy")}}
<span
{{keyboard-shortcut
pattern=(array "n" "p" )
action=(action this.goToNewPolicy)
label="Create Policy"
}}
>
<Hds::Button @text="Create from Scratch" @icon="plus" @route="administration.sentinel-policies.new" data-test-create-sentinel-policy />
</span>
<span
{{keyboard-shortcut
pattern=(array "n" "t" "p")
action=(action this.goToTemplateGallery)
label="Create Policy from Template"
}}
>
<Hds::Button @text="Create from Template" @icon="plus" @route="administration.sentinel-policies.gallery" />
</span>
{{else}}
<Hds::Button @text="Create Policy" @icon="plus" disabled data-test-disabled-create-sentinel-policy />
{{/if}}
</PH.Actions>
</Hds::PageHeader>
{{#if this.model}}
<Hds::Table @caption="A list of policies for this cluster" class="acl-table" @model={{this.model}}
@columns={{this.columns}} @sortBy="name">
<:body as |B|>
<B.Tr {{keyboard-shortcut enumerated=true action=(action "openPolicy" B.data) }} data-test-sentinel-policy-row>
<B.Td>
<LinkTo data-test-sentinel-policy-name={{B.data.name}} @route="administration.sentinel-policies.policy"
@model={{B.data.name}}>{{B.data.name}}</LinkTo>
</B.Td>
<B.Td>{{B.data.description}}</B.Td>
<B.Td>{{B.data.enforcementLevel}}</B.Td>
{{#if (can "destroy sentinel-policy")}}
<B.Td>
<TwoStepButton
data-test-delete-policy
@idleText="Delete"
@inlineText={{true}}
@cancelText="Cancel"
@confirmText="Yes, Delete Policy"
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{this.deletePolicy.isRunning}}
@disabled={{this.deletePolicy.isRunning}}
@onConfirm={{perform this.deletePolicy B.data}}
/>
</B.Td>
{{/if}}
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<div data-test-empty-jobs-list class="empty-message">
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">
No Sentinel Policies
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="administration.sentinel-policies.new">creating a policy from scratch</LinkTo> or
by <LinkTo @route="administration.sentinel-policies.gallery">creating one from the policy gallery</LinkTo>.
</p>
</div>
{{/if}}
</section>

View File

@@ -0,0 +1,26 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="New" args=(array "administration.sentinel-policies.new")}} />
{{page-title "Create a Policy"}}
<section class="section">
<Hds::PageHeader class="variable-title" as |PH|>
<PH.Title>Create Sentinel Policy</PH.Title>
<PH.Description>
Nomad integrates with <Hds::Link::Inline @icon="collections" @href="https://developer.hashicorp.com/nomad/tutorials/governance-and-policy/sentinel">HashiCorp Sentinel</Hds::Link::Inline> to allow operators to express policies as code and have those policies automatically enforced. This allows operators to define a "sandbox" and restrict actions to only those compliant with that policy.
</PH.Description>
<PH.Actions>
<Hds::Button
@text="Start from a template"
@color="secondary"
@route="administration.sentinel-policies.gallery"
data-test-choose-template
/>
</PH.Actions>
</Hds::PageHeader>
<SentinelPolicyEditor @policy={{this.model}} />
</section>

View File

@@ -0,0 +1,32 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label=this.model.name args=(array "administration.sentinel-policies.policy" this.model.name)}} />
{{page-title (concat "Sentinel Policy: " this.model.name)}}
<section class="section">
<Hds::PageHeader class="variable-title" as |PH|>
<PH.Title>{{this.model.name}}</PH.Title>
<PH.Actions>
{{#if (can "destroy sentinel-policy")}}
<div>
<TwoStepButton
data-test-delete-policy
@alignRight={{true}}
@idleText="Delete Sentinel Policy"
@cancelText="Cancel"
@confirmText="Yes, Delete Policy"
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{this.deletePolicy.isRunning}}
@disabled={{this.deletePolicy.isRunning}}
@onConfirm={{perform this.deletePolicy}}
/>
</div>
{{/if}}
</PH.Actions>
</Hds::PageHeader>
<SentinelPolicyEditor @policy={{this.model}} />
</section>

View File

@@ -4,5 +4,5 @@
~}}
{{page-title "Tokens"}}
<Breadcrumb @crumb={{hash label="Tokens" args=(array "access-control.tokens")}} />
<Breadcrumb @crumb={{hash label="Tokens" args=(array "administration.tokens")}} />
{{outlet}}

View File

@@ -13,7 +13,7 @@
<Hds::Button
@text="Create Token"
@icon="plus"
@route="access-control.tokens.new"
@route="administration.tokens.new"
{{keyboard-shortcut
pattern=(array "n" "t")
action=(action this.goToNewToken)
@@ -58,7 +58,7 @@
{{#if (eq B.data.id this.selfToken.id)}}
<strong>{{B.data.name}}</strong>
{{else}}
<LinkTo @route="access-control.tokens.token" @model={{B.data.id}}>
<LinkTo @route="administration.tokens.token" @model={{B.data.id}}>
{{B.data.name}}
</LinkTo>
{{/if}}
@@ -84,7 +84,7 @@
--}}
{{#each B.data.roles as |role|}}
{{#if role.name}}
<Hds::Tag @color="primary" @text="{{role.name}}" @route="access-control.roles.role" @model="{{role.id}}" />
<Hds::Tag @color="primary" @text="{{role.name}}" @route="administration.roles.role" @model="{{role.id}}" />
{{/if}}
{{else}}
{{#if (eq B.data.type "management")}}
@@ -101,7 +101,7 @@
{{#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}}" />
<Hds::Tag @color="primary" @text="{{policy.name}}" @route="administration.policies.policy" @model="{{policy.name}}" />
{{else}}
<Hds::Tag
{{hds-tooltip "This policy has been deleted"}}
@@ -142,7 +142,7 @@
No Tokens
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="access-control.policies.new">creating a new policy</LinkTo>
Get started by <LinkTo @route="administration.policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}

View File

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

View File

@@ -2,7 +2,7 @@
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)}} />
<Breadcrumb @crumb={{hash label=this.activeToken.name args=(array "administration.tokens.token" this.activeToken.id)}} />
{{page-title "Token"}}
<section class="section">
<h1 class="title with-flex" data-test-title>

View File

@@ -1,14 +0,0 @@
{{!
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>
<li><LinkTo @route="access-control.namespaces" @activeClass="is-active">Namespaces</LinkTo></li>
</ul>
</div>

View File

@@ -0,0 +1,17 @@
{{!
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="administration.index" @activeClass="is-active">Overview</LinkTo></li>
<li><LinkTo @route="administration.tokens" @activeClass="is-active">Tokens</LinkTo></li>
<li><LinkTo @route="administration.roles" @activeClass="is-active">Roles</LinkTo></li>
<li><LinkTo @route="administration.policies" @activeClass="is-active">Policies</LinkTo></li>
<li><LinkTo @route="administration.namespaces" @activeClass="is-active">Namespaces</LinkTo></li>
{{#if (can "list sentinel-policy")}}
<li><LinkTo @route="administration.sentinel-policies" @activeClass="is-active">Sentinel Policies</LinkTo></li>
{{/if}}
</ul>
</div>

View File

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

View File

@@ -23,7 +23,7 @@
>
<div class="hds-button__text">Upload file</div>
<input
type="file"
type="file"
onchange={{action this.uploadJobSpec}}
accept=".hcl,.json,.nomad"
/>

View File

@@ -51,7 +51,7 @@
<div class="boxed-section-body is-full-bleed">
{{#if (eq @data.view "job-spec")}}
<div
data-test-job-spec-view
data-test-job-spec-view
{{code-mirror
content=@data.definition
mode=(if (eq @data.format "json") "javascript" "ruby")

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import countLimitsPolicy from './sentinel_policy_templates/count-limits';
import noFridaysPolicy from './sentinel_policy_templates/no-friday-deploys';
import alwaysFailPolicy from './sentinel_policy_templates/always-fail';
import canariesOnlyPolicy from './sentinel_policy_templates/canaries-only';
import resourceLimitsPolicy from './sentinel_policy_templates/resource-limits';
import restictImagesPolicy from './sentinel_policy_templates/restrict-images';
export default [
{
displayName: 'Count Limits',
name: 'count-limits',
description: 'Enforces that no task group has a count over 100',
policy: countLimitsPolicy,
},
{
displayName: 'No Friday Deploys',
name: 'no-friday-deploys',
description: 'Ensures that no deploys happen on a Friday',
policy: noFridaysPolicy,
},
{
displayName: 'Always Fail',
name: 'always-fail',
description: 'A test Sentinel Policy that will always fail',
policy: alwaysFailPolicy,
},
{
displayName: 'Canaries Only',
name: 'canaries-only',
description: 'All deployments must have a canary',
policy: canariesOnlyPolicy,
},
{
displayName: 'Resource Limits',
name: 'resource-limits',
description: 'Ensures that tasks do not request too much CPU or Memory',
policy: resourceLimitsPolicy,
},
{
displayName: 'Restrict Images',
name: 'restrict-images',
description: 'Allows only certain Docker images and disables "latest" tags',
policy: restictImagesPolicy,
},
];

View File

@@ -0,0 +1,9 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default `# This policy will always fail. You can temporarily halt all new job updates using this.
main = rule { false }
`;

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default `# This policy ensures that all deployments must use canary deployments.
canary_required = rule {
all job.task_groups as tg {
tg.update.canary > 0
}
}
main = rule { canary_required }`;

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default `main = rule { all_counts_under }
# all_counts_under checks that all task group counts are under a certain value
all_counts_under = rule {
all job.task_groups as tg {
tg.count < 100
}
}
`;

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default `import "time"
is_weekday = rule { time.day not in ["friday", "saturday", "sunday"] }
is_open_hours = rule { time.hour > 8 and time.hour < 16 }
main = rule { is_open_hours and is_weekday }`;

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default `import "units"
resource_check = func(task_groups, resource) {
result = 0
for task_groups as g {
for g.tasks as t {
result = result + t.resources[resource] * g.count
}
}
return result
}
main = rule {
resource_check(job.task_groups, "cpu") <= 1500 and
resource_check(job.task_groups, "memory_mb") <= 2500
}`;

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default `# This policy restricts which Docker images are allowed and also prevents use of
# the "latest" tag since the image must specify a tag that starts with a number.
# Allowed Docker images
allowed_images = [
"https://hub.docker.internal",
"nginx",
"mongo",
]
# Restrict allowed Docker images
restrict_images = rule {
all job.task_groups as tg {
all tg.tasks as task {
any allowed_images as allowed {
# Note that we require ":" and a tag after it
# which must start with a number, preventing "latest"
task.config.image matches allowed + ":[0-9](.*)"
}
}
}
}
# Main rule
main = rule {
restrict_images
}`;

View File

@@ -881,6 +881,23 @@ export default function () {
return this.serialize(policies.all());
});
this.get('/sentinel/policies', function (schema, req) {
return this.serialize(schema.sentinelPolicies.all());
});
this.post('/sentinel/policy/:id', function (schema, req) {
const { Name, Description, Rules } = JSON.parse(req.requestBody);
return server.create('sentinelPolicy', {
name: Name,
description: Description,
rules: Rules,
});
});
this.get('/sentinel/policy/:id', function ({ sentinelPolicies }, req) {
return this.serialize(sentinelPolicies.findBy({ name: req.params.id }));
});
this.delete('/acl/policy/:id', function (schema, request) {
const { id } = request.params;

View File

@@ -90,6 +90,7 @@ function jobsIndexTestCluster(server) {
function smallCluster(server) {
faker.seed(1);
server.create('feature', { name: 'Dynamic Application Sizing' });
server.create('feature', { name: 'Sentinel Policies' });
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
server.createList('node-pool', 2);
server.createList('node', 5);
@@ -623,6 +624,7 @@ function variableTestCluster(server) {
}
function policiesTestCluster(server) {
server.create('feature', { name: 'Sentinel Policies' });
faker.seed(1);
createTokens(server);
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');

View File

@@ -7,7 +7,7 @@ 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 Administration from 'nomad-ui/tests/pages/administration';
import Tokens from 'nomad-ui/tests/pages/settings/tokens';
import { allScenarios } from '../../mirage/scenarios/default';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
@@ -28,22 +28,22 @@ module('Acceptance | access control', function (hooks) {
test('Access Control is only accessible by a management user', async function (assert) {
assert.expect(7);
await AccessControl.visit();
await Administration.visit();
assert.equal(
currentURL(),
'/jobs',
'redirected to the jobs page if a non-management token on /access-control'
'redirected to the jobs page if a non-management token on /administration'
);
await AccessControl.visitTokens();
await Administration.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();
assert.dom('[data-test-gutter-link="administration"]').doesNotExist();
await Tokens.visit();
const managementToken = server.db.tokens.findBy(
@@ -52,22 +52,22 @@ module('Acceptance | access control', function (hooks) {
const { secretId } = managementToken;
await Tokens.secret(secretId).submit();
assert.dom('[data-test-gutter-link="access-control"]').exists();
assert.dom('[data-test-gutter-link="administration"]').exists();
await AccessControl.visit();
await Administration.visit();
assert.equal(
currentURL(),
'/access-control',
'management token can access /access-control'
'/administration',
'management token can access /administration'
);
await a11yAudit(assert);
await AccessControl.visitTokens();
await Administration.visitTokens();
assert.equal(
currentURL(),
'/access-control/tokens',
'management token can access /access-control/tokens'
'/administration/tokens',
'management token can access /administration/tokens'
);
});
@@ -79,7 +79,7 @@ module('Acceptance | access control', function (hooks) {
const { secretId } = managementToken;
await Tokens.secret(secretId).submit();
await AccessControl.visit();
await Administration.visit();
assert.dom('[data-test-tokens-card]').exists();
assert.dom('[data-test-roles-card]').exists();
assert.dom('[data-test-policies-card]').exists();
@@ -112,16 +112,16 @@ module('Acceptance | access control', function (hooks) {
const { secretId } = managementToken;
await Tokens.secret(secretId).submit();
await AccessControl.visit();
await Administration.visit();
assert.equal(currentURL(), '/access-control');
assert.equal(currentURL(), '/administration');
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
shiftKey: true,
});
assert.equal(
currentURL(),
`/access-control/tokens`,
`/administration/tokens`,
'Shift+ArrowRight takes you to the next tab (Tokens)'
);
@@ -130,7 +130,7 @@ module('Acceptance | access control', function (hooks) {
});
assert.equal(
currentURL(),
`/access-control/roles`,
`/administration/roles`,
'Shift+ArrowRight takes you to the next tab (Roles)'
);
@@ -139,7 +139,7 @@ module('Acceptance | access control', function (hooks) {
});
assert.equal(
currentURL(),
`/access-control/policies`,
`/administration/policies`,
'Shift+ArrowRight takes you to the next tab (Policies)'
);
@@ -148,7 +148,7 @@ module('Acceptance | access control', function (hooks) {
});
assert.equal(
currentURL(),
`/access-control/namespaces`,
`/administration/namespaces`,
'Shift+ArrowRight takes you to the next tab (Namespaces)'
);
@@ -157,7 +157,7 @@ module('Acceptance | access control', function (hooks) {
});
assert.equal(
currentURL(),
`/access-control`,
`/administration`,
'Shift+ArrowLeft takes you back to the Access Control index page'
);
});

View File

@@ -26,9 +26,9 @@ module('Acceptance | namespaces', function (hooks) {
assert.expect(4);
allScenarios.namespacesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/namespaces');
assert.dom('[data-test-gutter-link="access-control"]').exists();
assert.equal(currentURL(), '/access-control/namespaces');
await visit('/administration/namespaces');
assert.dom('[data-test-gutter-link="administration"]').exists();
assert.equal(currentURL(), '/administration/namespaces');
assert
.dom('[data-test-namespace-row]')
.exists({ count: server.db.namespaces.length });
@@ -41,9 +41,9 @@ module('Acceptance | namespaces', function (hooks) {
test('Prevents namespaes access if you lack a management token', async function (assert) {
allScenarios.namespacesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[1].secretId;
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
assert.equal(currentURL(), '/jobs');
assert.dom('[data-test-gutter-link="access-control"]').doesNotExist();
assert.dom('[data-test-gutter-link="administration"]').doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
@@ -52,15 +52,15 @@ module('Acceptance | namespaces', function (hooks) {
assert.expect(7);
allScenarios.namespacesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
await click('[data-test-create-namespace]');
assert.equal(currentURL(), '/access-control/namespaces/new');
assert.equal(currentURL(), '/administration/namespaces/new');
await typeIn('[data-test-namespace-name-input]', 'My New Namespace');
await click('button[data-test-save-namespace]');
assert
.dom('.flash-message.alert-critical')
.exists('Doesnt let you save a bad name');
assert.equal(currentURL(), '/access-control/namespaces/new');
assert.equal(currentURL(), '/administration/namespaces/new');
document.querySelector('[data-test-namespace-name-input]').value = ''; // clear
await typeIn('[data-test-namespace-name-input]', 'My-New-Namespace');
await click('button[data-test-save-namespace]');
@@ -68,16 +68,16 @@ module('Acceptance | namespaces', function (hooks) {
assert.equal(
currentURL(),
'/access-control/namespaces/My-New-Namespace',
'/administration/namespaces/My-New-Namespace',
'redirected to the now-created namespace'
);
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
const newNs = [...findAll('[data-test-namespace-name]')].filter((a) =>
a.textContent.includes('My-New-Namespace')
)[0];
assert.ok(newNs, 'Namespace is in the list');
await click(newNs);
assert.equal(currentURL(), '/access-control/namespaces/My-New-Namespace');
assert.equal(currentURL(), '/administration/namespaces/My-New-Namespace');
await percySnapshot(assert);
// Reset Token
window.localStorage.nomadTokenSecret = null;
@@ -87,7 +87,7 @@ module('Acceptance | namespaces', function (hooks) {
assert.expect(2);
allScenarios.namespacesTestCluster(server, { enterprise: true });
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
await click('[data-test-create-namespace]');
// Get the dom node text for the description
@@ -114,7 +114,7 @@ module('Acceptance | namespaces', function (hooks) {
assert.expect(2);
allScenarios.namespacesTestCluster(server, { enterprise: false });
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
await click('[data-test-create-namespace]');
// Get the dom node text for the description
@@ -132,7 +132,7 @@ module('Acceptance | namespaces', function (hooks) {
test('Modifying an existing namespace', async function (assert) {
allScenarios.namespacesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
await click('[data-test-namespace-row]:first-child a');
// Table sorts by name by default
let firstNamespace = server.db.namespaces.sort((a, b) => {
@@ -140,7 +140,7 @@ module('Acceptance | namespaces', function (hooks) {
})[0];
assert.equal(
currentURL(),
`/access-control/namespaces/${firstNamespace.name}`
`/administration/namespaces/${firstNamespace.name}`
);
assert.dom('[data-test-namespace-editor]').exists();
assert.dom('[data-test-title]').includesText(firstNamespace.name);
@@ -148,7 +148,7 @@ module('Acceptance | namespaces', function (hooks) {
assert.dom('.flash-message.alert-success').exists();
assert.equal(
currentURL(),
`/access-control/namespaces/${firstNamespace.name}`,
`/administration/namespaces/${firstNamespace.name}`,
'remain on page after save'
);
// Reset Token
@@ -159,7 +159,7 @@ module('Acceptance | namespaces', function (hooks) {
assert.expect(11);
allScenarios.namespacesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
// Default namespace hides delete button
const defaultNamespaceLink = [
@@ -167,14 +167,14 @@ module('Acceptance | namespaces', function (hooks) {
].filter((row) => row.textContent.includes('default'))[0];
await click(defaultNamespaceLink);
assert.equal(currentURL(), `/access-control/namespaces/default`);
assert.equal(currentURL(), `/administration/namespaces/default`);
let deleteButton = find('[data-test-delete-namespace] button');
assert
.dom(deleteButton)
.doesNotExist('delete button is not present for default');
// Standard namespace properly deletes
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
let nonDefaultNamespace = server.db.namespaces.findBy(
(ns) => ns.name != 'default'
@@ -185,7 +185,7 @@ module('Acceptance | namespaces', function (hooks) {
await click(nonDefaultNsLink);
assert.equal(
currentURL(),
`/access-control/namespaces/${nonDefaultNamespace.name}`
`/administration/namespaces/${nonDefaultNamespace.name}`
);
deleteButton = find('[data-test-delete-namespace] button');
assert.dom(deleteButton).exists('delete button is present for non-default');
@@ -195,15 +195,15 @@ module('Acceptance | namespaces', function (hooks) {
.exists('confirmation message is present');
await click(find('[data-test-confirm-button]'));
assert.dom('.flash-message.alert-success').exists();
assert.equal(currentURL(), '/access-control/namespaces');
assert.equal(currentURL(), '/administration/namespaces');
assert
.dom(`[data-test-namespace-name="${nonDefaultNamespace.name}"]`)
.doesNotExist();
// Namespace with variables errors properly
// "with-variables" hard-coded into scenario to be a NS with variables attached
await visit('/access-control/namespaces/with-variables');
assert.equal(currentURL(), '/access-control/namespaces/with-variables');
await visit('/administration/namespaces/with-variables');
assert.equal(currentURL(), '/administration/namespaces/with-variables');
deleteButton = find('[data-test-delete-namespace] button');
await click(deleteButton);
await click(find('[data-test-confirm-button]'));
@@ -211,7 +211,7 @@ module('Acceptance | namespaces', function (hooks) {
.dom('.flash-message.alert-critical')
.exists('Doesnt let you delete a namespace with variables');
assert.equal(currentURL(), '/access-control/namespaces/with-variables');
assert.equal(currentURL(), '/administration/namespaces/with-variables');
// Reset Token
window.localStorage.nomadTokenSecret = null;
@@ -228,7 +228,7 @@ module('Acceptance | namespaces', function (hooks) {
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
// Attempt a delete on an un-deletable namespace
await visit('/access-control/namespaces/with-variables');
await visit('/administration/namespaces/with-variables');
let deleteButton = find('[data-test-delete-namespace] button');
await click(deleteButton);
await click(find('[data-test-confirm-button]'));
@@ -236,10 +236,10 @@ module('Acceptance | namespaces', function (hooks) {
assert
.dom('.flash-message.alert-critical')
.exists('Doesnt let you delete a namespace with variables');
assert.equal(currentURL(), '/access-control/namespaces/with-variables');
assert.equal(currentURL(), '/administration/namespaces/with-variables');
// Navigate back to the page via the index
await visit('/access-control/namespaces');
await visit('/administration/namespaces');
// Default namespace hides delete button
const notDeletedNSLink = [...findAll('[data-test-namespace-name]')].filter(
@@ -247,6 +247,6 @@ module('Acceptance | namespaces', function (hooks) {
)[0];
await click(notDeletedNSLink);
assert.equal(currentURL(), `/access-control/namespaces/with-variables`);
assert.equal(currentURL(), `/administration/namespaces/with-variables`);
});
});

View File

@@ -26,9 +26,9 @@ module('Acceptance | policies', function (hooks) {
assert.expect(4);
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
assert.dom('[data-test-gutter-link="access-control"]').exists();
assert.equal(currentURL(), '/access-control/policies');
await visit('/administration/policies');
assert.dom('[data-test-gutter-link="administration"]').exists();
assert.equal(currentURL(), '/administration/policies');
assert
.dom('[data-test-policy-row]')
.exists({ count: server.db.policies.length });
@@ -41,9 +41,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('/access-control/policies');
await visit('/administration/policies');
assert.equal(currentURL(), '/jobs');
assert.dom('[data-test-gutter-link="access-control"]').doesNotExist();
assert.dom('[data-test-gutter-link="administration"]').doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
@@ -51,20 +51,20 @@ 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('/access-control/policies');
await visit('/administration/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.equal(currentURL(), `/administration/policies/${firstPolicy.name}`);
assert.dom('[data-test-policy-editor]').exists();
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(),
`/access-control/policies/${firstPolicy.name}`,
`/administration/policies/${firstPolicy.name}`,
'remain on page after save'
);
// Reset Token
@@ -74,9 +74,9 @@ module('Acceptance | policies', function (hooks) {
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 visit('/administration/policies');
await click('[data-test-policy-name="Variable-Maker"]');
assert.equal(currentURL(), '/access-control/policies/Variable-Maker');
assert.equal(currentURL(), '/administration/policies/Variable-Maker');
await click('[data-test-create-test-token]');
assert.dom('.flash-message.alert-success').exists();
assert
@@ -100,31 +100,31 @@ module('Acceptance | policies', function (hooks) {
assert.expect(7);
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
await visit('/administration/policies');
await click('[data-test-create-policy]');
assert.equal(currentURL(), '/access-control/policies/new');
assert.equal(currentURL(), '/administration/policies/new');
await typeIn('[data-test-policy-name-input]', 'My Fun Policy');
await click('button[data-test-save-policy]');
assert
.dom('.flash-message.alert-critical')
.exists('Doesnt let you save a bad name');
assert.equal(currentURL(), '/access-control/policies/new');
assert.equal(currentURL(), '/administration/policies/new');
document.querySelector('[data-test-policy-name-input]').value = ''; // clear
await typeIn('[data-test-policy-name-input]', 'My-Fun-Policy');
await click('button[data-test-save-policy]');
assert.dom('.flash-message.alert-success').exists();
assert.equal(
currentURL(),
'/access-control/policies/My-Fun-Policy',
'/administration/policies/My-Fun-Policy',
'redirected to the now-created policy'
);
await visit('/access-control/policies');
await visit('/administration/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(), '/access-control/policies/My-Fun-Policy');
assert.equal(currentURL(), '/administration/policies/My-Fun-Policy');
await percySnapshot(assert);
// Reset Token
window.localStorage.nomadTokenSecret = null;
@@ -133,7 +133,7 @@ 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('/access-control/policies');
await visit('/administration/policies');
let firstPolicy = server.db.policies.sort((a, b) => {
return a.name.localeCompare(b.name);
})[0];
@@ -143,7 +143,7 @@ module('Acceptance | policies', function (hooks) {
(row) => row.textContent.includes(firstPolicyName)
)[0];
await click(firstPolicyLink);
assert.equal(currentURL(), `/access-control/policies/${firstPolicyName}`);
assert.equal(currentURL(), `/administration/policies/${firstPolicyName}`);
const deleteButton = find('[data-test-delete-policy] button');
assert.dom(deleteButton).exists('delete button is present');
@@ -154,7 +154,7 @@ module('Acceptance | policies', function (hooks) {
await click(find('[data-test-confirm-button]'));
assert.dom('.flash-message.alert-success').exists();
assert.equal(currentURL(), '/access-control/policies');
assert.equal(currentURL(), '/administration/policies');
assert.dom(`[data-test-policy-name="${firstPolicyName}"]`).doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
@@ -163,7 +163,7 @@ module('Acceptance | policies', function (hooks) {
test('Policies Index', async function (assert) {
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
await visit('/administration/policies');
// Table contains every policy in db
assert
.dom('[data-test-policy-row]')

View File

@@ -10,7 +10,7 @@ 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 Administration from 'nomad-ui/tests/pages/administration';
import percySnapshot from '@percy/ember';
module('Acceptance | roles', function (hooks) {
@@ -27,7 +27,7 @@ module('Acceptance | roles', function (hooks) {
);
const { secretId } = managementToken;
await Tokens.secret(secretId).submit();
await AccessControl.visitRoles();
await Administration.visitRoles();
});
hooks.afterEach(async function () {
@@ -39,7 +39,7 @@ module('Acceptance | roles', function (hooks) {
assert.expect(3);
await a11yAudit(assert);
assert.equal(currentURL(), '/access-control/roles');
assert.equal(currentURL(), '/administration/roles');
assert
.dom('[data-test-role-row]')
@@ -78,7 +78,7 @@ module('Acceptance | roles', function (hooks) {
assert.equal(policiesCellTags[1].textContent.trim(), 'job-reader');
await click(policiesCellTags[0].querySelector('a'));
assert.equal(currentURL(), '/access-control/policies/client-reader');
assert.equal(currentURL(), '/administration/policies/client-reader');
assert.dom('[data-test-title]').containsText('client-reader');
});
@@ -86,7 +86,7 @@ module('Acceptance | roles', function (hooks) {
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.equal(currentURL(), `/administration/roles/${role.id}`);
assert.dom('[data-test-role-name-input]').hasValue(role.name);
assert.dom('[data-test-role-description-input]').hasValue(role.description);
@@ -99,13 +99,13 @@ module('Acceptance | roles', function (hooks) {
assert.dom('.flash-message.alert-success').exists();
assert.equal(
currentURL(),
`/access-control/roles/${role.name}`,
`/administration/roles/${role.name}`,
'remain on page after save'
);
await percySnapshot(assert);
// Go back to the roles index
await AccessControl.visitRoles();
await Administration.visitRoles();
let readerRoleRow = find('[data-test-role-row="reader-edited"]');
assert.dom(readerRoleRow).exists();
assert.equal(
@@ -119,7 +119,7 @@ module('Acceptance | roles', function (hooks) {
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}`);
assert.equal(currentURL(), `/administration/roles/${role.id}`);
// Policies table is sortable
@@ -202,7 +202,7 @@ module('Acceptance | roles', function (hooks) {
await click('button[data-test-save-role]');
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitRoles();
await Administration.visitRoles();
const readerRoleRow = find('[data-test-role-row="reader"]');
const readerRolePolicies = readerRoleRow
.querySelector('[data-test-role-policies]')
@@ -219,7 +219,7 @@ module('Acceptance | roles', function (hooks) {
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.equal(currentURL(), `/administration/roles/${role.id}`);
assert.dom('table.tokens').exists();
// "Reader" role has a single token with it applied by default
@@ -243,7 +243,7 @@ module('Acceptance | roles', function (hooks) {
await percySnapshot(assert);
await AccessControl.visitTokens();
await Administration.visitTokens();
assert
.dom('[data-test-token-name="Example Token for reader"]')
.exists(
@@ -254,7 +254,7 @@ module('Acceptance | roles', function (hooks) {
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}`);
assert.equal(currentURL(), `/administration/roles/${role.id}`);
const deleteButton = find('[data-test-delete-role] button');
assert.dom(deleteButton).exists('delete button is present');
await click(deleteButton);
@@ -263,12 +263,12 @@ module('Acceptance | roles', function (hooks) {
.exists('confirmation message is present');
await click(find('[data-test-confirm-button]'));
assert.dom('.flash-message.alert-success').exists();
assert.equal(currentURL(), '/access-control/roles');
assert.equal(currentURL(), '/administration/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');
assert.equal(currentURL(), '/administration/roles/new');
await fillIn('[data-test-role-name-input]', 'test-role');
await click('button[data-test-save-role]');
assert
@@ -279,19 +279,19 @@ module('Acceptance | roles', function (hooks) {
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.equal(currentURL(), '/administration/roles/1'); // default id created via mirage
await Administration.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();
await Administration.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 Administration.visitRoles();
await click('[data-test-create-role]');
assert.dom('.empty-message').exists();
assert

View File

@@ -21,7 +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 Administration from 'nomad-ui/tests/pages/administration';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
import moment from 'moment';
@@ -621,7 +621,7 @@ module('Acceptance | tokens', function (hooks) {
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
await visit('/administration/policies');
assert.dom('[data-test-policy-total-tokens]').exists();
const expectedFirstPolicyTokens = server.db.tokens.filter((token) => {
return token.policyIds.includes(firstPolicy.name);
@@ -648,9 +648,9 @@ module('Acceptance | tokens', function (hooks) {
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
await visit('/administration/policies');
await click('[data-test-policy-name]');
assert.equal(currentURL(), `/access-control/policies/${firstPolicy.name}`);
assert.equal(currentURL(), `/administration/policies/${firstPolicy.name}`);
const expectedFirstPolicyTokens = server.db.tokens.filter((token) => {
return token.policyIds.includes(firstPolicy.name);
@@ -692,10 +692,10 @@ module('Acceptance | tokens', function (hooks) {
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
await visit('/administration/policies');
await click('[data-test-policy-name]:first-child');
assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`);
assert.equal(currentURL(), `/administration/policies/${testPolicy.name}`);
assert
.dom('[data-test-policy-token-row]')
.exists(
@@ -730,10 +730,10 @@ module('Acceptance | tokens', function (hooks) {
);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await visit('/access-control/policies');
await visit('/administration/policies');
await click('[data-test-policy-name]');
assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`);
assert.equal(currentURL(), `/administration/policies/${testPolicy.name}`);
assert
.dom('[data-test-policy-token-row]')
@@ -885,7 +885,7 @@ module('Acceptance | tokens', function (hooks) {
);
const { secretId } = managementToken;
await Tokens.secret(secretId).submit();
await AccessControl.visitTokens();
await Administration.visitTokens();
});
hooks.afterEach(async function () {
@@ -894,7 +894,7 @@ module('Acceptance | tokens', function (hooks) {
});
test('Tokens index, general', async function (assert) {
assert.equal(currentURL(), '/access-control/tokens');
assert.equal(currentURL(), '/administration/tokens');
// Number of token rows equivalent to number in db
assert
.dom('[data-test-token-row]')
@@ -1002,7 +1002,7 @@ module('Acceptance | tokens', function (hooks) {
(row) => row.textContent.includes(tokenToClick.name)
);
await click(tokenRowToClick.querySelector('[data-test-token-name] a'));
assert.equal(currentURL(), `/access-control/tokens/${tokenToClick.id}`);
assert.equal(currentURL(), `/administration/tokens/${tokenToClick.id}`);
assert.dom('[data-test-token-name-input]').hasValue(tokenToClick.name);
});
@@ -1059,7 +1059,7 @@ module('Acceptance | tokens', function (hooks) {
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}`);
await visit(`/administration/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);
@@ -1136,18 +1136,18 @@ module('Acceptance | tokens', function (hooks) {
});
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}`);
await visit(`/administration/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();
await Administration.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}`);
await visit(`/administration/tokens/${token.id}`);
// The policies/roles belonging to this token are checked
const tokenPolicies = token.policyIds;
@@ -1199,7 +1199,7 @@ module('Acceptance | tokens', function (hooks) {
await percySnapshot(assert);
await AccessControl.visitTokens();
await Administration.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) =>
@@ -1220,7 +1220,7 @@ module('Acceptance | tokens', function (hooks) {
});
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 visit(`/administration/tokens/${token.id}`);
const deleteButton = find('[data-test-delete-token] button');
assert.dom(deleteButton).exists('delete button is present');
@@ -1231,16 +1231,16 @@ module('Acceptance | tokens', function (hooks) {
await click(find('[data-test-confirm-button]'));
assert.dom('.flash-message.alert-success').exists();
await AccessControl.visitTokens();
await Administration.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');
assert.equal(currentURL(), '/administration/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();
await Administration.visitTokens();
assert
.dom('[data-test-token-name="Timeless Token"]')
.exists({ count: 1 });
@@ -1254,13 +1254,13 @@ module('Acceptance | tokens', function (hooks) {
// Now create one with a TTL
await click('[data-test-create-token]');
assert.equal(currentURL(), '/access-control/tokens/new');
assert.equal(currentURL(), '/administration/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();
await Administration.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')
@@ -1272,7 +1272,7 @@ module('Acceptance | tokens', function (hooks) {
// Now create one with an expiration time
await click('[data-test-create-token]');
assert.equal(currentURL(), '/access-control/tokens/new');
assert.equal(currentURL(), '/administration/tokens/new');
await fillIn('[data-test-token-name-input]', 'Expiring Token');
// select the Custom radio button
await click('.expiration-time input[value="custom"]');
@@ -1288,7 +1288,7 @@ module('Acceptance | tokens', function (hooks) {
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();
await Administration.visitTokens();
assert
.dom('[data-test-token-name="Expiring Token"]')
.exists({ count: 1 });

View File

@@ -1,14 +0,0 @@
/**
* 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'),
visitNamespaces: visitable('/access-control/namespaces'),
});

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { create, visitable } from 'ember-cli-page-object';
export default create({
visit: visitable('/administration'),
visitTokens: visitable('/administration/tokens'),
visitPolicies: visitable('/administration/policies'),
visitRoles: visitable('/administration/roles'),
visitNamespaces: visitable('/administration/namespaces'),
});