From bf2029eb54229c8fa26c6f924575ccc7852ea3f0 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Wed, 15 Mar 2023 15:45:51 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Add=20abilities=20for=20?= =?UTF-8?q?session=20recordings=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 Add abilities for session recordings ✅ Closes: ICU-8079 --- addons/api/addon/abilities/recording.js | 34 +++ addons/api/app/abilities/recording.js | 3 + addons/api/mirage/factories/recording.js | 6 +- addons/api/mirage/factories/scope.js | 2 + ui/admin/app/abilities/recording.js | 33 +++ ui/admin/app/abilities/storage-bucket.js | 7 +- .../tests/unit/abilities/recording-test.js | 221 ++++++++++++++++++ .../unit/abilities/storage-bucket-test.js | 45 ++-- 8 files changed, 324 insertions(+), 27 deletions(-) create mode 100644 addons/api/addon/abilities/recording.js create mode 100644 addons/api/app/abilities/recording.js create mode 100644 ui/admin/app/abilities/recording.js create mode 100644 ui/admin/tests/unit/abilities/recording-test.js diff --git a/addons/api/addon/abilities/recording.js b/addons/api/addon/abilities/recording.js new file mode 100644 index 000000000..7d9da0957 --- /dev/null +++ b/addons/api/addon/abilities/recording.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ModelAbility from './model'; + +/** + * Provides abilities for session recordings. + */ +export default class RecordingAbility extends ModelAbility { + // =services + + // =permissions + + /** + * Only "known" session recording types may be read. + * @type {boolean} + */ + get canRead() { + return !this.model.isUnknown && this.hasAuthorizedAction('read'); + } + + get canList() { + return ( + this.hasAuthorizedCollectionAction('list') && + (this.model.isGlobal || this.model.isOrg) + ); + } + + get canDownloadAsciiCast() { + return this.model.isSSH && this.hasAuthorizedAction('download'); + } +} diff --git a/addons/api/app/abilities/recording.js b/addons/api/app/abilities/recording.js new file mode 100644 index 000000000..a6d654ffd --- /dev/null +++ b/addons/api/app/abilities/recording.js @@ -0,0 +1,3 @@ +import { Ability } from 'ember-can'; + +export default class extends Ability {} diff --git a/addons/api/mirage/factories/recording.js b/addons/api/mirage/factories/recording.js index 1c39abf09..ce2cf9c99 100644 --- a/addons/api/mirage/factories/recording.js +++ b/addons/api/mirage/factories/recording.js @@ -18,7 +18,11 @@ export default factory.extend({ type: (i) => types[i % types.length], authorized_actions: () => - permissions.authorizedActionsFor('recording') || ['no-op', 'read', 'list'], + permissions.authorizedActionsFor('recording') || [ + 'no-op', + 'read', + 'download', + ], withNonExistingUserAndTarget: trait({ afterCreate(recording, server) { diff --git a/addons/api/mirage/factories/scope.js b/addons/api/mirage/factories/scope.js index a61f252ee..568efe1df 100644 --- a/addons/api/mirage/factories/scope.js +++ b/addons/api/mirage/factories/scope.js @@ -37,8 +37,10 @@ export default factory.extend({ collectionActions.workers = ['create:worker-led', 'list']; } + // Session recording resources are only available on global or org scope if (this.type === 'global' || this.type === 'org') { collectionActions['storage-buckets'] = ['create', 'list']; + collectionActions.recordings = ['list']; } return collectionActions; diff --git a/ui/admin/app/abilities/recording.js b/ui/admin/app/abilities/recording.js new file mode 100644 index 000000000..3365397c8 --- /dev/null +++ b/ui/admin/app/abilities/recording.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import RecordingAbility from 'api/abilities/recording'; +import { inject as service } from '@ember/service'; + +export default class OverrideRecordingAbility extends RecordingAbility { + // =service + @service features; + + /** + * This override ensures that session recordings may be read only if the + * session-recording feature flag is enabled. + */ + get canRead() { + return this.features.isEnabled('session-recording') ? super.canRead : false; + } + + /** + * This override ensures that session recordings can only be presented in the global scope + * and if the session-recording feature flag is enabled. */ + get canList() { + return this.features.isEnabled('session-recording') + ? this.hasAuthorizedCollectionAction('list') && this.model.isGlobal + : false; + } + + get canNavigate() { + return this.canList; + } +} diff --git a/ui/admin/app/abilities/storage-bucket.js b/ui/admin/app/abilities/storage-bucket.js index a34d0b9d3..c8fae92d6 100644 --- a/ui/admin/app/abilities/storage-bucket.js +++ b/ui/admin/app/abilities/storage-bucket.js @@ -19,10 +19,13 @@ export default class OverrideStorageBucketAbility extends StorageBucketAbility { } /** - * This override ensures that storage buckets can only be presented in the global scope. + * This override ensures that storage buckets can only be presented in the global scope + * and if the session-recording feature flag is enabled. */ get canList() { - return this.hasAuthorizedCollectionAction('list') && this.model.isGlobal; + return this.features.isEnabled('session-recording') + ? this.hasAuthorizedCollectionAction('list') && this.model.isGlobal + : false; } get canNavigate() { diff --git a/ui/admin/tests/unit/abilities/recording-test.js b/ui/admin/tests/unit/abilities/recording-test.js new file mode 100644 index 000000000..05a46b2b5 --- /dev/null +++ b/ui/admin/tests/unit/abilities/recording-test.js @@ -0,0 +1,221 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { TYPE_RECORDING_SSH } from 'api/models/recording'; + +module('Unit | Abilities | session-recording', function (hooks) { + setupTest(hooks); + + let features; + let canService; + let store; + + hooks.beforeEach(function () { + features = this.owner.lookup('service:features'); + features.enable('session-recording'); + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); + }); + + test('can read session recording feature is enabled', function (assert) { + assert.expect(2); + + const recordingWithAuthorizedAction = store.createRecord('recording', { + authorized_actions: ['read'], + type: TYPE_RECORDING_SSH, + }); + const recordingWithoutAuthorizedAction = store.createRecord('recording', { + authorized_actions: [], + type: TYPE_RECORDING_SSH, + }); + + assert.true( + canService.can('read recording', recordingWithAuthorizedAction) + ); + assert.false( + canService.can('read recording', recordingWithoutAuthorizedAction) + ); + }); + + test('cannot read session recording when feature is disabled', function (assert) { + assert.expect(2); + features.disable('session-recording'); + + const recordingWithAuthorizedAction = store.createRecord('recording', { + authorized_actions: ['read'], + type: TYPE_RECORDING_SSH, + }); + const recordingWithoutAuthorizedAction = store.createRecord('recording', { + authorized_actions: [], + type: TYPE_RECORDING_SSH, + }); + + assert.false( + canService.can('read recording', recordingWithAuthorizedAction) + ); + assert.false( + canService.can('read recording', recordingWithoutAuthorizedAction) + ); + }); + + test('can list session recording when in global scope', function (assert) { + assert.expect(2); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: ['list'] }, + type: 'global', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: [] }, + type: 'global', + id: 'second', + }); + + assert.true( + canService.can('list recording', scopeModelWithAuthorizedAction, { + collection: 'recordings', + }) + ); + assert.false( + canService.can('list recording', scopeModelWithoutAuthorizedAction, { + collection: 'recordings', + }) + ); + }); + + test('cannot list session recording when in org scope', function (assert) { + assert.expect(2); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: ['list'] }, + type: 'org', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: [] }, + type: 'org', + id: 'second', + }); + + assert.false( + canService.can('list recording', scopeModelWithAuthorizedAction, { + collection: 'recordings', + }) + ); + assert.false( + canService.can('list recording', scopeModelWithoutAuthorizedAction, { + collection: 'recordings', + }) + ); + }); + + test('can navigate to session recording when feature is enabled', function (assert) { + assert.expect(2); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: ['list'] }, + type: 'global', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: [] }, + type: 'global', + id: 'second', + }); + + assert.true( + canService.can('navigate recording', scopeModelWithAuthorizedAction, { + collection: 'recordings', + }) + ); + assert.false( + canService.can('navigate recording', scopeModelWithoutAuthorizedAction, { + collection: 'recordings', + }) + ); + }); + + test('cannot navigate to session recording when feature is disabled', function (assert) { + assert.expect(2); + features.disable('session-recording'); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: ['list'] }, + type: 'global', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { recordings: [] }, + type: 'global', + id: 'second', + }); + + assert.false( + canService.can('navigate recording', scopeModelWithAuthorizedAction, { + collection: 'recordings', + }) + ); + assert.false( + canService.can('navigate recording', scopeModelWithoutAuthorizedAction, { + collection: 'recordings', + }) + ); + }); + + test('can download asciicast when an ssh recording has download authorization', function (assert) { + assert.expect(2); + + const recordingWithAuthorizedAction = store.createRecord('recording', { + authorized_actions: ['download'], + type: TYPE_RECORDING_SSH, + }); + const recordingWithoutAuthorizedAction = store.createRecord('recording', { + authorized_actions: [], + type: TYPE_RECORDING_SSH, + }); + + assert.true( + canService.can( + 'downloadAsciiCast recording', + recordingWithAuthorizedAction + ) + ); + assert.false( + canService.can( + 'downloadAsciiCast recording', + recordingWithoutAuthorizedAction + ) + ); + }); + + test('cannot download asciicast when a recording is not ssh type', function (assert) { + assert.expect(2); + + const recordingWithAuthorizedAction = store.createRecord('recording', { + authorized_actions: ['download'], + type: 'unknown', + }); + const recordingWithoutAuthorizedAction = store.createRecord('recording', { + authorized_actions: [], + type: 'unknown', + }); + + assert.false( + canService.can( + 'downloadAsciiCast recording', + recordingWithAuthorizedAction + ) + ); + assert.false( + canService.can( + 'downloadAsciiCast recording', + recordingWithoutAuthorizedAction + ) + ); + }); +}); diff --git a/ui/admin/tests/unit/abilities/storage-bucket-test.js b/ui/admin/tests/unit/abilities/storage-bucket-test.js index 3961c60dd..58b5c4319 100644 --- a/ui/admin/tests/unit/abilities/storage-bucket-test.js +++ b/ui/admin/tests/unit/abilities/storage-bucket-test.js @@ -14,16 +14,18 @@ module('Unit | Abilities | storage-bucket', function (hooks) { setupTest(hooks); let features; + let canService; + let store; hooks.beforeEach(function () { features = this.owner.lookup('service:features'); features.enable('session-recording'); + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); }); test('can read storage bucket when authorized and feature is enabled', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const storageBucket = store.createRecord('storage-bucket', { authorized_actions: ['read'], @@ -36,8 +38,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot read storage bucket when unauthorized and feature is enabled', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const storageBucket = store.createRecord('storage-bucket', { authorized_actions: [], @@ -50,8 +50,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot read storage bucket when authorized and feature is disabled', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); features.disable('session-recording'); const storageBucket = store.createRecord('storage-bucket', { @@ -65,8 +63,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot read storage bucket when unauthorized and feature is disabled', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); features.disable('session-recording'); const storageBucket = store.createRecord('storage-bucket', { @@ -80,8 +76,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('can list storage bucket when authorized and in global scope', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': ['list'] }, @@ -98,8 +92,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot list storage bucket when unauthorized and in global scope', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': [] }, @@ -116,8 +108,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot list storage bucket when authorized and in org scope', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': ['list'] }, @@ -134,8 +124,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('can create storage bucket when authorized and in global scope', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': ['create'] }, @@ -152,8 +140,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('can create storage bucket when authorized and in org scope', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': ['create'] }, @@ -170,8 +156,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot create storage bucket when authorized and in project scope', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': ['create'] }, @@ -188,8 +172,6 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('cannot create storage bucket when unauthorized', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': [] }, @@ -206,8 +188,23 @@ module('Unit | Abilities | storage-bucket', function (hooks) { test('can navigate to storage bucket when authorized', function (assert) { assert.expect(1); - const canService = this.owner.lookup('service:can'); - const store = this.owner.lookup('service:store'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['create'] }, + type: 'global', + id: 'global', + }); + + assert.true( + canService.can('navigate storage-bucket', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('cannot navigate to storage bucket when authorized and feature is disabled', function (assert) { + assert.expect(1); + features.disable('session-recording'); const scopeModel = store.createRecord('scope', { authorized_collection_actions: { 'storage-buckets': ['create'] },