feat: reapply storage policies to session recording (#2095)

* refactor: 💡 add global settings link

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12257

* fix: 🐛 route

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12257

* fix: 🐛 comment

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12257

* feat: 🎸 add reapply sp to session recording page

* fix: 🐛 normalize

* feat: 🎸 upates

* fix: 🐛 logs

* fix: 🐛 comment

* test: 💍 add tests

* fix: 🐛 normalizer, style

* fix: 🐛 normalize

* fix: 🐛 normalizw

* fix: 🐛 normalize

* test: 💍 add tests for serializer n normalizer

* fix: 🐛 name

* fix: 🐛 do not delete, send null

* fix: 🐛 return

---------

Co-authored-by: Lisbet Alvarez <lisbet.alvarez@hashicorp.com>
This commit is contained in:
Dharini JeevaandLisbet Alvarez authored and GitHub committed 2024-01-17 14:37:54 -05:00
1 parent 9e0467cbf0
commit fb9a080d2e
19 files changed
+314 -3

No files matched your search

@@ -24,4 +24,11 @@ export default class SessionRecordingAbility extends ModelAbility {
get canDownload() {
return this.hasAuthorizedAction('download');
}
/* Permission that checks whether a policy can be reapplied to a session recording
* @type {boolean}
*/
get canReapplyStoragePolicy() {
return this.hasAuthorizedAction('reapply-storage-policy');
}
}
@@ -31,7 +31,7 @@ export default class GeneratedChannelRecordingModel extends BaseModel {
'The total number of errors that occurred during the use of this Channel.',
readOnly: true,
})
errors;
errors_number;
@attr('date', {
description: 'The time the Channel started.',
@@ -38,7 +38,7 @@ export default class GeneratedConnectionRecordingModel extends BaseModel {
'The total number of errors that occurred during the use of this Connection.',
readOnly: true,
})
errors;
errors_number;
@attr('date', {
description: 'The time the Connection started.',
@@ -70,7 +70,7 @@ export default class GeneratedSessionRecordingModel extends BaseModel {
'The total number of errors that occurred during the use of the Session.',
readOnly: true,
})
errors;
errors_number;
@attr('date', {
description: 'The time the Session started.',
@@ -109,4 +109,15 @@ export default class GeneratedSessionRecordingModel extends BaseModel {
readOnly: true,
})
error_details;
@attr('date', {
description: 'The time until a session recording is required to be stored.',
})
retain_until;
@attr('date', {
description:
'The time a session recording is scheduled to be automatically deleted.',
})
delete_after;
}
@@ -69,4 +69,23 @@ export default class SessionRecordingModel extends GeneratedSessionRecordingMode
this.create_time_values?.user?.name || this.create_time_values?.user?.id
);
}
/**
* Reapply policy dates via the `reapply-staorage-policy` method.
* @param {object} options
* @param {object} options.adapterOptions
* @return {Promise}
*/
reapplyStoragePolicy(options = { adapterOptions: {} }) {
const defaultAdapterOptions = {
method: 'reapply-storage-policy',
};
return this.save({
...options,
adapterOptions: {
...defaultAdapterOptions,
...options.adapterOptions,
},
});
}
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationSerializer from './application';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class ChannelSerializer extends ApplicationSerializer.extend(
EmbeddedRecordsMixin,
) {
normalize(typeClass, hash, ...rest) {
const normalizedHash = structuredClone(hash);
const err = normalizedHash.errors;
delete normalizedHash.errors;
const normalized = super.normalize(typeClass, normalizedHash, ...rest);
// we should not override the ember models errors field,
// so we rename the errors field from the api
normalized.data.attributes.errors_number = err;
return normalized;
}
}
@@ -12,4 +12,16 @@ export default class ConnectionSerializer extends ApplicationSerializer.extend(
attrs = {
channel_recordings: { embedded: 'always' },
};
normalize(typeClass, hash, ...rest) {
const normalizedHash = structuredClone(hash);
const err = normalizedHash.errors;
delete normalizedHash.errors;
const normalized = super.normalize(typeClass, normalizedHash, ...rest);
// we should not override the ember models errors field,
// so we rename the errors field from the api
normalized.data.attributes.errors_number = err;
return normalized;
}
}
@@ -11,4 +11,26 @@ export default class RecordingSerializer extends ApplicationSerializer.extend(
attrs = {
connection_recordings: { embedded: 'always' },
};
serialize(snapshot) {
let serialized = super.serialize(...arguments);
// The post request for reapply-storage-policy doesn't expect a payload,
// so we return nothing
if (snapshot?.adapterOptions?.method === 'reapply-storage-policy') {
return {};
}
return serialized;
}
normalize(typeClass, hash, ...rest) {
const normalizedHash = structuredClone(hash);
const err = normalizedHash.errors;
delete normalizedHash.errors;
const normalized = super.normalize(typeClass, normalizedHash, ...rest);
// we should not override the ember models errors field,
// so we rename the errors field from the api
normalized.data.attributes.errors_number = err;
return normalized;
}
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'api/serializers/channel-recording';
+18
View File
@@ -780,6 +780,24 @@ function routes() {
},
);
this.post(
'/session-recordings/:idMethod',
function ({ sessionRecordings }, { params: { idMethod } }) {
const attrs = this.normalizedRequestAttrs();
const id = idMethod.split(':')[0];
const method = idMethod.split(':')[1];
const record = sessionRecordings.find(id);
const updatedAttrs = {
version: attrs.version,
};
if (method === 'reapply-storage-policy') {
updatedAttrs.retain_until = faker.date.recent();
updatedAttrs.delete_after = faker.date.recent();
}
return record.update(updatedAttrs);
},
);
/* Uncomment the following line and the Response import above
* Then change the response code to simulate error responses.
* this.get('/scopes', () => new Response(505));
@@ -43,11 +43,23 @@ export default factory.extend({
: null;
},
retain_until() {
return this.state === STATE_SESSION_RECORDING_AVAILABLE
? faker.date.recent()
: null;
},
delete_after() {
return this.state === STATE_SESSION_RECORDING_AVAILABLE
? faker.date.recent()
: null;
},
authorized_actions: () =>
permissions.authorizedActionsFor('session-recording') || [
'no-op',
'read',
'download',
'reapply-storage-policy',
],
withNonExistingUserAndTarget: trait({
@@ -25,4 +25,45 @@ module('Unit | Serializer | session-recording', function (hooks) {
assert.ok(serializedRecord);
});
test('it serializes only version when `adapterOptions.method` is `reapply-storage-policy`', function (assert) {
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('session-recording');
const record = store.createRecord('session-recording', {
version: 1,
retain_until: 1,
delete_after: 2,
});
const snapshot = record._createSnapshot();
snapshot.adapterOptions = {
method: 'reapply-storage-policy',
};
const serializedRecord = serializer.serialize(snapshot);
assert.deepEqual(serializedRecord, {});
});
test('it normalizes correctly', function (assert) {
let store = this.owner.lookup('service:store');
const serializer = store.serializerFor('session-recording');
let record = store.createRecord('session-recording').constructor;
const payload = {
id: '1',
type: 'session-recording',
errors: 2,
};
const normalized = serializer.normalize(record, payload);
assert.deepEqual(normalized, {
data: {
id: '1',
type: 'session-recording',
attributes: {
errors_number: 2,
type: 'session-recording',
},
relationships: {},
},
});
});
});
@@ -937,15 +937,18 @@ policy:
days: days
policy_description: Store recorded sessions for at least this long.
create_policy_description: Enable session recording for projects in this org.
retain_until: Retain until
actions:
delete: Delete Storage Policy
add: Add Storage Policy
apply: Apply the storage policy to this scope and its children
new: Add a new storage policy
update: Re-apply storage policy
questions:
detach: Are you sure you want to detach this storage policy?
messages:
detach: Successfully detached
reapply: Successfully reapplied
none:
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.
@@ -18,5 +18,6 @@
display: flex;
align-items: center;
column-gap: 0.5rem;
font-size: var(--token-typography-body-200-font-size);
}
}
@@ -19,4 +19,14 @@ export default class OverrideSessionRecordingAbility extends SessionRecordingAbi
? super.canRead
: false;
}
/**
* Reapplying policy is allowed only if the feature flag is enabled
* @type {boolean}
*/
get canReapplyStoragePolicy() {
return this.features.isEnabled('ssh-session-recording')
? this.hasAuthorizedAction('reapply-storage-policy')
: false;
}
}
@@ -0,0 +1,31 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
{{page-title (t 'titles.new')}}
<Breadcrumbs::Item
@text={{t 'resources.policy.titles.new'}}
@route='scopes.scope.add-storage-policy.create'
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<Hds::Text::Display @tag='h2'>
{{t 'resources.policy.titles.new'}}
</Hds::Text::Display>
</page.header>
<page.body>
<Form::Policy
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
</page.body>
</Rose::Layout::Page>
@@ -5,6 +5,8 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { notifySuccess, notifyError } from 'core/decorators/notify';
import orderBy from 'lodash/orderBy';
export default class ScopesScopeSessionRecordingsRoute extends Route {
@@ -68,4 +70,16 @@ export default class ScopesScopeSessionRecordingsRoute extends Route {
};
}
}
/**
* Reapplies storage policy dates to session recording
* @param {SessionRecordingModel}
*/
@action
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('resources.policy.messages.reapply')
async reapplyStoragepolicy(sessionRecording) {
await sessionRecording.reapplyStoragePolicy();
super.refresh(...arguments);
}
}
@@ -12,6 +12,24 @@
<h2>{{t 'resources.session-recording.session.title'}}</h2>
</page.header>
<page.actions>
{{#if
(can 'reapplyStoragePolicy session-recording' @model.sessionRecording)
}}
<Hds::Dropdown data-test-manage-dropdown as |dd|>
<dd.ToggleButton @text={{t 'actions.manage'}} @color='secondary' />
<dd.Interactive
@text={{t 'resources.policy.actions.update'}}
{{on
'click'
(route-action 'reapplyStoragepolicy' @model.sessionRecording)
}}
/>
</Hds::Dropdown>
{{/if}}
</page.actions>
<page.body>
<div class='channels-by-connection'>
<Rose::Layout::BodyContent as |bc|>
@@ -223,6 +241,35 @@
}}
</list.Item>
{{/if}}
{{! Policy info}}
{{#if @model.sessionRecording.retain_until}}
<list.Item @icon='hourglass' @color='faint'>
{{t 'resources.policy.titles.retain_until'}}
<time
data-test-retain-until
datetime={{format-date-iso
@model.sessionRecording.retain_until
}}
>
{{format-date-iso @model.sessionRecording.retain_until}}
</time>
</list.Item>
{{/if}}
{{#if @model.sessionRecording.delete_after}}
<list.Item @icon='hourglass' @color='faint'>
{{t 'resources.policy.titles.delete_after'}}
<time
data-test-delete-after
datetime={{format-date-iso
@model.sessionRecording.delete_after
}}
>
{{format-date-iso @model.sessionRecording.delete_after}}
</time>
</list.Item>
{{/if}}
</Rose::MetadataList>
</div>
@@ -146,5 +146,39 @@ module(
assert.notEqual(currentURL(), incorrectUrl);
assert.strictEqual(currentURL(), urls.channelRecording);
});
test('user cannot view manage dropdown without proper authorization', async function (assert) {
// Visit channel
featuresService.enable('ssh-session-recording');
instances.sessionRecording.authorized_actions =
instances.sessionRecording.authorized_actions.filter(
(item) => item !== 'reapply-storage-policy',
);
await visit(urls.sessionRecording);
assert.dom('[data-test-manage-dropdown]').doesNotExist();
});
test('user can view manage dropdown with proper authorization', async function (assert) {
// Visit channel
featuresService.enable('ssh-session-recording');
await visit(urls.sessionRecording);
assert.dom('[data-test-manage-dropdown]').isVisible();
});
test('both retain until and delete after can be seen with proper authorization', async function (assert) {
// Visit channel
featuresService.enable('ssh-session-recording');
await visit(urls.sessionRecording);
assert.dom('.hds-dropdown-toggle-button__text').isVisible();
assert.dom('[data-test-retain-until]').isVisible();
assert.dom('[data-test-delete-after]').isVisible();
});
},
);