From 54239bac2668e9f9fae930ab7f8fcfde5fb2048a Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 7 Jan 2021 14:18:36 -0600 Subject: [PATCH] UI/managed namespace changes (#10588) * Redirect to url with namespace param if user logged into root namespace without permission * Feature flag service for managing flags * Redirect with namespace query param if no current namespace param AND managed root namespace set * Test coverage for managed namespace changes * Handle null body case on feature-flag response, add pretender route for feature-flags on shamir test --- changelog/10588.txt | 3 ++ ui/app/controllers/vault/cluster/auth.js | 21 ++++++++ ui/app/routes/application.js | 12 +++++ ui/app/routes/vault/cluster.js | 18 ++++++- ui/app/services/feature-flag.js | 19 +++++++ ui/app/styles/components/auth-form.scss | 1 + ui/app/templates/vault/cluster/auth.hbs | 36 ++++++++++++- ui/config/environment.js | 3 ++ ui/mirage/config.js | 10 ++++ ui/mirage/factories/feature.js | 7 +++ ui/mirage/factories/user.js | 9 ---- ui/mirage/models/feature.js | 5 ++ ui/mirage/scenarios/default.js | 1 + .../acceptance/enterprise-namespaces-test.js | 20 +++++++- ui/tests/acceptance/init-test.js | 1 + ui/tests/acceptance/managed-namespace-test.js | 51 +++++++++++++++++++ ui/tests/unit/services/feature-flag-test.js | 24 +++++++++ 17 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 changelog/10588.txt create mode 100644 ui/app/services/feature-flag.js create mode 100644 ui/mirage/factories/feature.js delete mode 100644 ui/mirage/factories/user.js create mode 100644 ui/mirage/models/feature.js create mode 100644 ui/tests/acceptance/managed-namespace-test.js create mode 100644 ui/tests/unit/services/feature-flag-test.js diff --git a/changelog/10588.txt b/changelog/10588.txt new file mode 100644 index 000000000000..0e363b4b3700 --- /dev/null +++ b/changelog/10588.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Adds check for feature flag on application, and updates namespace toolbar on login if present +``` diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 03907ac29cf9..d4478bae4cd4 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -7,11 +7,32 @@ export default Controller.extend({ vaultController: controller('vault'), clusterController: controller('vault.cluster'), namespaceService: service('namespace'), + featureFlagService: service('featureFlag'), namespaceQueryParam: alias('clusterController.namespaceQueryParam'), queryParams: [{ authMethod: 'with' }], wrappedToken: alias('vaultController.wrappedToken'), authMethod: '', redirectTo: alias('vaultController.redirectTo'), + managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'), + + get managedNamespaceChild() { + let fullParam = this.namespaceQueryParam; + let split = fullParam.split('/'); + if (split.length > 1) { + split.shift(); + return `/${split.join('/')}`; + } + return ''; + }, + + updateManagedNamespace: task(function*(value) { + // debounce + yield timeout(500); + // TODO: Move this to shared fn + const newNamespace = `${this.managedNamespaceRoot}${value}`; + this.namespaceService.setNamespace(newNamespace, true); + this.set('namespaceQueryParam', newNamespace); + }).restartable(), updateNamespace: task(function*(value) { // debounce diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 7fd0fb582020..7c36d1c5431d 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -8,6 +8,7 @@ export default Route.extend({ routing: service('router'), wizard: service(), namespaceService: service('namespace'), + featureFlagService: service('featureFlag'), actions: { willTransition() { @@ -81,4 +82,15 @@ export default Route.extend({ return true; }, }, + + async beforeModel() { + const result = await fetch('/v1/sys/internal/ui/feature-flags', { + method: 'GET', + }); + if (result.status === 200) { + const body = await result.json(); + const flags = body.data?.feature_flags || []; + this.featureFlagService.setFeatureFlags(flags); + } + }, }); diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js index 96db4d842f66..f489a9d3a2ae 100644 --- a/ui/app/routes/vault/cluster.js +++ b/ui/app/routes/vault/cluster.js @@ -4,6 +4,7 @@ import { reject } from 'rsvp'; import Route from '@ember/routing/route'; import { task, timeout } from 'ember-concurrency'; import Ember from 'ember'; +import getStorage from '../../lib/token-storage'; import ClusterRoute from 'vault/mixins/cluster-route'; import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; @@ -15,6 +16,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { permissions: service(), store: service(), auth: service(), + featureFlagService: service('featureFlag'), currentCluster: service(), modelTypes: computed(function() { return ['node', 'secret', 'secret-engine']; @@ -34,7 +36,21 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { async beforeModel() { const params = this.paramsFor(this.routeName); - this.namespaceService.setNamespace(params.namespaceQueryParam); + let namespace = params.namespaceQueryParam; + const currentTokenName = this.auth.get('currentTokenName'); + // if no namespace queryParam and user authenticated, + // use user's root namespace to redirect to properly param'd url + if (!namespace && currentTokenName && !Ember.testing) { + const storage = getStorage().getItem(currentTokenName); + namespace = storage.userRootNamespace; + // only redirect if something other than nothing + if (namespace) { + this.transitionTo({ queryParams: { namespace } }); + } + } else if (!namespace && !!this.featureFlagService.managedNamespaceRoot) { + this.transitionTo({ queryParams: { namespace: this.featureFlagService.managedNamespaceRoot } }); + } + this.namespaceService.setNamespace(namespace); const id = this.getClusterId(params); if (id) { this.auth.setCluster(id); diff --git a/ui/app/services/feature-flag.js b/ui/app/services/feature-flag.js new file mode 100644 index 000000000000..fb1c85a0ecbc --- /dev/null +++ b/ui/app/services/feature-flag.js @@ -0,0 +1,19 @@ +import Service from '@ember/service'; + +const FLAGS = { + vaultCloudNamespace: 'VAULT_CLOUD_ADMIN_NAMESPACE', +}; + +export default Service.extend({ + featureFlags: null, + setFeatureFlags(flags) { + this.set('featureFlags', flags); + }, + + get managedNamespaceRoot() { + if (this.featureFlags && this.featureFlags.includes(FLAGS.vaultCloudNamespace)) { + return 'admin'; + } + return null; + }, +}); diff --git a/ui/app/styles/components/auth-form.scss b/ui/app/styles/components/auth-form.scss index cd7752493d7c..5bba1043107f 100644 --- a/ui/app/styles/components/auth-form.scss +++ b/ui/app/styles/components/auth-form.scss @@ -28,6 +28,7 @@ .field-label { margin-right: $spacing-s; + align-self: center; } .is-label { diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index 1cb533ee0a04..648658e99e6f 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -4,10 +4,42 @@ Sign in to Vault - {{#if (has-feature "Namespaces")}} + {{#if managedNamespaceRoot}} + + +
+
+
+ +
+
+ /{{managedNamespaceRoot}} +
+
+
+
+ +
+
+
+
+
+
+
+ {{else if (has-feature "Namespaces")}} -
+
diff --git a/ui/config/environment.js b/ui/config/environment.js index 401413bc767f..4896b1ad8f77 100644 --- a/ui/config/environment.js +++ b/ui/config/environment.js @@ -56,6 +56,9 @@ module.exports = function(environment) { ENV.APP.rootElement = '#ember-testing'; ENV.APP.autoboot = false; ENV.flashMessageDefaults.timeout = 50; + ENV['ember-cli-mirage'] = { + enabled: false, + }; } if (environment !== 'production') { ENV.APP.DEFAULT_PAGE_SIZE = 15; diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 4c145a75db50..5fb7e4a01e4d 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -19,5 +19,15 @@ export default function() { data: db['metrics/configs'].first(), }; }); + + this.get('/sys/internal/ui/feature-flags', db => { + const featuresResponse = db.features.first(); + return { + data: { + feature_flags: featuresResponse ? featuresResponse.feature_flags : null, + }, + }; + }); + this.passthrough(); } diff --git a/ui/mirage/factories/feature.js b/ui/mirage/factories/feature.js new file mode 100644 index 000000000000..832aee5ed152 --- /dev/null +++ b/ui/mirage/factories/feature.js @@ -0,0 +1,7 @@ +import { Factory } from 'ember-cli-mirage'; + +export default Factory.extend({ + feature_flags() { + return []; // VAULT_CLOUD_ADMIN_NAMESPACE + }, +}); diff --git a/ui/mirage/factories/user.js b/ui/mirage/factories/user.js deleted file mode 100644 index fa39d34cc7be..000000000000 --- a/ui/mirage/factories/user.js +++ /dev/null @@ -1,9 +0,0 @@ -import Mirage from 'ember-cli-mirage'; - -export default Mirage.Factory.extend({ - name(i) { - return `Person ${i}`; - }, - age: 28, - admin: false, -}); diff --git a/ui/mirage/models/feature.js b/ui/mirage/models/feature.js new file mode 100644 index 000000000000..4f84f3bd71dc --- /dev/null +++ b/ui/mirage/models/feature.js @@ -0,0 +1,5 @@ +import { Model } from 'ember-cli-mirage'; + +export default Model.extend({ + feature_flags: null, +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 216d7fcb2ff2..5e65448276cf 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -1,3 +1,4 @@ export default function(server) { server.create('metrics/config'); + server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] }); } diff --git a/ui/tests/acceptance/enterprise-namespaces-test.js b/ui/tests/acceptance/enterprise-namespaces-test.js index 6188fcf48271..5df7780bc58e 100644 --- a/ui/tests/acceptance/enterprise-namespaces-test.js +++ b/ui/tests/acceptance/enterprise-namespaces-test.js @@ -1,4 +1,4 @@ -import { click, settled, visit } from '@ember/test-helpers'; +import { click, settled, visit, fillIn, currentURL } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { create } from 'ember-cli-page-object'; @@ -71,4 +71,22 @@ module('Acceptance | Enterprise | namespaces', function(hooks) { .dom('[data-test-namespace-link="beep/boop/bop"]') .exists('renders the link to the nested namespace'); }); + + test('it shows the regular namespace toolbar when not managed', async function(assert) { + // This test is the opposite of the test in managed-namespace-test + await logout.visit(); + assert.equal(currentURL(), '/vault/auth?with=token', 'Does not redirect'); + assert.dom('[data-test-namespace-toolbar]').exists('Normal namespace toolbar exists'); + assert + .dom('[data-test-managed-namespace-toolbar]') + .doesNotExist('Managed namespace toolbar does not exist'); + assert.dom('input#namespace').hasAttribute('placeholder', '/ (Root)'); + await fillIn('input#namespace', '/foo'); + let encodedNamespace = encodeURIComponent('/foo'); + assert.equal( + currentURL(), + `/vault/auth?namespace=${encodedNamespace}&with=token`, + 'Does not prepend root to namespace' + ); + }); }); diff --git a/ui/tests/acceptance/init-test.js b/ui/tests/acceptance/init-test.js index bb2fdc543c71..8c257906f3ba 100644 --- a/ui/tests/acceptance/init-test.js +++ b/ui/tests/acceptance/init-test.js @@ -77,6 +77,7 @@ module('Acceptance | init', function(hooks) { this.server.get('/v1/sys/health', () => { return [200, { 'Content-Type': 'application/json' }, JSON.stringify(HEALTH_RESPONSE)]; }); + this.server.get('/v1/sys/internal/ui/feature-flags', this.server.passthrough); }); hooks.afterEach(function() { diff --git a/ui/tests/acceptance/managed-namespace-test.js b/ui/tests/acceptance/managed-namespace-test.js new file mode 100644 index 000000000000..0437c8dc8a0c --- /dev/null +++ b/ui/tests/acceptance/managed-namespace-test.js @@ -0,0 +1,51 @@ +import { module, test } from 'qunit'; +import { currentURL, visit, fillIn } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import Pretender from 'pretender'; + +const FEATURE_FLAGS_RESPONSE = { + data: { + feature_flags: ['VAULT_CLOUD_ADMIN_NAMESPACE'], + }, +}; + +module('Acceptance | Enterprise | Managed namespace root', function(hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(function() { + /** + * Since the features are fetched on the application load, + * we have to populate them on the beforeEach hook because + * the fetch won't trigger again within the tests + */ + this.server = new Pretender(function() { + this.get('/v1/sys/internal/ui/feature-flags', () => { + return [200, { 'Content-Type': 'application/json' }, JSON.stringify(FEATURE_FLAGS_RESPONSE)]; + }); + this.get('/v1/sys/health', this.passthrough); + this.get('/v1/sys/seal-status', this.passthrough); + this.get('/v1/sys/license/features', this.passthrough); + }); + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + test('it shows the managed namespace toolbar when feature flag exists', async function(assert) { + await visit('/vault/auth'); + assert.equal(currentURL(), '/vault/auth?namespace=admin&with=token', 'Redirected to base namespace'); + + assert.dom('[data-test-namespace-toolbar]').doesNotExist('Normal namespace toolbar does not exist'); + assert.dom('[data-test-managed-namespace-toolbar]').exists('Managed namespace toolbar exists'); + assert.dom('[data-test-managed-namespace-root]').hasText('/admin', 'Shows /admin namespace prefix'); + assert.dom('input#namespace').hasAttribute('placeholder', '/ (Default)'); + await fillIn('input#namespace', '/foo'); + let encodedNamespace = encodeURIComponent('admin/foo'); + assert.equal( + currentURL(), + `/vault/auth?namespace=${encodedNamespace}&with=token`, + 'Correctly prepends root to namespace' + ); + }); +}); diff --git a/ui/tests/unit/services/feature-flag-test.js b/ui/tests/unit/services/feature-flag-test.js new file mode 100644 index 000000000000..d9da255774e2 --- /dev/null +++ b/ui/tests/unit/services/feature-flag-test.js @@ -0,0 +1,24 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | feature-flag', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let service = this.owner.lookup('service:feature-flag'); + assert.ok(service); + }); + + test('it returns the namespace root when flag is present', function(assert) { + let service = this.owner.lookup('service:feature-flag'); + assert.equal(service.managedNamespaceRoot, null, 'Managed namespace root is null by default'); + service.setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']); + assert.equal(service.managedNamespaceRoot, 'admin', 'Managed namespace is admin when flag present'); + service.setFeatureFlags(['SOMETHING_ELSE']); + assert.equal( + service.managedNamespaceRoot, + null, + 'Flags were overwritten and root namespace is null again' + ); + }); +});