diff --git a/ui/app/abilities/variable.js b/ui/app/abilities/variable.js index 40f77fe5b..9692e0b38 100644 --- a/ui/app/abilities/variable.js +++ b/ui/app/abilities/variable.js @@ -39,6 +39,13 @@ export default class Variable extends AbstractAbility { ) canDestroy; + @or( + 'bypassAuthorization', + 'selfTokenIsManagement', + 'policiesSupportVariableRead' + ) + canRead; + @computed('token.selfTokenPolicies') get policiesSupportVariableList() { return this.policyNamespacesIncludeSecureVariablesCapabilities( @@ -47,6 +54,14 @@ export default class Variable extends AbstractAbility { ); } + @computed('path', 'allPaths') + get policiesSupportVariableRead() { + const matchingPath = this._nearestMatchingPath(this.path); + return this.allPaths + .find((path) => path.name === matchingPath) + ?.capabilities?.includes('read'); + } + /** * * Map to your policy's namespaces, @@ -159,7 +174,6 @@ export default class Variable extends AbstractAbility { _nearestMatchingPath(path) { const pathNames = this.allPaths.map((path) => path.name); - if (pathNames.includes(path)) { return path; } diff --git a/ui/app/components/variable-paths.hbs b/ui/app/components/variable-paths.hbs index 5341971ea..668af2325 100644 --- a/ui/app/components/variable-paths.hbs +++ b/ui/app/components/variable-paths.hbs @@ -25,9 +25,14 @@ {{/each}} {{#each this.files as |file|}} - + + {{#if (can "read variable" path=file.absoluteFilePath namespace=file.variable.namespace)}} {{file.name}} + {{else}} + {{file.name}} + {{/if}} {{file.variable.namespace}} diff --git a/ui/app/components/variable-paths.js b/ui/app/components/variable-paths.js index 60a741441..ea361dfc0 100644 --- a/ui/app/components/variable-paths.js +++ b/ui/app/components/variable-paths.js @@ -5,6 +5,7 @@ import { inject as service } from '@ember/service'; import compactPath from '../utils/compact-path'; export default class VariablePathsComponent extends Component { @service router; + @service can; /** * @returns {Array>} @@ -25,7 +26,9 @@ export default class VariablePathsComponent extends Component { } @action - async handleFileClick(path) { - this.router.transitionTo('variables.variable', path); + async handleFileClick({ path, variable: { namespace } }) { + if (this.can.can('read variable', null, { path, namespace })) { + this.router.transitionTo('variables.variable', path); + } } } diff --git a/ui/app/controllers/variables/variable.js b/ui/app/controllers/variables/variable.js index 3fd68ad8f..bbb578fdd 100644 --- a/ui/app/controllers/variables/variable.js +++ b/ui/app/controllers/variables/variable.js @@ -3,11 +3,11 @@ import Controller from '@ember/controller'; export default class VariablesVariableController extends Controller { get breadcrumbs() { let crumbs = []; - this.model.path.split('/').reduce((m, n) => { + this.params.path.split('/').reduce((m, n) => { crumbs.push({ label: n, args: - m + n === this.model.path // If the last crumb, link to the var itself + m + n === this.params.path // If the last crumb, link to the var itself ? [`variables.variable`, m + n] : [`variables.path`, m + n], }); diff --git a/ui/app/routes/variables/variable.js b/ui/app/routes/variables/variable.js index 6150b2ddd..3e01dafed 100644 --- a/ui/app/routes/variables/variable.js +++ b/ui/app/routes/variables/variable.js @@ -1,16 +1,21 @@ 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 notifyForbidden from 'nomad-ui/utils/notify-forbidden'; export default class VariablesVariableRoute extends Route.extend( - withForbiddenState, - WithModelErrorHandling + withForbiddenState ) { @service store; model(params) { - return this.store.findRecord('variable', decodeURIComponent(params.path), { - reload: true, - }); + return this.store + .findRecord('variable', decodeURIComponent(params.path), { + reload: true, + }) + .catch(notifyForbidden(this)); + } + setupController(controller) { + super.setupController(controller); + controller.set('params', this.paramsFor('variables.variable')); } } diff --git a/ui/app/styles/components/secure-variables.scss b/ui/app/styles/components/secure-variables.scss index 12ae38730..d957e1dda 100644 --- a/ui/app/styles/components/secure-variables.scss +++ b/ui/app/styles/components/secure-variables.scss @@ -103,9 +103,8 @@ table.path-tree { tr { cursor: pointer; - a { - color: #0a0a0a; - text-decoration: none; + &.inaccessible { + cursor: not-allowed; } svg { margin-bottom: -2px; diff --git a/ui/app/templates/variables/variable.hbs b/ui/app/templates/variables/variable.hbs index 70d6e4446..7eea2143a 100644 --- a/ui/app/templates/variables/variable.hbs +++ b/ui/app/templates/variables/variable.hbs @@ -3,6 +3,10 @@ {{/each}}
- {{outlet}} + {{#if this.isForbidden}} + + {{else}} + {{outlet}} + {{/if}}
\ No newline at end of file diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 57ff741cc..d10f1eaa3 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -838,8 +838,12 @@ export default function () { //#region Secure Variables - this.get('/vars', function (schema) { - return schema.variables.all(); + this.get('/vars', function (schema, { queryParams: { namespace } }) { + if (namespace && namespace !== '*') { + return schema.variables.all().filter((v) => v.namespace === namespace); + } else { + return schema.variables.all(); + } }); this.get('/var/:id', function ({ variables }, { params }) { diff --git a/ui/mirage/factories/token.js b/ui/mirage/factories/token.js index 7971bc510..6b3d47245 100644 --- a/ui/mirage/factories/token.js +++ b/ui/mirage/factories/token.js @@ -34,23 +34,13 @@ export default Factory.extend({ id: 'Variable Maker', rules: ` # Allow read only access to the default namespace -namespace "default" { +namespace "*" { policy = "read" capabilities = ["list-jobs", "alloc-exec", "read-logs"] secure_variables { - # full access to secrets in all project paths - path "blue/*" { - capabilities = ["write", "read", "destroy", "list"] - } - - # full access to secrets in all project paths + # Base access is to all abilities for all secure variables path "*" { - capabilities = ["write", "read", "destroy", "list"] - } - - # read/list access within a "system" path belonging to administrators - path "system/*" { - capabilities = ["read", "list"] + capabilities = ["list", "read", "destroy", "create"] } } } @@ -63,22 +53,14 @@ node { rulesJSON: { Namespaces: [ { - Name: 'default', + Name: '*', Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], SecureVariables: { Paths: [ - { - Capabilities: ['write', 'read', 'destroy', 'list'], - PathSpec: 'blue/*', - }, { Capabilities: ['write', 'read', 'destroy', 'list'], PathSpec: '*', }, - { - Capabilities: ['read', 'list'], - PathSpec: 'system/*', - }, ], }, }, @@ -88,5 +70,99 @@ node { server.create('policy', variableMakerPolicy); token.policyIds.push(variableMakerPolicy.id); } + if (token.id === 'f3w3r-53cur3-v4r14bl35') { + const variableViewerPolicy = { + id: 'Variable Viewer', + rules: ` +# Allow read only access to the default namespace +namespace "*" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + secure_variables { + # Base access is to all abilities for all secure variables + path "*" { + capabilities = ["list"] + } + } +} + +namespace "namespace-1" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + secure_variables { + # Base access is to all abilities for all secure variables + path "*" { + capabilities = ["list", "read", "destroy", "create"] + } + } +} + +namespace "namespace-2" { + policy = "read" + capabilities = ["list-jobs", "alloc-exec", "read-logs"] + secure_variables { + # Base access is to all abilities for all secure variables + path "blue/*" { + capabilities = ["list", "read", "destroy", "create"] + } + path "nomad/jobs/*" { + capabilities = ["list", "read", "create"] + } + } +} + +node { + policy = "read" +} + `, + + rulesJSON: { + Namespaces: [ + { + Name: '*', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + SecureVariables: { + Paths: [ + { + Capabilities: ['list'], + PathSpec: '*', + }, + ], + }, + }, + { + Name: 'namespace-1', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + SecureVariables: { + Paths: [ + { + Capabilities: ['list', 'read', 'destroy', 'create'], + PathSpec: '*', + }, + ], + }, + }, + { + Name: 'namespace-2', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + SecureVariables: { + Paths: [ + { + Capabilities: ['list', 'read', 'destroy', 'create'], + PathSpec: 'blue/*', + }, + { + Capabilities: ['list', 'read', 'create'], + PathSpec: 'nomad/jobs/*', + }, + ], + }, + }, + ], + }, + }; + server.create('policy', variableViewerPolicy); + token.policyIds.push(variableViewerPolicy.id); + } }, }); diff --git a/ui/mirage/factories/variable.js b/ui/mirage/factories/variable.js index c2adb12a0..77eb0fd3b 100644 --- a/ui/mirage/factories/variable.js +++ b/ui/mirage/factories/variable.js @@ -1,5 +1,6 @@ import { Factory } from 'ember-cli-mirage'; import faker from 'nomad-ui/mirage/faker'; +import { provide, pickOne } from '../utils'; export default Factory.extend({ id: () => faker.random.words(3).split(' ').join('/').toLowerCase(), @@ -25,8 +26,7 @@ export default Factory.extend({ afterCreate(variable, server) { if (!variable.namespaceId) { - const namespace = - (server.db.jobs && server.db.jobs[0]?.namespace) || 'default'; + const namespace = pickOne(server.db.jobs)?.namespace || 'default'; variable.update({ namespace, }); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 956e3e0ab..125bc0e17 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -7,7 +7,7 @@ const withNamespaces = getConfigValue('mirageWithNamespaces', false); const withTokens = getConfigValue('mirageWithTokens', true); const withRegions = getConfigValue('mirageWithRegions', false); -const allScenarios = { +export const allScenarios = { smallCluster, mediumCluster, largeCluster, @@ -16,6 +16,7 @@ const allScenarios = { allNodeTypes, everyFeature, emptyCluster, + variableTestCluster, ...topoScenarios, ...sysbatchScenarios, }; @@ -75,11 +76,23 @@ function smallCluster(server) { 'just some arbitrary file', 'another arbitrary file', 'another arbitrary file again', - `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}/${variableLinkedTask.name}`, - `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}`, - `nomad/jobs/${variableLinkedJob.id}`, ].forEach((path) => server.create('variable', { id: path })); + server.create('variable', { + id: `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}/${variableLinkedTask.name}`, + namespaceId: variableLinkedJob.namespace, + }); + + server.create('variable', { + id: `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}`, + namespaceId: variableLinkedJob.namespace, + }); + + server.create('variable', { + id: `nomad/jobs/${variableLinkedJob.id}`, + namespaceId: variableLinkedJob.namespace, + }); + // #region evaluations // Branching: a single eval that relates to N-1 mutually-unrelated evals @@ -156,6 +169,58 @@ function mediumCluster(server) { server.createList('job', 25); } +function variableTestCluster(server) { + createTokens(server); + createNamespaces(server); + server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); + server.createList('node', 5); + server.createList('job', 3); + server.createList('variable', 3); + // server.createList('allocFile', 5); + // server.create('allocFile', 'dir', { depth: 2 }); + // server.createList('csi-plugin', 2); + + const variableLinkedJob = server.db.jobs[0]; + const variableLinkedGroup = server.db.taskGroups.findBy({ + jobId: variableLinkedJob.id, + }); + const variableLinkedTask = server.db.tasks.findBy({ + taskGroupId: variableLinkedGroup.id, + }); + [ + 'a/b/c/foo0', + 'a/b/c/bar1', + 'a/b/c/d/e/foo2', + 'a/b/c/d/e/bar3', + 'a/b/c/d/e/f/foo4', + 'a/b/c/d/e/f/g/foo5', + 'a/b/c/x/y/z/foo6', + 'a/b/c/x/y/z/bar7', + 'a/b/c/x/y/z/baz8', + 'w/x/y/foo9', + 'w/x/y/z/foo10', + 'w/x/y/z/bar11', + 'just some arbitrary file', + 'another arbitrary file', + 'another arbitrary file again', + ].forEach((path) => server.create('variable', { id: path })); + + server.create('variable', { + id: `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}/${variableLinkedTask.name}`, + namespaceId: variableLinkedJob.namespace, + }); + + server.create('variable', { + id: `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}`, + namespaceId: variableLinkedJob.namespace, + }); + + server.create('variable', { + id: `nomad/jobs/${variableLinkedJob.id}`, + namespaceId: variableLinkedJob.namespace, + }); +} + // Due to Mirage performance, large cluster scenarios will be slow function largeCluster(server) { server.createList('agent', 5); @@ -238,6 +303,10 @@ function createTokens(server) { name: 'Secure McVariables', id: '53cur3-v4r14bl35', }); + server.create('token', { + name: "Safe O'Constants", + id: 'f3w3r-53cur3-v4r14bl35', + }); logTokens(server); } diff --git a/ui/tests/acceptance/secure-variables-test.js b/ui/tests/acceptance/secure-variables-test.js index 36bd51403..7cf0a1727 100644 --- a/ui/tests/acceptance/secure-variables-test.js +++ b/ui/tests/acceptance/secure-variables-test.js @@ -15,7 +15,7 @@ import { import { setupApplicationTest } from 'ember-qunit'; import { module, test } from 'qunit'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; -import defaultScenario from '../../mirage/scenarios/default'; +import { allScenarios } from '../../mirage/scenarios/default'; import cleanWhitespace from '../utils/clean-whitespace'; import percySnapshot from '@percy/ember'; @@ -23,6 +23,7 @@ import Variables from 'nomad-ui/tests/pages/variables'; import Layout from 'nomad-ui/tests/pages/layout'; const SECURE_TOKEN_ID = '53cur3-v4r14bl35'; +const LIMITED_SECURE_TOKEN_ID = 'f3w3r-53cur3-v4r14bl35'; module('Acceptance | secure variables', function (hooks) { setupApplicationTest(hooks); @@ -38,7 +39,7 @@ module('Acceptance | secure variables', function (hooks) { }); test('it allows access for management level tokens', async function (assert) { - defaultScenario(server); + allScenarios.variableTestCluster(server); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; await Variables.visit(); assert.equal(currentURL(), '/variables'); @@ -47,7 +48,7 @@ module('Acceptance | secure variables', function (hooks) { test('it allows access for list-variables allowed ACL rules', async function (assert) { assert.expect(2); - defaultScenario(server); + allScenarios.variableTestCluster(server); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -59,7 +60,7 @@ module('Acceptance | secure variables', function (hooks) { test('it correctly traverses to and deletes a variable', async function (assert) { assert.expect(13); - defaultScenario(server); + allScenarios.variableTestCluster(server); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; server.db.variables.update({ namespace: 'default' }); @@ -110,7 +111,7 @@ module('Acceptance | secure variables', function (hooks) { const deleteButton = find('[data-test-delete-button] button'); assert.dom(deleteButton).exists('delete button is present'); - await percySnapshot(assert); + await percySnapshot('deeply nested variable'); await click(deleteButton); assert @@ -144,7 +145,7 @@ module('Acceptance | secure variables', function (hooks) { test('variables prefixed with nomad/jobs/ correctly link to entities', async function (assert) { assert.expect(23); - defaultScenario(server); + allScenarios.variableTestCluster(server); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); const variableLinkedJob = server.db.jobs[0]; @@ -154,10 +155,10 @@ module('Acceptance | secure variables', function (hooks) { const variableLinkedTask = server.db.tasks.findBy({ taskGroupId: variableLinkedGroup.id, }); - const variableLinkedTaskAlloc = server.db.allocations.filterBy( - 'taskGroup', - variableLinkedGroup.name - )[1]; + const variableLinkedTaskAlloc = server.db.allocations + .filterBy('taskGroup', variableLinkedGroup.name) + ?.find((alloc) => alloc.taskStateIds.length); + window.localStorage.nomadTokenSecret = variablesToken.secretId; // Non-job variable @@ -213,7 +214,7 @@ module('Acceptance | secure variables', function (hooks) { 'Related Entities box is job-oriented' ); - await percySnapshot(assert); + await percySnapshot('related entities box for job variable'); let relatedJobLink = find('.related-entities a'); await click(relatedJobLink); @@ -249,7 +250,7 @@ module('Acceptance | secure variables', function (hooks) { 'Related Entities box is group-oriented' ); - await percySnapshot(assert); + await percySnapshot('related entities box for group variable'); let relatedGroupLink = find('.related-entities a'); await click(relatedGroupLink); @@ -287,7 +288,7 @@ module('Acceptance | secure variables', function (hooks) { 'Related Entities box is task-oriented' ); - await percySnapshot(assert); + await percySnapshot('related entities box for task variable'); let relatedTaskLink = find('.related-entities a'); await click(relatedTaskLink); @@ -316,7 +317,7 @@ module('Acceptance | secure variables', function (hooks) { test('it does not allow you to save if you lack Items', async function (assert) { assert.expect(5); - defaultScenario(server); + allScenarios.variableTestCluster(server); window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; await Variables.visitNew(); assert.equal(currentURL(), '/variables/new'); @@ -339,7 +340,7 @@ module('Acceptance | secure variables', function (hooks) { test('it passes an accessibility audit', async function (assert) { assert.expect(1); - defaultScenario(server); + allScenarios.variableTestCluster(server); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; await Variables.visit(); @@ -349,7 +350,7 @@ module('Acceptance | secure variables', function (hooks) { module('create flow', function () { test('allows a user with correct permissions to create a secure variable', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -402,7 +403,7 @@ module('Acceptance | secure variables', function (hooks) { test('prevents users from creating a secure variable without proper permissions', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -425,7 +426,7 @@ module('Acceptance | secure variables', function (hooks) { test('allows creating a variable that starts with nomad/jobs/', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -452,7 +453,7 @@ module('Acceptance | secure variables', function (hooks) { test('disallows creating a variable that starts with nomad//', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -477,14 +478,14 @@ module('Acceptance | secure variables', function (hooks) { test('allows a user with correct permissions to edit a secure variable', async function (assert) { assert.expect(8); // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; const policy = server.db.policies.find('Variable Maker'); policy.rulesJSON.Namespaces[0].SecureVariables.Paths.find( (path) => path.PathSpec === '*' - ).Capabilities = ['list', 'write']; + ).Capabilities = ['list', 'read', 'write']; server.db.variables.update({ namespace: 'default' }); await Variables.visit(); await click('[data-test-file-row]'); @@ -532,14 +533,14 @@ module('Acceptance | secure variables', function (hooks) { test('prevents users from editing a secure variable without proper permissions', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; const policy = server.db.policies.find('Variable Maker'); policy.rulesJSON.Namespaces[0].SecureVariables.Paths.find( (path) => path.PathSpec === '*' - ).Capabilities = ['list']; + ).Capabilities = ['list', 'read']; await Variables.visit(); await click('[data-test-file-row]'); // End Test Set-up @@ -557,19 +558,18 @@ module('Acceptance | secure variables', function (hooks) { module('delete flow', function () { test('allows a user with correct permissions to delete a secure variable', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; const policy = server.db.policies.find('Variable Maker'); policy.rulesJSON.Namespaces[0].SecureVariables.Paths.find( (path) => path.PathSpec === '*' - ).Capabilities = ['list', 'destroy']; + ).Capabilities = ['list', 'read', 'destroy']; server.db.variables.update({ namespace: 'default' }); await Variables.visit(); await click('[data-test-file-row]'); // End Test Set-up - assert.equal(currentRouteName(), 'variables.variable.index'); assert .dom('[data-test-delete-button]') @@ -594,14 +594,14 @@ module('Acceptance | secure variables', function (hooks) { test('prevents users from delete a secure variable without proper permissions', async function (assert) { // Arrange Test Set-up - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; const policy = server.db.policies.find('Variable Maker'); policy.rulesJSON.Namespaces[0].SecureVariables.Paths.find( (path) => path.PathSpec === '*' - ).Capabilities = ['list']; + ).Capabilities = ['list', 'read']; await Variables.visit(); await click('[data-test-file-row]'); // End Test Set-up @@ -616,12 +616,51 @@ module('Acceptance | secure variables', function (hooks) { }); }); + module('read flow', function () { + test('allows a user with correct permissions to read a secure variable', async function (assert) { + allScenarios.variableTestCluster(server); + const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); + window.localStorage.nomadTokenSecret = variablesToken.secretId; + await Variables.visit(); + + assert + .dom('[data-test-file-row]:not(.inaccessible)') + .exists( + { count: 3 }, + 'Shows 3 variable files, none of which are inaccessible' + ); + + await click('[data-test-file-row]'); + assert.equal(currentRouteName(), 'variables.variable.index'); + + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('prevents users from reading a secure variable without proper permissions', async function (assert) { + allScenarios.variableTestCluster(server); + const variablesToken = server.db.tokens.find(LIMITED_SECURE_TOKEN_ID); + window.localStorage.nomadTokenSecret = variablesToken.secretId; + await Variables.visit(); + + assert + .dom('[data-test-file-row].inaccessible') + .exists( + { count: 3 }, + 'Shows 3 variable files, all of which are inaccessible' + ); + + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + }); + module('namespace filtering', function () { test('allows a user to filter variables by namespace', async function (assert) { assert.expect(3); // Arrange - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -653,7 +692,7 @@ module('Acceptance | secure variables', function (hooks) { }); test('does not show namespace filtering if the user only has access to one namespace', async function (assert) { - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -676,7 +715,7 @@ module('Acceptance | secure variables', function (hooks) { assert.expect(4); // Arrange - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; @@ -715,7 +754,7 @@ module('Acceptance | secure variables', function (hooks) { }); test('does not show namespace filtering if the user only has access to one namespace', async function (assert) { - defaultScenario(server); + allScenarios.variableTestCluster(server); server.createList('variable', 3); const variablesToken = server.db.tokens.find(SECURE_TOKEN_ID); window.localStorage.nomadTokenSecret = variablesToken.secretId; diff --git a/ui/tests/integration/components/variable-paths-test.js b/ui/tests/integration/components/variable-paths-test.js index aaf2a5d65..fedfbf0df 100644 --- a/ui/tests/integration/components/variable-paths-test.js +++ b/ui/tests/integration/components/variable-paths-test.js @@ -1,9 +1,11 @@ +/* eslint-disable ember/avoid-leaking-state-in-ember-objects */ 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'; import pathTree from 'nomad-ui/utils/path-tree'; +import Service from '@ember/service'; const PATHSTRINGS = [ { path: '/foo/bar/baz' }, @@ -69,6 +71,36 @@ module('Integration | Component | variable-paths', function (hooks) { }); test('it allows for traversal: Files', async function (assert) { + // Arrange Test Set-up + const mockToken = Service.extend({ + selfTokenPolicies: [ + [ + { + rulesJSON: { + Namespaces: [ + { + Name: '*', + Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'], + SecureVariables: { + Paths: [ + { + Capabilities: ['list', 'read'], + PathSpec: '*', + }, + ], + }, + }, + ], + }, + }, + ], + ], + }); + + this.owner.register('service:token', mockToken); + + // End Test Set-up + assert.expect(5); this.set('tree', tree.findPath('foo/bar')); diff --git a/ui/tests/unit/abilities/variable-test.js b/ui/tests/unit/abilities/variable-test.js index 89335b810..3434c00b6 100644 --- a/ui/tests/unit/abilities/variable-test.js +++ b/ui/tests/unit/abilities/variable-test.js @@ -406,6 +406,101 @@ module('Unit | Ability | variable', function (hooks) { }); }); + module('#read', function () { + test('it does not permit reading variables by default', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + }); + + this.owner.register('service:token', mockToken); + + assert.notOk(this.ability.canRead); + }); + + test('it permits reading 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.canRead); + }); + + test('it permits reading variables when acl is disabled', function (assert) { + const mockToken = Service.extend({ + aclEnabled: false, + selfToken: { type: 'client' }, + }); + + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canRead); + }); + + test('it permits reading variables when token has SecureVariables with read capabilities in its rules', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'default', + Capabilities: [], + SecureVariables: { + Paths: [{ Capabilities: ['read'], PathSpec: '*' }], + }, + }, + ], + }, + }, + ], + }); + + this.owner.register('service:token', mockToken); + + assert.ok(this.ability.canRead); + }); + + test('it handles namespace matching', function (assert) { + const mockToken = Service.extend({ + aclEnabled: true, + selfToken: { type: 'client' }, + selfTokenPolicies: [ + { + rulesJSON: { + Namespaces: [ + { + Name: 'default', + Capabilities: [], + SecureVariables: { + Paths: [{ Capabilities: ['list'], PathSpec: 'foo/bar' }], + }, + }, + { + Name: 'pablo', + Capabilities: [], + SecureVariables: { + Paths: [{ Capabilities: ['read'], PathSpec: 'foo/bar' }], + }, + }, + ], + }, + }, + ], + }); + + this.owner.register('service:token', mockToken); + this.ability.path = 'foo/bar'; + this.ability.namespace = 'pablo'; + + assert.ok(this.ability.canRead); + }); + }); + module('#_nearestMatchingPath', function () { test('returns capabilities for an exact path match', function (assert) { const mockToken = Service.extend({