[ui] Policies UI (#13976)

Co-authored-by: Mike Nomitch <mail@mikenomitch.com>
This commit is contained in:
Phil Renaud 2022-12-06 12:45:36 -05:00 committed by GitHub
parent d3aac1fe08
commit ce0ffdd077
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 732 additions and 11 deletions

3
.changelog/13976.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Added a Policy Editor interface for management tokens
```

View File

@ -0,0 +1,12 @@
import AbstractAbility from './abstract';
import { alias } from '@ember/object/computed';
import classic from 'ember-classic-decorator';
@classic
export default class Policy extends AbstractAbility {
@alias('selfTokenIsManagement') canRead;
@alias('selfTokenIsManagement') canList;
@alias('selfTokenIsManagement') canWrite;
@alias('selfTokenIsManagement') canUpdate;
@alias('selfTokenIsManagement') canDestroy;
}

View File

@ -4,4 +4,12 @@ import classic from 'ember-classic-decorator';
@classic
export default class PolicyAdapter extends ApplicationAdapter {
namespace = namespace + '/acl';
urlForCreateRecord(_modelName, model) {
return this.urlForUpdateRecord(model.attr('name'), 'policy');
}
urlForDeleteRecord(id) {
return this.urlForUpdateRecord(id, 'policy');
}
}

View File

@ -0,0 +1,61 @@
<form class="edit-policy" autocomplete="off" {{on "submit" this.save}}>
{{#if @policy.isNew }}
<label>
<span>
Policy Name
</span>
<Input
data-test-policy-name-input
@type="text"
@value={{@policy.name}}
class="input"
{{autofocus}}
/>
</label>
{{/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.rules
onUpdate=this.updatePolicyRules
autofocus=(not @policy.isNew)
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>
<footer>
{{#if (can "update policy")}}
<button
class="button is-primary"
type="submit"
>
Save Policy
</button>
{{/if}}
</footer>
</form>

View File

@ -0,0 +1,62 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import messageForError from 'nomad-ui/utils/message-from-adapter-error';
export default class PolicyEditorComponent extends Component {
@service flashMessages;
@service router;
@service store;
@alias('args.policy') policy;
@action updatePolicyRules(value) {
this.policy.set('rules', value);
}
@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.isNew &&
this.store.peekRecord('policy', this.policy.name)
) {
throw new Error(
`A policy with name ${this.policy.name} already exists.`
);
}
this.policy.id = this.policy.name;
await this.policy.save();
this.flashMessages.add({
title: 'Policy Saved',
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('policies');
} catch (error) {
console.log('error and its', error);
this.flashMessages.add({
title: `Error creating Policy ${this.policy.name}`,
message: messageForError(error),
type: 'error',
destroyOnClick: false,
sticky: true,
});
}
}
}

View File

@ -0,0 +1,19 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class PoliciesIndexController extends Controller {
@service router;
get policies() {
return this.model.policies.map((policy) => {
policy.tokens = this.model.tokens.filter((token) => {
return token.policies.includes(policy);
});
return policy;
});
}
@action openPolicy(policy) {
this.router.transitionTo('policies.policy', policy.name);
}
}

View File

@ -0,0 +1,46 @@
// @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 { task } from 'ember-concurrency';
export default class PoliciesPolicyController extends Controller {
@service flashMessages;
@service router;
@tracked isDeleting = false;
@action
onDeletePrompt() {
this.isDeleting = true;
}
@action
onDeleteCancel() {
this.isDeleting = false;
}
@task(function* () {
try {
yield this.model.deleteRecord();
yield this.model.save();
this.flashMessages.add({
title: 'Policy Deleted',
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('policies');
} catch (err) {
this.flashMessages.add({
title: `Error deleting Policy ${this.model.name}`,
message: err,
type: 'error',
destroyOnClick: false,
sticky: true,
});
}
})
deletePolicy;
}

View File

@ -8,8 +8,18 @@ import 'codemirror/addon/selection/active-line';
import 'codemirror/addon/lint/lint.js';
import 'codemirror/addon/lint/json-lint.js';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/ruby/ruby';
export default class CodeMirrorModifier extends Modifier {
get autofocus() {
if (Object.hasOwn({ ...this.args.named }, 'autofocus')) {
// spread (...) because proxy, and because Ember over-eagerly prevents named prop lookups for modifier args.
return this.args.named.autofocus;
} else {
return !this.args.named.readOnly;
}
}
didInstall() {
this._setup();
}
@ -49,6 +59,10 @@ export default class CodeMirrorModifier extends Modifier {
screenReaderLabel: this.args.named.screenReaderLabel || '',
});
if (this.autofocus) {
editor.focus();
}
editor.on('change', bind(this, this._onChange));
this._editor = editor;

View File

@ -98,6 +98,14 @@ Router.map(function () {
path: '/path/*absolutePath',
});
});
this.route('policies', function () {
this.route('new');
this.route('policy', {
path: '/:name',
});
});
// Mirage-only route for testing OIDC flow
if (config['ember-cli-mirage']) {
this.route('oidc-mock');

27
ui/app/routes/policies.js Normal file
View File

@ -0,0 +1,27 @@
import Route from '@ember/routing/route';
import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class PoliciesRoute extends Route.extend(
withForbiddenState,
WithModelErrorHandling
) {
@service can;
@service store;
@service router;
beforeModel() {
if (this.can.cannot('list policies')) {
this.router.transitionTo('/jobs');
}
}
async model() {
return await hash({
policies: this.store.query('policy', { reload: true }),
tokens: this.store.query('token', { reload: true }),
});
}
}

View File

@ -0,0 +1,109 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
const INITIAL_POLICY_RULES = `# See https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-policies for ACL Policy details
# Example policy structure:
namespace "default" {
policy = "deny"
capabilities = []
}
namespace "example-ns" {
policy = "deny"
capabilities = ["list-jobs", "read-job"]
variables {
# list access to variables in all paths, full access in nested/variables/*
path "*" {
capabilities = ["list"]
}
path "nested/variables/*" {
capabilities = ["write", "read", "destroy", "list"]
}
}
}
host_volume "example-volume" {
policy = "deny"
}
agent {
policy = "deny"
}
node {
policy = "deny"
}
quota {
policy = "deny"
}
operator {
policy = "deny"
}
# Possible Namespace Policies:
# * deny
# * read
# * write
# * scale
# Possible Namespace Capabilities:
# * list-jobs
# * parse-job
# * read-job
# * submit-job
# * dispatch-job
# * read-logs
# * read-fs
# * alloc-exec
# * alloc-lifecycle
# * csi-write-volume
# * csi-mount-volume
# * list-scaling-policies
# * read-scaling-policy
# * read-job-scaling
# * scale-job
# Possible Variables capabilities
# * write
# * read
# * destroy
# * list
# Possible Policies for "agent", "node", "quota", "operator", and "host_volume":
# * deny
# * read
# * write
`;
export default class PoliciesNewRoute extends Route {
@service can;
@service router;
beforeModel() {
if (this.can.cannot('write policy')) {
this.router.transitionTo('/policies');
}
}
model() {
return this.store.createRecord('policy', {
name: '',
rules: INITIAL_POLICY_RULES,
});
}
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) {
controller.model.destroyRecord();
}
}
}
}

View File

@ -0,0 +1,16 @@
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';
export default class PoliciesPolicyRoute extends Route.extend(
withForbiddenState,
WithModelErrorHandling
) {
@service store;
model(params) {
return this.store.findRecord('policy', decodeURIComponent(params.name), {
reload: true,
});
}
}

View File

@ -2,9 +2,17 @@ import ApplicationSerializer from './application';
import classic from 'ember-classic-decorator';
@classic
export default class Policy extends ApplicationSerializer {
export default class PolicySerializer extends ApplicationSerializer {
primaryKey = 'Name';
normalize(typeHash, hash) {
hash.ID = hash.Name;
return super.normalize(typeHash, hash);
}
serialize(snapshot, options) {
const hash = super.serialize(snapshot, options);
hash.ID = hash.Name;
return hash;
}
}

View File

@ -51,3 +51,4 @@
@import './components/services';
@import './components/task-sub-row';
@import './components/authorization';
@import './components/policies';

View File

@ -0,0 +1,27 @@
table.policies {
tr {
cursor: pointer;
&:hover td {
background-color: #f5f5f5;
}
a {
color: black;
text-decoration: none;
}
.number-expired {
color: $red;
}
}
}
.edit-policy {
.policy-editor {
max-height: 600px;
overflow: auto;
}
.input {
margin-bottom: 1rem;
}
}

View File

@ -4,6 +4,7 @@ section.notifications {
position: fixed;
bottom: 10px;
right: 10px;
z-index: 100;
.flash-message {
width: 300px;

View File

@ -7,10 +7,12 @@
border-collapse: separate;
width: 100%;
&:not(.no-mobile-condense) {
@media #{$mq-table-overflow} {
display: block;
overflow-x: auto;
}
}
&.is-fixed {
table-layout: fixed;

View File

@ -114,7 +114,7 @@
</li>
</ul>
<p class="menu-label">
Debugging
Operations
</p>
<ul class="menu-list">
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "e") }}>
@ -126,6 +126,23 @@
Evaluations
</LinkTo>
</li>
{{#if (can "list policies")}}
<li
{{keyboard-shortcut
menuLevel=true
pattern=(array "g" "l")
action=(action this.transitionTo 'policies')
}}
>
<LinkTo
@route="policies"
@activeClass="is-active"
data-test-gutter-link="policies"
>
Policies
</LinkTo>
</li>
{{/if}}
</ul>
</aside>
{{#if this.system.agent.version}}

View File

@ -0,0 +1,4 @@
<Breadcrumb @crumb={{hash label="Policies" args=(array "policies.index")}} />
<PageLayout>
{{outlet}}
</PageLayout>

View File

@ -0,0 +1,67 @@
{{page-title "Policies"}}
<section class="section">
<div class="toolbar">
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
{{#if (can "write policy")}}
<LinkTo
@route="policies.new"
class="button is-primary"
data-test-create-policy
>
Create Policy
</LinkTo>
{{else}}
<button
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have sufficient permissions"
disabled
type="button"
data-test-disabled-create-policy
>
Create Policy
</button>
{{/if}}
</div>
</div>
</div>
{{#if this.policies.length}}
<ListTable
@source={{this.policies}}
@class="policies no-mobile-condense" as |t|>
<t.head>
<th>Policy Name</th>
<th>Tokens</th>
</t.head>
<t.body as |row|>
<tr data-test-policy-row {{on "click" (action "openPolicy" row.model)}}
{{keyboard-shortcut
enumerated=true
action=(action "openPolicy" row.model)
}}>
<td data-test-policy-name>
<LinkTo @route="policies.policy" @model={{row.model.name}}>{{row.model.name}}</LinkTo>
</td>
<td data-test-policy-token-count>
<span>
{{row.model.tokens.length}}
{{#if (filter-by "isExpired" row.model.tokens)}}
<span class="number-expired">({{get (filter-by "isExpired" row.model.tokens) "length"}} expired)</span>
{{/if}}
</span>
</td>
</tr>
</t.body>
</ListTable>
{{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="policies.new">creating a new policy</LinkTo>
</p>
</div>
{{/if}}
</section>

View File

@ -0,0 +1,10 @@
<Breadcrumb @crumb={{hash label="New" args=(array "policies.new")}} />
{{page-title "Create Policy"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
Create Policy
</h1>
<PolicyEditor
@policy={{this.model}}
/>
</section>

View File

@ -0,0 +1,29 @@
<Breadcrumb @crumb={{hash label=this.model.name args=(array "policies.policy" this.model.name)}} />
{{page-title "Policy"}}
<section class="section">
<h1 class="title with-flex" data-test-title>
<div>
{{this.model.name}}
</div>
{{#if (can "destroy policy")}}
<div>
<TwoStepButton
data-test-delete-button
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{this.deletePolicy.isRunning}}
@onConfirm={{perform this.deletePolicy}}
@onPrompt={{this.onDeletePrompt}}
@onCancel={{this.onDeleteCancel}}
/>
</div>
{{/if}}
</h1>
<PolicyEditor
@policy={{this.model}}
/>
</section>
{{outlet}}

View File

@ -17,7 +17,7 @@ module.exports = function (defaults) {
},
},
codemirror: {
modes: ['javascript'],
modes: ['javascript', 'ruby'],
},
babel: {
include: ['proposal-optional-chaining'],

View File

@ -443,8 +443,7 @@ export default function () {
return JSON.stringify(findLeader(schema));
});
// Note: Mirage-only route, for UI testing and not part of the Nomad API
this.get('/acl/tokens', function ({ tokens }, req) {
this.get('/acl/tokens', function ({tokens}, req) {
return this.serialize(tokens.all());
});
@ -499,7 +498,7 @@ export default function () {
);
this.get('/acl/policy/:id', function ({ policies, tokens }, req) {
const policy = policies.find(req.params.id);
const policy = policies.findBy({ name: req.params.id });
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });
@ -526,6 +525,33 @@ export default function () {
return new Response(403, {}, null);
});
this.get('/acl/policies', function ({ policies }, req) {
return this.serialize(policies.all());
});
this.delete('/acl/policy/:id', function (schema, request) {
const { id } = request.params;
schema.tokens.all().models.filter(token => token.policyIds.includes(id)).forEach(token => {
token.update({ policyIds: token.policyIds.filter(pid => pid !== id) });
});
server.db.policies.remove(id);
return '';
});
this.put('/acl/policy/:id', function (schema, request) {
return new Response(200, {}, {});
});
this.post('/acl/policy/:id', function (schema, request) {
const { Name, Description, Rules } = JSON.parse(request.requestBody);
return server.create('policy', {
name: Name,
description: Description,
rules: Rules,
});
});
this.get('/regions', function ({ regions }) {
return this.serialize(regions.all());
});

View File

@ -7,8 +7,7 @@ export default Factory.extend({
return this.id;
},
description: () => (faker.random.number(10) >= 2 ? faker.lorem.sentence() : null),
rules: `
# Allow read only access to the default namespace
rules: `# Allow read only access to the default namespace
namespace "default" {
policy = "read"
}

View File

@ -19,6 +19,7 @@ export const allScenarios = {
emptyCluster,
variableTestCluster,
servicesTestCluster,
policiesTestCluster,
...topoScenarios,
...sysbatchScenarios,
};
@ -259,6 +260,13 @@ function variableTestCluster(server) {
});
}
function policiesTestCluster(server) {
faker.seed(1);
createTokens(server);
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
}
function servicesTestCluster(server) {
faker.seed(1);
server.create('feature', { name: 'Dynamic Application Sizing' });

View File

@ -0,0 +1,102 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, typeIn, findAll } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { allScenarios } from '../../mirage/scenarios/default';
import { setupMirage } from 'ember-cli-mirage/test-support';
import percySnapshot from '@percy/ember';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
module('Acceptance | policies', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('Policies index route looks good', async function (assert) {
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');
assert
.dom('[data-test-policy-row]')
.exists({ count: server.db.policies.length });
await a11yAudit(assert);
await percySnapshot(assert);
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
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');
assert.equal(currentURL(), '/jobs');
assert.dom('[data-test-gutter-link="policies"]').doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
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}`);
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('.flash-message.alert-success').exists();
assert.equal(currentURL(), '/policies');
// 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 click('[data-test-create-policy]');
assert.equal(currentURL(), '/policies/new');
await typeIn('[data-test-policy-name-input]', 'My Fun Policy');
await click('button[type="submit"]');
assert
.dom('.flash-message.alert-error')
.exists('Doesnt let you save a bad name');
assert.equal(currentURL(), '/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"]');
assert.dom('.flash-message.alert-success').exists();
assert.equal(currentURL(), '/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');
await percySnapshot(assert);
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
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(
(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]');
assert.dom('.flash-message.alert-success').exists();
assert.equal(currentURL(), '/policies');
assert.dom(`[data-test-policy-name="${firstPolicyName}"]`).doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
});

View File

@ -0,0 +1,35 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | policy-editor', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
assert.expect(1);
await render(hbs`<PolicyEditor />`);
await componentA11yAudit(this.element, assert);
});
test('Only has editable name if new', async function (assert) {
const newMockPolicy = {
isNew: true,
name: 'New Policy',
};
const oldMockPolicy = {
isNew: false,
name: 'Old Policy',
};
this.set('newMockPolicy', newMockPolicy);
this.set('oldMockPolicy', oldMockPolicy);
await render(hbs`<PolicyEditor @policy={{this.newMockPolicy}} />`);
assert.dom('[data-test-policy-name-input]').exists();
await render(hbs`<PolicyEditor @policy={{this.oldMockPolicy}} />`);
assert.dom('[data-test-policy-name-input]').doesNotExist();
});
});