feat: 🎸 create storage policy (#2079)

* feat: 🎸 add storage policy
This commit is contained in:
Dharini Jeeva authored and GitHub committed 2024-01-12 11:07:18 -08:00
1 parent 8ad4e93ff7
commit 1cb3b708a2
17 files changed
+741 -16

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ export default class ObjectTransform extends Transform {
*/
serialize(deserialized) {
if (!deserialized) {
return null;
return {};
}
return deserialized;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
modelName: 'policy',
});
+8 -6
View File
@@ -24,12 +24,14 @@ export default class extends Helper {
*/
compute([days]) {
const remainder = days % 365;
if (remainder === 0) {
const numberOfYears = days / 365;
return this.intl.t('titles.year', { numberOfYears });
} else {
return this.intl.t('titles.days', { days });
if (days > 0) {
const remainder = days % 365;
if (remainder === 0) {
const numberOfYears = days / 365;
return this.intl.t('titles.year', { numberOfYears });
} else {
return this.intl.t('titles.days', { days });
}
}
}
}
@@ -925,6 +925,7 @@ policy:
delete_after: Delete After
do_not_delete: Do not delete
forever: Forever
days: days
actions:
delete: Delete Storage Policy
messages:
@@ -932,3 +933,24 @@ policy:
title: No storage policies yet
description: Storage policies define how long to store recordings before they are automatically deleted. We recommend checking your data retention policies for compliance requirements.
link: Create a new storage policy
form:
retention_policy:
label: Retention Policy
help: Specifies how long a recording must be stored.
deletion_policy:
label: Deletion Policy
help: Specifies when to delete a recording
overridable:
label: Allow orgs to override
retention_policy_options:
forever:
label: Forever
custom:
label: Custom
do_not_protect:
label: Do not protect, allow deletion any time
deletion_policy_options:
do_not_delete:
label: Do not delete
custom:
label: Custom
@@ -0,0 +1,81 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
<Hds::Form::TextInput::Field
@isOptional={{true}}
@value={{@model.name}}
@isInvalid={{@model.errors.name}}
@type='text'
name='name'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'name')}}
as |F|
>
<F.Label>{{t 'form.name.label'}}</F.Label>
<F.HelperText>{{t 'form.name.help'}}</F.HelperText>
{{#if @model.errors.name}}
<F.Error as |E|>
{{#each @model.errors.name as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::Textarea::Field
@isOptional={{true}}
@value={{@model.description}}
@isInvalid={{@model.errors.description}}
name='description'
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'form.description.label'}}</F.Label>
<F.HelperText>{{t 'form.description.help'}}</F.HelperText>
{{#if @model.errors.description}}
<F.Error as |E|>
{{#each @model.errors.description as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Textarea::Field>
{{! retention policy }}
<Form::Policy::PolicySelection
@name='retention_policy'
@disabled={{form.disabled}}
@model={{@model}}
@options={{this.listRententionOptions}}
@customInputName='retain_for'
@inputValue={{@model.retain_for.days}}
@selectedOption={{this.selectRetentionPolicyType}}
/>
{{! deletion policy }}
<Form::Policy::PolicySelection
@name='deletion_policy'
@disabled={{form.disabled}}
@model={{@model}}
@options={{this.listDeletionOptions}}
@customInputName='delete_after'
@inputValue={{@model.delete_after.days}}
@selectedOption={{this.selectDeletePolicyType}}
/>
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -0,0 +1,63 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
const RETENTION_POLICY = {
forever: -1,
custom: 1,
do_not_protect: 0,
};
const DELETION_POLICY = { do_not_delete: 0, custom: 1 };
export default class FormPolicyComponent extends Component {
//attributes
//methods
/**
* Returns retention policy options list
* @type {array}
*/
get listRententionOptions() {
return RETENTION_POLICY;
}
/**
* Returns deletion policy options list, if the retain days are -1 (forever)
* then we should force the delete option to be do not delete
* @type {array}
*/
get listDeletionOptions() {
if (this.args.model.retain_for?.days < 0) {
return { do_not_delete: 0 };
} else {
return DELETION_POLICY;
}
}
/**
* Returns policy type
* @type {string}
*/
get selectRetentionPolicyType() {
if (this.args.model.retain_for?.days < 0) {
return 'forever';
} else if (this.args.model.retain_for?.days >= 1) {
return 'custom';
} else {
return 'do_not_protect';
}
}
/**
* Returns policy type
* @type {string}
*/
get selectDeletePolicyType() {
return this.args.model.delete_after?.days > 0 ? 'custom' : 'do_not_delete';
}
}
@@ -0,0 +1,71 @@
<Hds::Form::Fieldset @isRequired={{true}} class='policy' as |F|>
<F.Legend>{{t (concat 'resources.policy.form.' @name '.label')}}</F.Legend>
<F.HelperText>
{{t (concat 'resources.policy.form.' @name '.help')}}
</F.HelperText>
<F.Control>
<Hds::Form::Select::Field
name={{@name}}
disabled={{or @disabled this.isDeleteDisable}}
aria-label={{t (concat 'resources.policy.form.' @name '.label')}}
@width='80%'
{{on 'change' this.handlePolicyTypeSelection}}
as |F|
>
<F.Options>
<option disabled hidden selected value=''>
{{t 'titles.choose-an-option'}}
</option>
{{#each-in @options as |key value|}}
<option value={{value}} selected={{eq key @selectedOption}}>
{{t
(concat 'resources.policy.form.' @name '_options.' key '.label')
}}
</option>
{{/each-in}}
</F.Options>
</Hds::Form::Select::Field>
</F.Control>
</Hds::Form::Fieldset>
{{#if this.showCustomInput}}
<Hds::Form::Field class='policy-custom-input' @layout='vertical' as |F|>
<F.Label>{{t (concat 'resources.policy.titles.' @customInputName)}}
</F.Label>
<F.Control>
<Hds::SegmentedGroup as |S|>
<S.TextInput
@value={{@inputValue}}
disabled={{@disabled}}
aria-label={{t 'resources.policy.title'}}
{{on 'input' this.handleInputChange}}
name={{@customInputName}}
data-input-type={{@customInputName}}
/>
<S.Generic>
<Hds::Badge
class={{if @disabled 'disabled'}}
@text={{t 'resources.policy.titles.days'}}
@color='neutral'
@type='outlined'
/>
</S.Generic>
</Hds::SegmentedGroup>
{{#if @model.scope.isGlobal}}
<Hds::Form::Toggle::Field
checked={{this.isOverridable}}
disabled={{@disabled}}
name={{@customInputName}}
{{on 'change' this.handleOverridableToggle}}
as |F|
>
<F.Label>{{t 'resources.policy.form.overridable.label'}}</F.Label>
</Hds::Form::Toggle::Field>
{{/if}}
</F.Control>
</Hds::Form::Field>
{{/if}}
@@ -0,0 +1,109 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class FormPolicySelectionComponent extends Component {
//methods
/**
* This is used to show/hide the custom input field
* @type {boolean}
*/
get showCustomInput() {
if (this.args.name === 'retention_policy') {
return this.isCustomRetentionSelected;
} else if (
this.args.name === 'deletion_policy' &&
this.args.model.retain_for?.days >= 0
) {
return this.isCustomDeletionSelected;
} else {
return null;
}
}
/**
* Returns true if rentention days are greater than 0
* @type {boolean}
*/
get isCustomRetentionSelected() {
return this.args.model.retain_for?.days > 0;
}
/**
* Returns true if deletion days are greater than 0
* @type {boolean}
*/
get isCustomDeletionSelected() {
return this.args.model.delete_after?.days > 0;
}
/**
* Returns true if the toggle is on
* @type {boolean}
*/
get isOverridable() {
return this.args.model[this.args.customInputName]?.overridable;
}
get isDeleteDisable() {
if (
this.args.name === 'deletion_policy' &&
this.args.model.retain_for?.days === -1
) {
return true;
} else {
return false;
}
}
//actions
/**
* Handles custom input changes
*/
@action
handleInputChange({ target: { value, name: field } }) {
// Select options return type is string and we want the `days` in integer format
const val = Number(value);
this.args.model[field] = {
...this.args.model[field],
days: val,
};
}
/**
* Toggle retain for & delete after overridables
*/
@action
handleOverridableToggle({ target: { name: field } }) {
this.args.model[field] = {
...this.args.model[field],
overridable: !this.args.model[field].overridable,
};
}
/**
* Show custom text field when custom option is selected
*/
@action
handlePolicyTypeSelection({ target: { value, name: policy } }) {
//Select options return type is string and we want the `days` in integer format
const selectedVal = Number(value);
if (policy === 'retention_policy') {
this.args.model.retain_for = {
...this.args.model.retain_for,
days: selectedVal,
};
if (selectedVal < 0) {
this.args.model.delete_after = {
...this.args.model.delete_after,
days: 0,
};
}
}
if (policy === 'deletion_policy') {
this.args.model.delete_after = {
...this.args.model.delete_after,
days: selectedVal,
};
}
}
}
+30 -2
View File
@@ -14,8 +14,9 @@ export default class ScopesScopePoliciesRoute extends Route {
// =services
@service store;
@service can;
@service session;
@service can;
@service router;
// =methods
@@ -35,7 +36,7 @@ export default class ScopesScopePoliciesRoute extends Route {
const { id: scope_id } = scope;
if (
this.can.can('list scope', scope, {
this.can.can('list model', scope, {
collection: 'policies',
})
) {
@@ -43,6 +44,33 @@ export default class ScopesScopePoliciesRoute extends Route {
}
}
//actions
/**
* Rollback changes on a policy.
* @param {PolicyModel} policy
*/
@action
cancel(policy) {
const { isNew } = policy;
policy.rollbackAttributes();
if (isNew) this.router.transitionTo('scopes.scope.policies');
}
/**
* Save a policy in current scope.
* @param {PolicyModel} policy
*/
@action
@loading
@notifyError(({ message }) => message)
@notifySuccess('notifications.save-success')
async save(policy) {
await policy.save();
await this.router.transitionTo('scopes.scope.policies.policy', policy);
this.refresh();
}
/**
* Deletes the policy.
* @param {PolicyModel} policy
@@ -4,7 +4,39 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ScopesScopePoliciesNewRoute extends Route {
// =services
@service store;
@service can;
@service router;
// =methods
/**
* Redirect to parent route when scope does not have create authorized action.
*/
beforeModel() {
const scopeModel = this.modelFor('scopes.scope');
if (
this.can.cannot('create model', scopeModel, { collection: 'policies' })
) {
this.router.replaceWith('scopes.scope.policies');
}
}
/**
* Creates a new unsaved policy.
* @return {PolicyModel}
*/
model() {
const scopeModel = this.modelFor('scopes.scope');
const record = this.store.createRecord('policy', {
type: 'storage',
});
record.scopeModel = scopeModel;
return record;
}
}
@@ -9,7 +9,6 @@ import { inject as service } from '@ember/service';
export default class ScopesScopePoliciesPolicyRoute extends Route {
@service store;
@service can;
@service router;
// =methods
+34
View File
@@ -951,3 +951,37 @@
margin-bottom: 1.5rem;
}
}
// storage policies
.policy {
margin-bottom: 0;
}
.policy-custom-input {
.hds-form-field__control {
display: flex;
justify-content: space-between;
align-items: center;
.hds-segmented-group {
width: 45%;
}
.hds-form-field--layout-flag {
width: 40%;
margin-bottom: 0;
}
}
.hds-badge {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-color: var(--ui-gray-subtler-1);
border-left: 0;
&.disabled {
color: var(--token-form-control-disabled-foreground-color);
border-color: var(--token-form-control-disabled-border-color);
}
}
}
@@ -64,17 +64,16 @@
{{/if}}
</B.Td>
<B.Td>
{{#if B.data.retain_for.days}}
{{! leave blank space when the retain days are 0 }}
{{#if (gte B.data.retain_for.days 1)}}
{{format-day-year B.data.retain_for.days}}
{{else}}
{{else if (eq B.data.retain_for.days -1)}}
{{t 'resources.policy.titles.forever'}}
{{/if}}
</B.Td>
<B.Td>
{{#if B.data.delete_after.days}}
{{format-day-year B.data.delete_after.days}}
{{else}}
{{t 'resources.policy.titles.do_not_delete'}}
{{/if}}
</B.Td>
<B.Td>
@@ -7,4 +7,25 @@
<Breadcrumbs::Item
@text={{t 'resources.policy.titles.new'}}
@route='scopes.scope.policies.new'
/>
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.policy.titles.new'}}
</h2>
</page.header>
<page.body>
<Form::Policy
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
</page.body>
</Rose::Layout::Page>
@@ -1,4 +1,39 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.policy.title'}}
</h2>
<Copyable
@text={{@model.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<Hds::Text::Code>{{@model.id}}</Hds::Text::Code>
</Copyable>
</page.header>
<page.navigation>
<Rose::Nav::Tabs as |nav|>
<nav.link @route='scopes.scope.policies.policy.index'>
{{t 'titles.details'}}
</nav.link>
</Rose::Nav::Tabs>
</page.navigation>
<page.body>
<Form::Policy
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,135 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { visit, currentURL, click, fillIn } from '@ember/test-helpers';
import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { Response } from 'miragejs';
import { authenticateSession } from 'ember-simple-auth/test-support';
module('Acceptance | policies | create', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
let features;
let getPolicyCount;
const SAVE_BTN_SELECTOR = '[type="submit"]';
const CANCEL_BTN_SELECTOR = '.rose-form-actions [type="button"]';
const NAME_FIELD_SELECTOR = '[name="name"]';
const RETAIN_FOR_INPUT = 'input[data-input-type="retain_for"]';
const ALERT_TEXT_SELECTOR = '[role="alert"] div';
const FIELD_ERROR_TEXT_SELECTOR = '.hds-form-error__message';
const NAME_FIELD_TEXT = 'random string';
const instances = {
scopes: {
global: null,
org: null,
},
};
const urls = {
globalScope: null,
policies: null,
newPolicy: null,
};
hooks.beforeEach(function () {
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
});
urls.globalScope = `/scopes/global`;
urls.policies = `${urls.globalScope}/policies`;
urls.newPolicy = `${urls.policies}/new`;
getPolicyCount = () => this.server.schema.policies.all().models.length;
features = this.owner.lookup('service:features');
features.enable('ssh-session-recording');
authenticateSession({});
});
test('users can create a new policy with global scope', async function (assert) {
const policyCount = getPolicyCount();
await visit(urls.policies);
await click(`[href="${urls.newPolicy}"]`);
await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT);
await click('.hds-form-select');
await fillIn('.hds-form-select', 1);
await fillIn(RETAIN_FOR_INPUT, 90);
await click(SAVE_BTN_SELECTOR);
const policy = this.server.schema.policies.findBy({
name: NAME_FIELD_TEXT,
});
assert.strictEqual(policy.name, NAME_FIELD_TEXT);
assert.strictEqual(policy.scopeId, 'global');
assert.strictEqual(policy.attributes.retain_for.days, 90);
assert.strictEqual(getPolicyCount(), policyCount + 1);
});
test('user can cancel new policy creation', async function (assert) {
const policyCount = getPolicyCount();
await visit(urls.policies);
await click(`[href="${urls.newPolicy}"]`);
await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT);
await click(CANCEL_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.policies);
assert.strictEqual(getPolicyCount(), policyCount);
});
test('saving a new policy with invalid fields displays error messages', async function (assert) {
this.server.post('/policies', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'The request was invalid.',
details: {
request_fields: [
{
name: 'name',
description: 'Name is required.',
},
],
},
},
);
});
await visit(urls.policies);
await click(`[href="${urls.newPolicy}"]`);
await click(SAVE_BTN_SELECTOR);
await a11yAudit();
assert.dom(ALERT_TEXT_SELECTOR).hasText('The request was invalid.');
assert.dom(FIELD_ERROR_TEXT_SELECTOR).hasText('Name is required.');
});
test('users cannot directly navigate to new policy route without proper authorization', async function (assert) {
instances.scopes.global.authorized_collection_actions['policies'] =
instances.scopes.global.authorized_collection_actions['policies'].filter(
(item) => item !== 'create',
);
await visit(urls.newPolicy);
assert.false(
instances.scopes.global.authorized_collection_actions[
'policies'
].includes('create'),
);
assert.strictEqual(currentURL(), urls.policies);
});
});
@@ -0,0 +1,84 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { authenticateSession } from 'ember-simple-auth/test-support';
module('Acceptance | policies | read', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
let features;
const MESSAGE_SELECTOR = '.rose-message-subtitle';
const TABLE_LINK_SELECTOR = '.hds-table__tbody tr:first-child a';
const instances = {
scopes: {
global: null,
org: null,
},
policy: null,
};
const urls = {
globalScope: null,
policies: null,
policy: null,
unknownPolicy: null,
};
hooks.beforeEach(function () {
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
});
instances.policy = this.server.create('policy', {
scope: instances.scopes.global,
});
urls.globalScope = `/scopes/global`;
urls.policies = `${urls.globalScope}/policies`;
urls.policy = `${urls.policies}/${instances.policy.id}`;
urls.unknownPolicy = `${urls.policies}/foo`;
features = this.owner.lookup('service:features');
features.enable('ssh-session-recording');
authenticateSession({});
});
test('visiting a policy', async function (assert) {
await visit(urls.globalScope);
await a11yAudit();
await click(`[href="${urls.policies}"]`);
await a11yAudit();
await click(`[href="${urls.policy}"]`);
await a11yAudit();
assert.strictEqual(currentURL(), urls.policy);
});
test('cannot navigate to a policy without proper authorization', async function (assert) {
await visit(urls.globalScope);
instances.policy.authorized_actions =
instances.policy.authorized_actions.filter((item) => item !== 'read');
await click(`[href="${urls.policies}"]`);
assert.dom(TABLE_LINK_SELECTOR).doesNotExist();
});
test('visiting an unknown policy displays 404 message', async function (assert) {
await visit(urls.unknownPolicy);
await a11yAudit();
assert.dom(MESSAGE_SELECTOR).hasText('Error 404');
});
});