Skip to content

Commit

Permalink
UI/managed namespace changes (#10588)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
chelshaw committed Jan 7, 2021
1 parent 6d6f5f2 commit 54239ba
Show file tree
Hide file tree
Showing 17 changed files with 228 additions and 13 deletions.
3 changes: 3 additions & 0 deletions changelog/10588.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
ui: Adds check for feature flag on application, and updates namespace toolbar on login if present
```
21 changes: 21 additions & 0 deletions ui/app/controllers/vault/cluster/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions ui/app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default Route.extend({
routing: service('router'),
wizard: service(),
namespaceService: service('namespace'),
featureFlagService: service('featureFlag'),

actions: {
willTransition() {
Expand Down Expand Up @@ -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);
}
},
});
18 changes: 17 additions & 1 deletion ui/app/routes/vault/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'];
Expand All @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions ui/app/services/feature-flag.js
Original file line number Diff line number Diff line change
@@ -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;
},
});
1 change: 1 addition & 0 deletions ui/app/styles/components/auth-form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

.field-label {
margin-right: $spacing-s;
align-self: center;
}

.is-label {
Expand Down
36 changes: 34 additions & 2 deletions ui/app/templates/vault/cluster/auth.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
3 changes: 3 additions & 0 deletions ui/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions ui/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 7 additions & 0 deletions ui/mirage/factories/feature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Factory } from 'ember-cli-mirage';

export default Factory.extend({
feature_flags() {
return []; // VAULT_CLOUD_ADMIN_NAMESPACE
},
});
9 changes: 0 additions & 9 deletions ui/mirage/factories/user.js

This file was deleted.

5 changes: 5 additions & 0 deletions ui/mirage/models/feature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Model } from 'ember-cli-mirage';

export default Model.extend({
feature_flags: null,
});
1 change: 1 addition & 0 deletions ui/mirage/scenarios/default.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export default function(server) {
server.create('metrics/config');
server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] });
}
20 changes: 19 additions & 1 deletion ui/tests/acceptance/enterprise-namespaces-test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
);
});
});
1 change: 1 addition & 0 deletions ui/tests/acceptance/init-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
51 changes: 51 additions & 0 deletions ui/tests/acceptance/managed-namespace-test.js
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions ui/tests/unit/services/feature-flag-test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});

0 comments on commit 54239ba

Please sign in to comment.