UI - ent init (#5428)

* allow for enterprise init attributes

* allow moving from init to auth in the init flow on the tutorial machine

* show loading spinner while cluster is unsealing

* use seal-status type to determine the init attrs

* add init acceptance tests

* stored_shares should always be 1

* fix lint

* format template

* remove explicity model attr from init controller
This commit is contained in:
Matthew Irish 2018-09-28 09:36:18 -05:00 committed by GitHub
parent f7bf4a4384
commit a22861cee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 258 additions and 50 deletions

View File

@ -40,15 +40,29 @@ export default Controller.extend(DEFAULTS, {
actions: {
initCluster(data) {
let isCloudSeal = !!this.model.sealType && this.model.sealType !== 'shamir';
if (data.secret_shares) {
data.secret_shares = parseInt(data.secret_shares);
let shares = parseInt(data.secret_shares, 10);
data.secret_shares = shares;
if (isCloudSeal) {
data.stored_shares = 1;
data.recovery_shares = shares;
}
}
if (data.secret_threshold) {
data.secret_threshold = parseInt(data.secret_threshold);
let threshold = parseInt(data.secret_threshold, 10);
data.secret_threshold = threshold;
if (isCloudSeal) {
data.recovery_threshold = threshold;
}
}
if (!data.use_pgp) {
delete data.pgp_keys;
}
if (data.use_pgp && isCloudSeal) {
data.recovery_pgp_keys = data.pgp_keys;
}
if (!data.use_pgp_for_root) {
delete data.root_token_pgp_key;
}

View File

@ -34,7 +34,10 @@ export default {
onEntry: { type: 'render', level: 'feature', component: 'wizard/init-setup' },
},
save: {
on: { TOUNSEAL: 'unseal' },
on: {
TOUNSEAL: 'unseal',
TOLOGIN: 'login',
},
onEntry: { type: 'render', level: 'feature', component: 'wizard/init-save-keys' },
},
unseal: {

View File

@ -12,16 +12,13 @@ export default DS.Model.extend({
name: attr('string'),
status: attr('string'),
standby: attr('boolean'),
type: attr('string'),
needsInit: computed('nodes', 'nodes.[]', function() {
// needs init if no nodes are initialized
return this.get('nodes').isEvery('initialized', false);
}),
type: computed(function() {
return this.constructor.modelName;
}),
unsealed: computed('nodes', 'nodes.{[],@each.sealed}', function() {
// unsealed if there's at least one unsealed node
return !!this.get('nodes').findBy('sealed', false);
@ -40,6 +37,7 @@ export default DS.Model.extend({
sealThreshold: alias('leaderNode.sealThreshold'),
sealProgress: alias('leaderNode.progress'),
sealType: alias('leaderNode.type'),
hasProgress: gte('sealProgress', 1),
//replication mode - will only ever be 'unsupported'

View File

@ -1,4 +1,3 @@
import { computed } from '@ember/object';
import { alias, and, equal } from '@ember/object/computed';
import DS from 'ember-data';
const { attr } = DS;
@ -24,13 +23,10 @@ export default DS.Model.extend({
sealThreshold: alias('t'),
sealNumShares: alias('n'),
version: attr('string'),
type: attr('string'),
//https://www.vaultproject.io/docs/http/sys-leader.html
haEnabled: attr('boolean'),
isSelf: attr('boolean'),
leaderAddress: attr('string'),
type: computed(function() {
return this.constructor.modelName;
}),
});

View File

@ -1,24 +1,43 @@
<SplashPage as |Page|>
{{#if keyData}}
<Page.header>
<h1 class="title is-4">
Vault has been initialized! {{#if (eq keyData.keys.length 1)}}
Here is your key.
{{else}}
Here are your {{pluralize keyData.keys.length "key"}}.
{{/if}}
</h1>
{{#let (or keyData.recovery_keys keyData.keys) as |keyArray|}}
<h1 class="title is-4">
Vault has been initialized!
{{#if (eq keyArray.length 1)}}
Here is your key.
{{else}}
Here are your {{pluralize keyArray.length "key"}}.
{{/if}}
</h1>
{{/let}}
</Page.header>
<Page.content>
<div class="box is-marginless is-shadowless">
<div class="content">
<p>
Please securely distribute the keys below. When the Vault is re-sealed, restarted, or stopped, you must provide at least <strong class="has-text-danger">{{secret_threshold}}</strong> of these keys to unseal it again.
Vault does not store the master key. Without at least <strong class="has-text-danger">{{secret_threshold}}</strong> keys, your Vault will remain permanently sealed.
{{#if keyData.recovery_keys}}
Please securely distribute the keys below. Certain privileged operations in Vault such as rekeying the
barrier or generating a new root token will require you to provide
at least <strong class="has-text-danger">{{secret_threshold}}</strong> of these keys to perform the
operation.
{{else}}
Please securely distribute the keys below. When the Vault is re-sealed, restarted, or stopped, you must
provide at least <strong class="has-text-danger">{{secret_threshold}}</strong> of these keys to unseal it
again.
Vault does not store the master key. Without at least <strong class="has-text-danger">{{secret_threshold}}</strong>
keys, your Vault will remain permanently sealed.
{{/if}}
</p>
</div>
<div class="message is-list is-highlight has-copy-button" tabindex="-1">
<HoverCopyButton @alwaysShow=true @copyValue={{keyData.root_token}} />
<div
class="message is-list is-highlight has-copy-button"
tabindex="-1"
>
<HoverCopyButton
@alwaysShow=true
@copyValue={{keyData.root_token}}
/>
<div class="message-body">
<h4 class="title is-7 is-marginless">
Initial Root Token
@ -26,8 +45,12 @@
<code class="is-word-break">{{keyData.root_token}}</code>
</div>
</div>
{{#each (if keyData.keys_base64 keyData.keys_base64 keyData.keys) as |key index| }}
<div class="message is-list has-copy-button" tabindex="-1">
{{#each (or keyData.recovery_keys_base64 keyData.recovery_keys keyData.keys_base64 keyData.keys) as |key index|}}
<div
data-test-key-box
class="message is-list has-copy-button"
tabindex="-1"
>
<HoverCopyButton @copyValue={{key}} />
<div class="message-body">
<h4 class="title is-7 is-marginless">
@ -40,16 +63,26 @@
</div>
<div class="box is-marginless is-shadowless">
<div class="field is-grouped-split">
{{#if model.sealed}}
<div class="control">
{{#if (and model.sealed (not keyData.recovery_keys))}}
<div
data-test-advance-button
class="control"
>
{{#link-to 'vault.cluster.unseal' model.name class="button is-primary"}}
Continue to Unseal
Continue to Unseal
{{/link-to}}
</div>
{{else}}
<div class="control">
{{#link-to 'vault.cluster.auth' model.name class="button is-primary"}}
Continue to Authenticate
<div
data-test-advance-button
class="control"
>
{{#link-to 'vault.cluster.auth'
model.name
class=(concat (if model.sealed 'is-loading ' '') 'button is-primary')
disabled=model.sealed
}}
Continue to Authenticate
{{/link-to}}
</div>
{{/if}}
@ -60,7 +93,7 @@
@extension="json"
@class="button is-ghost"
@stringify={{true}}
>
>
<ICon @glyph="download" @size=16 /> Download Keys
</DownloadButton>
</div>
@ -73,56 +106,84 @@
</h1>
</Page.header>
<Page.content>
<form {{action 'initCluster' (hash
secret_shares=secret_shares
secret_threshold=secret_threshold
pgp_keys=pgp_keys
use_pgp=use_pgp
use_pgp_for_root=use_pgp_for_root
root_token_pgp_key=root_token_pgp_key
<form
{{action 'initCluster' (hash
secret_shares=secret_shares
secret_threshold=secret_threshold
pgp_keys=pgp_keys
use_pgp=use_pgp
use_pgp_for_root=use_pgp_for_root
root_token_pgp_key=root_token_pgp_key
)
on="submit"
}}
id="init"
id="init"
>
<div class="box is-marginless is-shadowless">
<MessageError @errors={{errors}} />
<div class="field">
<label for="key-shares" class="is-label">
<label
for="key-shares"
class="is-label"
>
Key Shares
</label>
<div class="control">
{{input class="input" autocomplete="off" name="key-shares" type="number" step="1" min="1" pattern="[0-9]*" value=secret_shares}}
{{input
data-test-key-shares="true"
class="input"
autocomplete="off"
name="key-shares"
type="number"
step="1"
min="1"
pattern="[0-9]*"
value=secret_shares
}}
</div>
<p class="help has-text-grey">
The number of key shares to split the master key into
</p>
</div>
<div class="field">
<label for="key-threshold" class="is-label">
<label
for="key-threshold"
class="is-label"
>
Key Threshold
</label>
<div class="control">
{{input class="input" autocomplete="off" name="key-threshold" type="number" step="1" min="1" pattern="[0-9]*" value=secret_threshold}}
{{input
data-test-key-threshold="true"
class="input" autocomplete="off"
name="key-threshold"
type="number"
step="1"
min="1"
pattern="[0-9]*"
value=secret_threshold
}}
</div>
<p class="help has-text-grey">
The number of key shares required to reconstruct the master key
</p>
</div>
<ToggleButton
@openLabel="Encrypt Output with PGP"
@closedLabel="Encrypt Output with PGP"
@toggleTarget={{this}}
@toggleAttr="use_pgp"
@class="is-block"
/>
/>
{{#if use_pgp}}
<div class="box init-box">
<p class="help has-text-grey">
The output unseal keys will be encrypted and hex-encoded, in order, with the given public keys.
</p>
<PgpList @listLength={{secret_shares}} @onDataUpdate={{action 'setKeys'}} />
<PgpList
@listLength={{secret_shares}}
@onDataUpdate={{action 'setKeys'}}
/>
</div>
{{/if}}
<ToggleButton
@ -137,19 +198,22 @@
<p class="help has-text-grey">
The root unseal key will be encrypted and hex-encoded with the given public key.
</p>
<PgpList @listLength=1 @onDataUpdate={{action 'setRootKey'}} />
<PgpList
@listLength=1
@onDataUpdate={{action 'setRootKey'}}
/>
</div>
{{/if}}
</div>
<div class="box is-marginless is-shadowless">
<button
data-test-init-submit
type="submit"
class="button is-primary {{if loading 'is-loading'}}"
disabled={{loading}}
>
Initialize
</button>
<div class="init-illustration">
{{partial "svg/initialize"}}
</div>
@ -157,4 +221,4 @@
</form>
</Page.content>
{{/if}}
</SplashPage>
</SplashPage>

View File

@ -0,0 +1,117 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import initPage from 'vault/tests/pages/init';
import Pretender from 'pretender';
const HEALTH_RESPONSE = {
initialized: false,
sealed: true,
standby: true,
performance_standby: false,
replication_performance_mode: 'unknown',
replication_dr_mode: 'unknown',
server_time_utc: 1538066726,
version: '0.11.0+prem',
};
const CLOUD_SEAL_RESPONSE = {
keys: [],
keys_base64: [],
recovery_keys: [
'1659986a8d56b998b175b6e259998f3c064c061d256c2a331681b8d122fedf0db4',
'4d34c58f56e4f077e3b74f9e8db2850fc251ac3f16e952441301eedc462addeb84',
'3b3cbdf4b2f5ac1e809ff1bb72fd9778e460856561728a871a9370345bd52e97f4',
'aa99b46e2ed5d837ee9824b7894b24987be2f32c81ab9ff5ce9e07d2012eaf4158',
'c2bf6d71d8db8ae09b26177ed393ecb274740fe9ab51884eaa00ac113a74c08ba7',
],
recovery_keys_base64: [
'FlmYao1WuZixdbbiWZmPPAZMBh0lbCozFoG40SL+3w20',
'TTTFj1bk8Hfjt0+ejbKFD8JRrD8W6VJEEwHu3EYq3euE',
'Ozy99LL1rB6An/G7cv2XeORghWVhcoqHGpNwNFvVLpf0',
'qpm0bi7V2DfumCS3iUskmHvi8yyBq5/1zp4H0gEur0FY',
'wr9tcdjbiuCbJhd+05PssnR0D+mrUYhOqgCsETp0wIun',
],
root_token: '48dF3Drr1jl4ayM0jcHrN4NC',
};
const SEAL_RESPONSE = {
keys: [
'1659986a8d56b998b175b6e259998f3c064c061d256c2a331681b8d122fedf0db4',
'4d34c58f56e4f077e3b74f9e8db2850fc251ac3f16e952441301eedc462addeb84',
'3b3cbdf4b2f5ac1e809ff1bb72fd9778e460856561728a871a9370345bd52e97f4',
],
keys_base64: [
'FlmYao1WuZixdbbiWZmPPAZMBh0lbCozFoG40SL+3w20',
'TTTFj1bk8Hfjt0+ejbKFD8JRrD8W6VJEEwHu3EYq3euE',
'Ozy99LL1rB6An/G7cv2XeORghWVhcoqHGpNwNFvVLpf0',
],
root_token: '48dF3Drr1jl4ayM0jcHrN4NC',
};
const CLOUD_SEAL_STATUS_RESPONSE = {
type: 'awskms',
sealed: true,
initialized: false,
};
const SEAL_STATUS_RESPONSE = {
type: 'shamir',
sealed: true,
initialized: false,
};
module('Acceptance | init', function(hooks) {
setupApplicationTest(hooks);
let setInitResponse = (server, resp) => {
server.put('/v1/sys/init', () => {
return [200, { 'Content-Type': 'application/json' }, JSON.stringify(resp)];
});
};
let setStatusResponse = (server, resp) => {
server.get('/v1/sys/seal-status', () => {
return [200, { 'Content-Type': 'application/json' }, JSON.stringify(resp)];
});
};
hooks.beforeEach(function() {
this.server = new Pretender();
this.server.get('/v1/sys/health', () => {
return [200, { 'Content-Type': 'application/json' }, JSON.stringify(HEALTH_RESPONSE)];
});
});
hooks.afterEach(function() {
this.server.shutdown();
});
test('cloud seal init', async function(assert) {
setInitResponse(this.server, CLOUD_SEAL_RESPONSE);
setStatusResponse(this.server, CLOUD_SEAL_STATUS_RESPONSE);
await initPage.init(5, 3);
assert.equal(
initPage.keys.length,
CLOUD_SEAL_RESPONSE.recovery_keys.length,
'shows all of the recovery keys'
);
assert.equal(initPage.buttonText, 'Continue to Authenticate', 'links to authenticate');
let { requestBody } = this.server.handledRequests.findBy('url', '/v1/sys/init');
requestBody = JSON.parse(requestBody);
for (let attr of ['recovery_shares', 'recovery_threshold']) {
assert.ok(requestBody[attr], `requestBody includes cloud seal specific attribute: ${attr}`);
}
});
test('shamir seal init', async function(assert) {
setInitResponse(this.server, SEAL_RESPONSE);
setStatusResponse(this.server, SEAL_STATUS_RESPONSE);
await initPage.init(3, 2);
assert.equal(initPage.keys.length, SEAL_RESPONSE.keys.length, 'shows all of the recovery keys');
assert.equal(initPage.buttonText, 'Continue to Unseal', 'links to unseal');
let { requestBody } = this.server.handledRequests.findBy('url', '/v1/sys/init');
requestBody = JSON.parse(requestBody);
for (let attr of ['recovery_shares', 'recovery_threshold']) {
assert.notOk(requestBody[attr], `requestBody does not include cloud seal specific attribute: ${attr}`);
}
});
});

16
ui/tests/pages/init.js Normal file
View File

@ -0,0 +1,16 @@
import { text, create, collection, visitable, fillable, clickable } from 'ember-cli-page-object';
export default create({
visit: visitable('/vault/init'),
submit: clickable('[data-test-init-submit]'),
shares: fillable('[data-test-key-shares]'),
threshold: fillable('[data-test-key-threshold]'),
keys: collection('[data-test-key-box]'),
buttonText: text('[data-test-advance-button]'),
init: async function(shares, threshold) {
await this.visit();
return this.shares(shares)
.threshold(threshold)
.submit();
},
});