Secure Variables UI: Router setup and /variables/index route + guards (#12967)

* Route init

* Bones of a mirage-mocked secure variables policy

* Functinoing policy for list vars

* Delog and transition on route

* Basic guard test

* Page guard tests for secure variables

* Cleanup and unit tests for variables ability

* Linter cleanup

* Set expectations for test assertions

* PR feedback addressed

* Read label changed to View per suggestion
This commit is contained in:
Phil Renaud 2022-05-17 14:52:14 -04:00 committed by Tim Gross
parent 2019eab2c8
commit 7ddc4c8359
14 changed files with 302 additions and 11 deletions

View file

@ -0,0 +1,19 @@
import AbstractAbility from './abstract';
import { computed, get } from '@ember/object';
import { or } from '@ember/object/computed';
export default class extends AbstractAbility {
@or(
'bypassAuthorization',
'selfTokenIsManagement',
'policiesSupportVariableView'
)
canList;
@computed('rulesForNamespace.@each.capabilities')
get policiesSupportVariableView() {
return this.rulesForNamespace.some((rules) => {
return get(rules, 'SecureVariables');
});
}
}

View file

@ -0,0 +1,5 @@
import Controller from '@ember/controller';
export default class VariablesIndexController extends Controller {
isForbidden = false;
}

View file

@ -78,4 +78,5 @@ Router.map(function () {
this.route('evaluations', function () {});
this.route('not-found', { path: '/*' });
this.route('variables', function () {});
});

View file

@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
import { inject as service } from '@ember/service';
export default class VariablesRoute extends Route.extend(withForbiddenState) {
@service can;
@service router;
beforeModel() {
if (this.can.cannot('list variables')) {
this.router.transitionTo('/jobs');
}
}
model() {
// TODO: Populate model from /variables
return {};
}
}

View file

@ -0,0 +1,17 @@
import Route from '@ember/routing/route';
import withForbiddenState from '../../mixins/with-forbidden-state';
export default class VariablesIndexRoute extends Route.extend(
withForbiddenState
) {
model() {
// TODO: Fill in model with format from API
return {};
// return RSVP.hash({
// variables: this.store
// .query('variable', { namespace: params.qpNamespace })
// .catch(notifyForbidden(this)),
// namespaces: this.store.findAll('namespace'),
// });
}
}

View file

@ -32,9 +32,6 @@
</ul>
</div>
{{/if}}
<p class="menu-label">
Workload
</p>
<ul class="menu-list">
<li>
<LinkTo
@ -56,11 +53,6 @@
</LinkTo>
</li>
{{/if}}
</ul>
<p class="menu-label is-minor">
Integrations
</p>
<ul class="menu-list">
<li>
<LinkTo
@route="csi"
@ -70,6 +62,17 @@
Storage
</LinkTo>
</li>
{{#if (can "list variables")}}
<li>
<LinkTo
@route="variables"
@activeClass="is-active"
data-test-gutter-link="variables"
>
Variables
</LinkTo>
</li>
{{/if}}
</ul>
<p class="menu-label">
Cluster

View file

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

View file

@ -0,0 +1,15 @@
{{page-title "Secure Variables"}}
<section class="section">
{{#if this.isForbidden}}
<ForbiddenMessage />
{{else}}
<div class="empty-message">
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">
No Secure Variables
</h3>
<p class="empty-message-body">
Get started by <LinkTo @route="variables">creating a new secure variable</LinkTo>
</p>
</div>
{{/if}}
</section>

View file

@ -7,9 +7,9 @@ export default Factory.extend({
return this.id;
},
secretId: () => faker.random.uuid(),
name: () => faker.name.findName(),
name: (i) => `${i === 0 ? 'Manager ' : ''}${faker.name.findName()}`,
global: () => faker.random.boolean(),
type: i => (i === 0 ? 'management' : 'client'),
type: (i) => (i === 0 ? 'management' : 'client'),
oneTimeSecret: () => faker.random.uuid(),
@ -19,7 +19,7 @@ export default Factory.extend({
.map(() => faker.hacker.verb())
.uniq();
policyIds.forEach(policy => {
policyIds.forEach((policy) => {
const dbPolicy = server.db.policies.find(policy);
if (!dbPolicy) {
server.create('policy', { id: policy });
@ -27,5 +27,44 @@ export default Factory.extend({
});
token.update({ policyIds });
// Create a special policy with secure variables rules in place
if (token.id === '53cur3-v4r14bl35') {
const variableMakerPolicy = {
id: 'Variable Maker',
rules: `
# Allow read only access to the default namespace
namespace "default" {
policy = "read"
capabilities = ["list-jobs", "alloc-exec", "read-logs"]
secure_variables {
path "*" {
capabilities = ["list"]
}
}
}
node {
policy = "read"
}
`,
rulesJSON: {
Namespaces: [
{
Name: 'default',
Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
SecureVariables: {
'Path "*"': {
Capabilities: ['list'],
},
},
},
],
},
};
server.create('policy', variableMakerPolicy);
token.policyIds.push(variableMakerPolicy.id);
}
},
});

View file

@ -205,6 +205,10 @@ function emptyCluster(server) {
function createTokens(server) {
server.createList('token', 3);
server.create('token', {
name: 'Secure McVariables',
id: '53cur3-v4r14bl35',
});
logTokens(server);
}

View file

@ -0,0 +1,53 @@
import { module, test } from 'qunit';
import { currentURL } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import defaultScenario from '../../mirage/scenarios/default';
import Variables from 'nomad-ui/tests/pages/variables';
import Layout from 'nomad-ui/tests/pages/layout';
const SECURE_TOKEN_ID = '53cur3-v4r14bl35';
module('Acceptance | secure variables', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
module('Guarding page access', function () {
test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function (assert) {
await Variables.visit();
assert.equal(currentURL(), '/jobs');
assert.ok(Layout.gutter.variables.isHidden);
});
test('it allows access for management level tokens', async function (assert) {
defaultScenario(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
await Variables.visit();
assert.equal(currentURL(), '/variables');
assert.ok(Layout.gutter.variables.isVisible);
});
test('it allows access for list-variables allowed ACL rules', async function (assert) {
assert.expect(2);
defaultScenario(server);
const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
await Variables.visit();
assert.equal(currentURL(), '/variables');
assert.ok(Layout.gutter.variables.isVisible);
});
test('it passes an accessibility audit', async function (assert) {
assert.expect(1);
defaultScenario(server);
const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
await Variables.visit();
await a11yAudit(assert);
});
});
});

View file

@ -73,6 +73,10 @@ export default create({
scope: '[data-test-gutter-link="optimize"]',
},
variables: {
scope: '[data-test-gutter-link="variables"]',
},
visitClients: clickable('[data-test-gutter-link="clients"]'),
visitServers: clickable('[data-test-gutter-link="servers"]'),
visitStorage: clickable('[data-test-gutter-link="storage"]'),

View file

@ -0,0 +1,5 @@
import { create, visitable } from 'ember-cli-page-object';
export default create({
visit: visitable('/variables'),
});

View file

@ -0,0 +1,104 @@
/* eslint-disable ember/avoid-leaking-state-in-ember-objects */
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import Service from '@ember/service';
import setupAbility from 'nomad-ui/tests/helpers/setup-ability';
module('Unit | Ability | variable', function (hooks) {
setupTest(hooks);
setupAbility('variable')(hooks);
module('when the Variables feature is not present', function (hooks) {
hooks.beforeEach(function () {
const mockSystem = Service.extend({
features: [],
});
this.owner.register('service:system', mockSystem);
});
test('it does not permit listing variables by default', function (assert) {
const mockToken = Service.extend({
aclEnabled: true,
});
this.owner.register('service:token', mockToken);
assert.notOk(this.ability.canList);
});
test('it does not permit listing variables when token type is client', function (assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
});
this.owner.register('service:token', mockToken);
assert.notOk(this.ability.canList);
});
test('it permits listing variables when token type is management', function (assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'management' },
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canList);
});
test('it permits listing variables when token has SecureVariables with list capabilities in its rules', function (assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
rulesJSON: {
Namespaces: [
{
Name: 'default',
Capabilities: [],
SecureVariables: {
'Path "*"': {
Capabilities: ['list'],
},
},
},
],
},
},
],
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canList);
});
test('it permits listing variables when token has SecureVariables alone in its rules', function (assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
rulesJSON: {
Namespaces: [
{
Name: 'default',
Capabilities: [],
SecureVariables: {},
},
],
},
},
],
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canList);
});
});
});