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:
Chelsea Shaw 2021-01-07 14:18:36 -06:00 committed by GitHub
parent 6ed50b5df9
commit 70d3185d3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 228 additions and 13 deletions

3
changelog/10588.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Adds check for feature flag on application, and updates namespace toolbar on login if present
```

View File

@ -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

View File

@ -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);
}
},
});

View File

@ -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);

View 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;
},
});

View File

@ -28,6 +28,7 @@
.field-label {
margin-right: $spacing-s;
align-self: center;
}
.is-label {

View File

@ -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>

View File

@ -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;

View File

@ -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();
}

View File

@ -0,0 +1,7 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
feature_flags() {
return []; // VAULT_CLOUD_ADMIN_NAMESPACE
},
});

View File

@ -1,9 +0,0 @@
import Mirage from 'ember-cli-mirage';
export default Mirage.Factory.extend({
name(i) {
return `Person ${i}`;
},
age: 28,
admin: false,
});

View File

@ -0,0 +1,5 @@
import { Model } from 'ember-cli-mirage';
export default Model.extend({
feature_flags: null,
});

View File

@ -1,3 +1,4 @@
export default function(server) {
server.create('metrics/config');
server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] });
}

View File

@ -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'
);
});
});

View File

@ -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() {

View 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'
);
});
});

View 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'
);
});
});