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:
parent
26c993107d
commit
b00d966054
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
ui: Add support for time-based key autorotation in transit secrets engine.
|
||||
```
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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(' ');
|
||||
});
|
|
@ -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'),
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(), '');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue