mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-30 02:02:43 +00:00 
			
		
		
		
	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
This commit is contained in:
		
							
								
								
									
										3
									
								
								changelog/10588.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/10588.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ```release-note:feature | ||||
| ui: Adds check for feature flag on application, and updates namespace toolbar on login if present | ||||
| ``` | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
|   }, | ||||
| }); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
							
								
								
									
										19
									
								
								ui/app/services/feature-flag.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								ui/app/services/feature-flag.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   }, | ||||
| }); | ||||
| @@ -28,6 +28,7 @@ | ||||
|  | ||||
|   .field-label { | ||||
|     margin-right: $spacing-s; | ||||
|     align-self: center; | ||||
|   } | ||||
|  | ||||
|   .is-label { | ||||
|   | ||||
| @@ -4,10 +4,42 @@ | ||||
|       Sign in to Vault | ||||
|     </h1> | ||||
|   </Page.header> | ||||
|   {{#if (has-feature "Namespaces")}} | ||||
|   {{#if managedNamespaceRoot}} | ||||
|     <Page.sub-header> | ||||
|       <Toolbar> | ||||
|         <div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar> | ||||
|           <div class="field is-horizontal"> | ||||
|             <div class="field-label"> | ||||
|               <label class="is-label" for="namespace">Namespace</label> | ||||
|             </div> | ||||
|             <div class="field-label"> | ||||
|               <span class="has-text-grey" data-test-managed-namespace-root>/{{managedNamespaceRoot}}</span> | ||||
|             </div> | ||||
|             <div class="field-body"> | ||||
|               <div class="field"> | ||||
|                 <div class="control"> | ||||
|                   <input | ||||
|                     value={{managedNamespaceChild}} | ||||
|                     placeholder="/ (Default)" | ||||
|                     oninput={{perform updateManagedNamespace value="target.value"}} | ||||
|                     autocomplete="off" | ||||
|                     spellcheck="false" | ||||
|                     name="namespace" | ||||
|                     id="namespace" | ||||
|                     class="input" | ||||
|                     type="text" | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </Toolbar> | ||||
|     </Page.sub-header> | ||||
|   {{else if (has-feature "Namespaces")}} | ||||
|     <Page.sub-header> | ||||
|       <Toolbar class="toolbar-namespace-picker"> | ||||
|         <div class="field is-horizontal"> | ||||
|         <div class="field is-horizontal" data-test-namespace-toolbar> | ||||
|           <div class="field-label is-normal"> | ||||
|             <label class="is-label" for="namespace">Namespace</label> | ||||
|           </div> | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										7
									
								
								ui/mirage/factories/feature.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								ui/mirage/factories/feature.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { Factory } from 'ember-cli-mirage'; | ||||
|  | ||||
| export default Factory.extend({ | ||||
|   feature_flags() { | ||||
|     return []; // VAULT_CLOUD_ADMIN_NAMESPACE | ||||
|   }, | ||||
| }); | ||||
| @@ -1,9 +0,0 @@ | ||||
| import Mirage from 'ember-cli-mirage'; | ||||
|  | ||||
| export default Mirage.Factory.extend({ | ||||
|   name(i) { | ||||
|     return `Person ${i}`; | ||||
|   }, | ||||
|   age: 28, | ||||
|   admin: false, | ||||
| }); | ||||
							
								
								
									
										5
									
								
								ui/mirage/models/feature.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								ui/mirage/models/feature.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { Model } from 'ember-cli-mirage'; | ||||
|  | ||||
| export default Model.extend({ | ||||
|   feature_flags: null, | ||||
| }); | ||||
| @@ -1,3 +1,4 @@ | ||||
| export default function(server) { | ||||
|   server.create('metrics/config'); | ||||
|   server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] }); | ||||
| } | ||||
|   | ||||
| @@ -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' | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
							
								
								
									
										51
									
								
								ui/tests/acceptance/managed-namespace-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								ui/tests/acceptance/managed-namespace-test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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' | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										24
									
								
								ui/tests/unit/services/feature-flag-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								ui/tests/unit/services/feature-flag-test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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' | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user
	 Chelsea Shaw
					Chelsea Shaw