UI/transit auto rotate interval (#13970)

* Add format-ttl helper

* Add autoRotateInterval to model and serializer for transit key

* Add goSafeTimeString to object returned from TtlPicker2 component

* Add auto rotate interval to transit key components

* clean up unit calculator on ttl-picker, with tests

* Fix tests, cleanup

* Add changelog
This commit is contained in:
Chelsea Shaw 2022-02-09 10:56:49 -06:00 committed by GitHub
parent 26c993107d
commit b00d966054
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 213 additions and 37 deletions

3
changelog/13970.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Add support for time-based key autorotation in transit secrets engine.
```

View File

@ -18,6 +18,7 @@ export default Component.extend(FocusOnInsertMixin, {
onDataChange() {},
onRefresh() {},
key: null,
autoRotateInvalid: false,
requestInFlight: or('key.isLoading', 'key.isReloading', 'key.isSaving'),
willDestroyElement() {
@ -92,6 +93,15 @@ export default Component.extend(FocusOnInsertMixin, {
set(this.key, key, event.target.checked);
},
handleAutoRotateChange(ttlObj) {
if (ttlObj.enabled) {
set(this.key, 'autoRotateInterval', ttlObj.goSafeTimeString);
this.set('autoRotateInvalid', ttlObj.seconds < 3600);
} else {
set(this.key, 'autoRotateInterval', 0);
}
},
derivedChange(val) {
this.key.setDerived(val);
},

View File

@ -0,0 +1,22 @@
import { helper } from '@ember/component/helper';
export default helper(function formatTtl([timestring], { removeZero = false }) {
// Expects a number followed by one of s, m, or h
// eg. 40m or 1h20m0s
let matches = timestring?.match(/([0-9]+[h|m|s])/g);
if (!matches) {
return timestring;
}
return matches
.map((set) => {
// eslint-disable-next-line no-unused-vars
let [_, number, unit] = set.match(/([0-9]+)(h|m|s)/);
if (removeZero && number === '0') {
return null;
}
const word = { h: 'hour', m: 'minute', s: 'second' }[unit];
return `${number} ${number === '1' ? word : word + 's'}`;
})
.filter((s) => null !== s)
.join(' ');
});

View File

@ -56,6 +56,12 @@ export default Model.extend({
fieldValue: 'id',
readOnly: true,
}),
autoRotateInterval: attr({
defaultValue: '0',
defaultShown: 'Key is not automatically rotated',
editType: 'ttl',
label: 'Auto-rotation interval',
}),
deletionAllowed: attr('boolean'),
derived: attr('boolean'),
exportable: attr('boolean'),

View File

@ -50,10 +50,12 @@ export default RESTSerializer.extend({
const min_decryption_version = snapshot.attr('minDecryptionVersion');
const min_encryption_version = snapshot.attr('minEncryptionVersion');
const deletion_allowed = snapshot.attr('deletionAllowed');
const auto_rotate_interval = snapshot.attr('autoRotateInterval');
return {
min_decryption_version,
min_encryption_version,
deletion_allowed,
auto_rotate_interval,
};
} else {
snapshot.id = snapshot.attr('name');

View File

@ -25,6 +25,8 @@
<TransitFormCreate
@createOrUpdateKey={{action "createOrUpdateKey" this.mode}}
@setValueOnKey={{action "setValueOnKey" "exportable"}}
@autoRotateInvalid={{this.autoRotateInvalid}}
@handleAutoRotateChange={{action "handleAutoRotateChange"}}
@derivedChange={{action "derivedChange" value="target.checked"}}
@convergentEncryptionChange={{action "convergentEncryptionChange" value="target.checked"}}
@key={{this.key}}
@ -34,6 +36,8 @@
<TransitFormEdit
@createOrUpdateKey={{action "createOrUpdateKey" this.mode}}
@setValueOnKey={{action "setValueOnKey" "deletionAllowed"}}
@autoRotateInvalid={{this.autoRotateInvalid}}
@handleAutoRotateChange={{action "handleAutoRotateChange"}}
@deleteKey={{action "deleteKey"}}
@key={{this.key}}
@requestInFlight={{this.requestInFlight}}

View File

@ -6,6 +6,17 @@
<label for="key-name" class="is-label">Name</label>
<Input id="key-name" @value={{@key.name}} class="input" data-test-transit-key-name={{true}} />
</div>
<div class="field">
<TtlPicker2
@initialValue="1h"
@initialEnabled={{false}}
@label="Auto-rotation interval"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{@handleAutoRotateChange}}
@errorMessage={{if @autoRotateInvalid "Duration must be longer than 1 hour"}}
/>
</div>
<div class="field">
<label for="key-type" class="is-label">Type</label>
<div class="control is-expanded">

View File

@ -16,6 +16,17 @@
</label>
</div>
</div>
<div class="field">
<TtlPicker2
@initialValue={{or @key.autoRotateInterval "1h"}}
@initialEnabled={{not (eq @key.autoRotateInterval "0s")}}
@label="Auto-rotation interval"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{@handleAutoRotateChange}}
@errorMessage={{if @autoRotateInvalid "Duration must be longer than 1 hour"}}
/>
</div>
<div class="field">
<label for="key-min-decrypt-version" class="is-label">Minimum decryption version</label>
<div class="control is-expanded">

View File

@ -170,6 +170,10 @@
{{/if}}
{{else}}
<InfoTableRow @label="Type" @value={{@key.type}} />
<InfoTableRow
@label="Auto-rotation interval"
@value={{or (format-ttl @key.autoRotateInterval removeZero=true) "Key will not be automatically rotated"}}
/>
<InfoTableRow @label="Deletion allowed" @value={{stringify @key.deletionAllowed}} />
{{#if @key.derived}}

View File

@ -8,7 +8,7 @@
* ```js
* <TtlPicker2 @onChange={{handleChange}} @time={{defaultTime}} @unit={{defaultUnit}}/>
* ```
* @param onChange {Function} - This function will be passed a TTL object, which includes enabled{bool}, seconds{number}, timeString{string}.
* @param onChange {Function} - This function will be passed a TTL object, which includes enabled{bool}, seconds{number}, timeString{string}, goSafeTimeString{string}.
* @param label="Time to live (TTL)" {String} - Label is the main label that lives next to the toggle.
* @param helperTextDisabled="Allow tokens to be used indefinitely" {String} - This helper text is shown under the label when the toggle is switched off
* @param helperTextEnabled="Disable the use of the token after" {String} - This helper text is shown under the label when the toggle is switched on
@ -33,10 +33,14 @@ const secondsMap = {
h: 3600,
d: 86400,
};
const validUnits = ['s', 'm', 'h', 'd'];
const convertFromSeconds = (seconds, unit) => {
return seconds / secondsMap[unit];
};
const goSafeConvertFromSeconds = (seconds, unit) => {
// Go only accepts s, m, or h units
let u = unit === 'd' ? 'h' : unit;
return convertFromSeconds(seconds, u) + u;
};
export default TtlForm.extend({
layout,
@ -74,12 +78,18 @@ export default TtlForm.extend({
} else {
try {
const seconds = Duration.parse(value).seconds();
const lastDigit = value.toString().substring(value.length - 1);
if (validUnits.indexOf(lastDigit) >= 0 && lastDigit !== 's') {
time = convertFromSeconds(seconds, lastDigit);
unit = lastDigit;
} else {
time = seconds;
time = seconds;
// get largest unit with no remainder
if (seconds % secondsMap.d === 0) {
unit = 'd';
} else if (seconds % secondsMap.h === 0) {
unit = 'h';
} else if (seconds % secondsMap.m === 0) {
unit = 'm';
}
if (unit !== 's') {
time = convertFromSeconds(seconds, unit);
}
} catch (e) {
// if parsing fails leave as default 30s
@ -111,6 +121,7 @@ export default TtlForm.extend({
enabled: enableTTL,
seconds,
timeString: time + unit,
goSafeTimeString: goSafeConvertFromSeconds(seconds, unit),
};
this.onChange(ttl);
},

View File

@ -7,9 +7,11 @@ import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | ttl-picker2', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set('onChange', sinon.spy());
});
test('it renders time and unit inputs when TTL enabled', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@onChange={{onChange}}
@ -21,8 +23,6 @@ module('Integration | Component | ttl-picker2', function (hooks) {
});
test('it does not show time and unit inputs when TTL disabled', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@onChange={{onChange}}
@ -34,8 +34,6 @@ module('Integration | Component | ttl-picker2', function (hooks) {
});
test('it passes the appropriate data to onChange when toggled on', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="clicktest"
@ -46,20 +44,19 @@ module('Integration | Component | ttl-picker2', function (hooks) {
/>
`);
await click('[data-test-toggle-input="clicktest"]');
assert.ok(changeSpy.calledOnce, 'it calls the passed onChange');
assert.ok(this.onChange.calledOnce, 'it calls the passed onChange');
assert.ok(
changeSpy.calledWith({
this.onChange.calledWith({
enabled: true,
seconds: 600,
timeString: '10m',
goSafeTimeString: '10m',
}),
'Passes the default values back to onChange'
);
});
test('it keeps seconds value when unit is changed', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="clicktest"
@ -70,21 +67,23 @@ module('Integration | Component | ttl-picker2', function (hooks) {
/>
`);
await click('[data-test-toggle-input="clicktest"]');
assert.ok(changeSpy.calledOnce, 'it calls the passed onChange');
assert.ok(this.onChange.calledOnce, 'it calls the passed onChange');
assert.ok(
changeSpy.calledWith({
this.onChange.calledWith({
enabled: true,
seconds: 360,
timeString: '360s',
goSafeTimeString: '360s',
}),
'Changes enabled to true on click'
);
await fillIn('[data-test-select="ttl-unit"]', 'm');
assert.ok(
changeSpy.calledWith({
this.onChange.calledWith({
enabled: true,
seconds: 360,
timeString: '6m',
goSafeTimeString: '6m',
}),
'Units and time update without changing seconds value'
);
@ -93,8 +92,6 @@ module('Integration | Component | ttl-picker2', function (hooks) {
});
test('it recalculates seconds when unit is changed and recalculateSeconds is on', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="clicktest"
@ -107,18 +104,17 @@ module('Integration | Component | ttl-picker2', function (hooks) {
`);
await fillIn('[data-test-select="ttl-unit"]', 'm');
assert.ok(
changeSpy.calledWith({
this.onChange.calledWith({
enabled: true,
seconds: 7200,
timeString: '120m',
goSafeTimeString: '120m',
}),
'Seconds value is recalculated based on time and unit'
);
});
test('it sets default value to time and unit passed', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@onChange={{onChange}}
@ -130,12 +126,10 @@ module('Integration | Component | ttl-picker2', function (hooks) {
`);
assert.dom('[data-test-ttl-value]').hasValue('2', 'time value is 2');
assert.dom('[data-test-select="ttl-unit"]').hasValue('h', 'unit is hours');
assert.ok(changeSpy.notCalled, 'it does not call onChange after render when changeOnInit is not set');
assert.ok(this.onChange.notCalled, 'it does not call onChange after render when changeOnInit is not set');
});
test('it is disabled on init if initialEnabled is false', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="inittest"
@ -152,8 +146,6 @@ module('Integration | Component | ttl-picker2', function (hooks) {
});
test('it is enabled on init if initialEnabled is true', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="inittest"
@ -170,8 +162,6 @@ module('Integration | Component | ttl-picker2', function (hooks) {
});
test('it is enabled on init if initialEnabled evals to truthy', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="inittest"
@ -186,8 +176,29 @@ module('Integration | Component | ttl-picker2', function (hooks) {
});
test('it calls onChange on init when rendered if changeOnInit is true', async function (assert) {
let changeSpy = sinon.spy();
this.set('onChange', changeSpy);
await render(hbs`
<TtlPicker2
@label="clicktest"
@unit="d"
@time="2"
@onChange={{onChange}}
@enableTTL={{false}}
/>
`);
await click('[data-test-toggle-input="clicktest"]');
assert.ok(this.onChange.calledOnce, 'it calls the passed onChange');
assert.ok(
this.onChange.calledWith({
enabled: true,
seconds: 172800,
timeString: '2d',
goSafeTimeString: '48h',
}),
'Converts day unit to go safe time'
);
});
test('it calls onChange on init when rendered if changeOnInit is true', async function (assert) {
await render(hbs`
<TtlPicker2
@label="changeOnInitTest"
@ -198,13 +209,27 @@ module('Integration | Component | ttl-picker2', function (hooks) {
/>
`);
assert.ok(
changeSpy.calledWith({
this.onChange.calledWith({
enabled: true,
seconds: 6000,
timeString: '100m',
goSafeTimeString: '100m',
}),
'Seconds value is recalculated based on time and unit'
);
assert.ok(changeSpy.calledOnce, 'it calls the passed onChange after render');
assert.ok(this.onChange.calledOnce, 'it calls the passed onChange after render');
});
test('it converts to the largest round unit on init', async function (assert) {
await render(hbs`
<TtlPicker2
@label="convertunits"
@onChange={{onChange}}
@initialValue="60000s"
@initialEnabled="true"
/>
`);
assert.dom('[data-test-ttl-value]').hasValue('1000', 'time value is converted');
assert.dom('[data-test-select="ttl-unit"]').hasValue('m', 'unit value is m (minutes)');
});
});

View File

@ -0,0 +1,67 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | format-ttl', function (hooks) {
setupRenderingTest(hooks);
test('it renders the input if no match found', async function (assert) {
this.set('inputValue', '1234');
await render(hbs`{{format-ttl inputValue}}`);
assert.equal(this.element.textContent.trim(), '1234');
});
test('it parses hours correctly', async function (assert) {
this.set('inputValue', '12h');
await render(hbs`{{format-ttl inputValue}}`);
assert.equal(this.element.textContent.trim(), '12 hours');
this.set('inputValue', '1h');
assert.equal(this.element.textContent.trim(), '1 hour');
});
test('it parses minutes correctly', async function (assert) {
this.set('inputValue', '30m');
await render(hbs`{{format-ttl inputValue}}`);
assert.equal(this.element.textContent.trim(), '30 minutes');
this.set('inputValue', '1m');
assert.equal(this.element.textContent.trim(), '1 minute');
});
test('it parses seconds correctly', async function (assert) {
this.set('inputValue', '45s');
await render(hbs`{{format-ttl inputValue}}`);
assert.equal(this.element.textContent.trim(), '45 seconds');
this.set('inputValue', '1s');
assert.equal(this.element.textContent.trim(), '1 second');
});
test('it parses multiple matches correctly', async function (assert) {
this.set('inputValue', '1h30m0s');
await render(hbs`{{format-ttl inputValue}}`);
assert.equal(this.element.textContent.trim(), '1 hour 30 minutes 0 seconds');
});
test('it removes 0 values if removeZero true', async function (assert) {
this.set('inputValue', '1h30m0s');
await render(hbs`{{format-ttl inputValue removeZero=true}}`);
assert.equal(this.element.textContent.trim(), '1 hour 30 minutes');
});
test('returns empty string if all values 0 and removeZero true', async function (assert) {
this.set('inputValue', '0h0m0s');
await render(hbs`{{format-ttl inputValue removeZero=true}}`);
assert.equal(this.element.textContent.trim(), '');
});
});