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:
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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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>
|
||||
|
||||
|
||||
+34
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user