From fb9a080d2e5800fd74fbc9710b6f08aa30c82c5e Mon Sep 17 00:00:00 2001 From: Dharini Jeeva Date: Wed, 17 Jan 2024 11:37:54 -0800 Subject: [PATCH] feat: reapply storage policies to session recording (#2095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../api/addon/abilities/session-recording.js | 7 +++ .../generated/models/channel-recording.js | 2 +- .../generated/models/connection-recording.js | 2 +- .../generated/models/session-recording.js | 13 ++++- addons/api/addon/models/session-recording.js | 19 ++++++++ .../addon/serializers/channel-recording.js | 23 +++++++++ .../addon/serializers/connection-recording.js | 12 +++++ .../addon/serializers/session-recording.js | 22 +++++++++ .../api/app/serializers/channel-recording.js | 6 +++ addons/api/mirage/config.js | 18 +++++++ .../api/mirage/factories/session-recording.js | 12 +++++ .../serializers/session-recording-test.js | 41 ++++++++++++++++ addons/core/translations/resources/en-us.yaml | 3 ++ .../rose/components/metadata-list/_index.scss | 1 + ui/admin/app/abilities/session-recording.js | 10 ++++ .../form/scope/add-storage-policy/create.hbs | 31 ++++++++++++ .../routes/scopes/scope/session-recordings.js | 14 ++++++ .../channels-by-connection/index.hbs | 47 +++++++++++++++++++ .../channels-by-connection/channel-test.js | 34 ++++++++++++++ 19 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 addons/api/addon/serializers/channel-recording.js create mode 100644 addons/api/app/serializers/channel-recording.js create mode 100644 ui/admin/app/components/form/scope/add-storage-policy/create.hbs diff --git a/addons/api/addon/abilities/session-recording.js b/addons/api/addon/abilities/session-recording.js index 102d48aa6..d0ae91f84 100644 --- a/addons/api/addon/abilities/session-recording.js +++ b/addons/api/addon/abilities/session-recording.js @@ -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'); + } } diff --git a/addons/api/addon/generated/models/channel-recording.js b/addons/api/addon/generated/models/channel-recording.js index 18a29db9d..4ea51d1c5 100644 --- a/addons/api/addon/generated/models/channel-recording.js +++ b/addons/api/addon/generated/models/channel-recording.js @@ -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.', diff --git a/addons/api/addon/generated/models/connection-recording.js b/addons/api/addon/generated/models/connection-recording.js index bb015f7c5..907f20ab2 100644 --- a/addons/api/addon/generated/models/connection-recording.js +++ b/addons/api/addon/generated/models/connection-recording.js @@ -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.', diff --git a/addons/api/addon/generated/models/session-recording.js b/addons/api/addon/generated/models/session-recording.js index 391e494a6..d78325880 100644 --- a/addons/api/addon/generated/models/session-recording.js +++ b/addons/api/addon/generated/models/session-recording.js @@ -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; } diff --git a/addons/api/addon/models/session-recording.js b/addons/api/addon/models/session-recording.js index 33720091e..df818693c 100644 --- a/addons/api/addon/models/session-recording.js +++ b/addons/api/addon/models/session-recording.js @@ -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, + }, + }); + } } diff --git a/addons/api/addon/serializers/channel-recording.js b/addons/api/addon/serializers/channel-recording.js new file mode 100644 index 000000000..18ffc0743 --- /dev/null +++ b/addons/api/addon/serializers/channel-recording.js @@ -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; + } +} diff --git a/addons/api/addon/serializers/connection-recording.js b/addons/api/addon/serializers/connection-recording.js index 4429ed509..ae1711d85 100644 --- a/addons/api/addon/serializers/connection-recording.js +++ b/addons/api/addon/serializers/connection-recording.js @@ -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; + } } diff --git a/addons/api/addon/serializers/session-recording.js b/addons/api/addon/serializers/session-recording.js index 434d92951..85f8578cc 100644 --- a/addons/api/addon/serializers/session-recording.js +++ b/addons/api/addon/serializers/session-recording.js @@ -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; + } } diff --git a/addons/api/app/serializers/channel-recording.js b/addons/api/app/serializers/channel-recording.js new file mode 100644 index 000000000..52fdc72ab --- /dev/null +++ b/addons/api/app/serializers/channel-recording.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'api/serializers/channel-recording'; diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 1e57fb989..0fd3244a9 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -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)); diff --git a/addons/api/mirage/factories/session-recording.js b/addons/api/mirage/factories/session-recording.js index d198fecb0..ea81b3c8c 100644 --- a/addons/api/mirage/factories/session-recording.js +++ b/addons/api/mirage/factories/session-recording.js @@ -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({ diff --git a/addons/api/tests/unit/serializers/session-recording-test.js b/addons/api/tests/unit/serializers/session-recording-test.js index e95f84f2b..f7e47b867 100644 --- a/addons/api/tests/unit/serializers/session-recording-test.js +++ b/addons/api/tests/unit/serializers/session-recording-test.js @@ -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: {}, + }, + }); + }); }); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 7e9325c6f..28ad32546 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -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. diff --git a/addons/rose/app/styles/rose/components/metadata-list/_index.scss b/addons/rose/app/styles/rose/components/metadata-list/_index.scss index 82273979c..e4745947d 100644 --- a/addons/rose/app/styles/rose/components/metadata-list/_index.scss +++ b/addons/rose/app/styles/rose/components/metadata-list/_index.scss @@ -18,5 +18,6 @@ display: flex; align-items: center; column-gap: 0.5rem; + font-size: var(--token-typography-body-200-font-size); } } diff --git a/ui/admin/app/abilities/session-recording.js b/ui/admin/app/abilities/session-recording.js index 6f5164444..130e68c66 100644 --- a/ui/admin/app/abilities/session-recording.js +++ b/ui/admin/app/abilities/session-recording.js @@ -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; + } } diff --git a/ui/admin/app/components/form/scope/add-storage-policy/create.hbs b/ui/admin/app/components/form/scope/add-storage-policy/create.hbs new file mode 100644 index 000000000..e5709b1c2 --- /dev/null +++ b/ui/admin/app/components/form/scope/add-storage-policy/create.hbs @@ -0,0 +1,31 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +{{page-title (t 'titles.new')}} + + + + + + + + + + {{t 'resources.policy.titles.new'}} + + + + + + + + \ No newline at end of file diff --git a/ui/admin/app/routes/scopes/scope/session-recordings.js b/ui/admin/app/routes/scopes/scope/session-recordings.js index 7d81c6d93..90fb45e94 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings.js @@ -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); + } } diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs index 16beef560..f1d51c38a 100644 --- a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs @@ -12,6 +12,24 @@

{{t 'resources.session-recording.session.title'}}

+ + {{#if + (can 'reapplyStoragePolicy session-recording' @model.sessionRecording) + }} + + + + + + {{/if}} + +
@@ -223,6 +241,35 @@ }} {{/if}} + {{! Policy info}} + + {{#if @model.sessionRecording.retain_until}} + + {{t 'resources.policy.titles.retain_until'}} + + + {{/if}} + + {{#if @model.sessionRecording.delete_after}} + + {{t 'resources.policy.titles.delete_after'}} + + + {{/if}}
diff --git a/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js b/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js index 90923c3e3..cdf927993 100644 --- a/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js +++ b/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js @@ -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(); + }); }, );