From 4910070c955e7e0b8bfefc7a4d680a8f51117715 Mon Sep 17 00:00:00 2001 From: Dharini Jeeva Date: Mon, 18 Sep 2023 13:26:38 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20support=20for=20dy?= =?UTF-8?q?namic=20credentials=20in=20storage=20bucket=20(#1897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add support for dynamic credentials in storage bucket --- addons/api/addon/models/storage-bucket.js | 21 ++ .../api/addon/serializers/storage-bucket.js | 5 + .../tests/unit/models/storage-bucket-test.js | 21 +- addons/core/translations/resources/en-us.yaml | 19 ++ .../components/form/storage-bucket/index.hbs | 312 ++++++++++++------ .../components/form/storage-bucket/index.js | 62 +++- .../scopes/scope/storage-buckets.js | 1 + .../scope/storage-buckets/storage-bucket.js | 1 - .../routes/scopes/scope/storage-buckets.js | 16 + .../scopes/scope/storage-buckets/new.js | 14 + .../scopes/scope/storage-buckets/new.hbs | 1 + .../storage-buckets/storage-bucket/index.hbs | 1 + .../acceptance/storage-buckets/create-test.js | 42 ++- 13 files changed, 420 insertions(+), 96 deletions(-) diff --git a/addons/api/addon/models/storage-bucket.js b/addons/api/addon/models/storage-bucket.js index 70e077bbe..7a15bc5c7 100644 --- a/addons/api/addon/models/storage-bucket.js +++ b/addons/api/addon/models/storage-bucket.js @@ -13,6 +13,14 @@ export const TYPES_STORAGE_BUCKET_PLUGIN = Object.freeze([ TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, ]); +export const TYPE_CREDENTIAL_STATIC = 'static'; +export const TYPE_CREDENTIAL_DYNAMIC = 'dynamic'; + +export const TYPES_CREDENTIALS = Object.freeze([ + TYPE_CREDENTIAL_STATIC, + TYPE_CREDENTIAL_DYNAMIC, +]); + export default class StorageBucketModel extends GeneratedStorageBucketModel { /** * True if the storage bucket is a plugin. @@ -22,6 +30,19 @@ export default class StorageBucketModel extends GeneratedStorageBucketModel { return this.type === TYPE_STORAGE_BUCKET_PLUGIN; } + /** + * If the storage bucket has role_arn, return dynamic + * otherwise return the default static type. + * @type {string} + */ + get credentialType() { + if (this.role_arn) { + return TYPE_CREDENTIAL_DYNAMIC; + } else { + return TYPE_CREDENTIAL_STATIC; + } + } + /** * True if the storage bucket is an unknown type. * @type {boolean} diff --git a/addons/api/addon/serializers/storage-bucket.js b/addons/api/addon/serializers/storage-bucket.js index d6f55c0be..0be34a2a5 100644 --- a/addons/api/addon/serializers/storage-bucket.js +++ b/addons/api/addon/serializers/storage-bucket.js @@ -8,6 +8,11 @@ import ApplicationSerializer from './application'; export default class StorageBucketSerializer extends ApplicationSerializer { serialize(snapshot) { const serialized = super.serialize(...arguments); + //Dynamic credentials do not allow credential rotation, and the API requires this field to be + //sent along with the dynamic credential fields + if (serialized.attributes.role_arn) { + serialized.attributes.disable_credential_rotation = true; + } if (!snapshot.record.isNew) { delete serialized.bucket_name; delete serialized.bucket_prefix; diff --git a/addons/api/tests/unit/models/storage-bucket-test.js b/addons/api/tests/unit/models/storage-bucket-test.js index e5cf2e695..095820500 100644 --- a/addons/api/tests/unit/models/storage-bucket-test.js +++ b/addons/api/tests/unit/models/storage-bucket-test.js @@ -5,7 +5,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; -import { TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3 } from 'api/models/storage-bucket'; +import { + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + TYPE_CREDENTIAL_DYNAMIC, + TYPE_CREDENTIAL_STATIC, +} from 'api/models/storage-bucket'; module('Unit | Model | storage bucket', function (hooks) { setupTest(hooks); @@ -41,6 +45,21 @@ module('Unit | Model | storage bucket', function (hooks) { assert.true(modelB.isUnknown); }); + test('it has credentialType property and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('storage-bucket', { + role_arn: 'test-role-arn', + access_key_id: null, + }); + const modelB = store.createRecord('storage-bucket', { + role_arn: null, + access_key_id: 'test-access-key-id', + }); + assert.strictEqual(modelA.credentialType, TYPE_CREDENTIAL_DYNAMIC); + assert.strictEqual(modelB.credentialType, TYPE_CREDENTIAL_STATIC); + }); + test('it has isAWS property and returns the expected values', async function (assert) { assert.expect(2); const store = this.owner.lookup('service:store'); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 15f297371..3096462e7 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -384,6 +384,13 @@ storage-bucket: delete: Delete Storage Bucket types: aws_s3: AWS S3 + credential: Credential type + static: + title: Static + description: Use an access key generated by AWS to use with this storage bucket + dynamic: + title: Dynamic + description: Specify an Assume Role used to generate credentials to use with this AWS bucket form: scope: label: Scope @@ -399,6 +406,18 @@ storage-bucket: bucket_prefix: label: Bucket prefix help: The base path where session recordings will be stored. + role_arn: + label: Role ARN + help: The ARN (Assume Role Name) used by this instance that the worker runs on + role_external_id: + label: Role external ID + help: Used to provide access to access this account if you have this configured + role_tags: + label: Role tags + help: Attributes to pass to AWS + role_session_name: + label: Role session name + help: Unique identifier for the AWS session to help identify it in your logs region: label: Region help: The AWS region to use diff --git a/ui/admin/app/components/form/storage-bucket/index.hbs b/ui/admin/app/components/form/storage-bucket/index.hbs index 0ba1e5818..5204230a0 100644 --- a/ui/admin/app/components/form/storage-bucket/index.hbs +++ b/ui/admin/app/components/form/storage-bucket/index.hbs @@ -176,93 +176,220 @@ {{/if}} - {{#if @model.isNew}} - - {{t - 'resources.storage-bucket.form.access_key_id.label' - }} - {{t - 'resources.storage-bucket.form.access_key_id.help' - }} - {{#if @model.errors.secrets}} - - {{#each @model.errors.secrets as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - + + {{t 'resources.storage-bucket.types.credential'}} + {{#each this.credentials as |credentialType|}} + + {{t + (concat 'resources.storage-bucket.types.' credentialType '.title') + }} + {{t + (concat + 'resources.storage-bucket.types.' credentialType '.description' + ) + }} + + {{/each}} + + + {{#unless this.showDynamicCredentials}} + + {{#if @model.isNew}} + + {{t + 'resources.storage-bucket.form.access_key_id.label' + }} + {{t + 'resources.storage-bucket.form.access_key_id.help' + }} + {{#if @model.errors.secrets}} + + {{#each @model.errors.secrets as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t + 'resources.storage-bucket.form.secret_access_key.label' + }} + {{t + 'resources.storage-bucket.form.secret_access_key.help' + }} + {{#if @model.errors.secrets}} + + {{#each @model.errors.secrets as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{else}} + + {{t + 'resources.storage-bucket.form.access_key_id.label' + }} + {{t + 'resources.storage-bucket.form.access_key_id.help' + }} + + + + {{t + 'resources.storage-bucket.form.secret_access_key.label' + }} + {{t + 'resources.storage-bucket.form.secret_access_key.help' + }} + + {{/if}} + {{/unless}} + + {{#if this.showDynamicCredentials}} - {{t - 'resources.storage-bucket.form.secret_access_key.label' - }} - {{t - 'resources.storage-bucket.form.secret_access_key.help' - }} - {{#if @model.errors.secrets}} + {{t 'resources.storage-bucket.form.role_arn.label'}} + + {{t 'resources.storage-bucket.form.role_arn.help'}} + + {{#if @model.errors.role_arn}} - {{#each @model.errors.secrets as |error|}} + {{#each @model.errors.role_arn as |error|}} {{error.message}} {{/each}} {{/if}} - {{else}} - - {{t - 'resources.storage-bucket.form.access_key_id.label' - }} - {{t - 'resources.storage-bucket.form.access_key_id.help' - }} - - - {{t - 'resources.storage-bucket.form.secret_access_key.label' - }} - {{t - 'resources.storage-bucket.form.secret_access_key.help' - }} - + + {{t 'resources.storage-bucket.form.role_external_id.label'}} + + + {{t 'resources.storage-bucket.form.role_external_id.help'}} + + {{#if @model.errors.role_external_id}} + + {{#each @model.errors.role_external_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + + {{t 'resources.storage-bucket.form.role_session_name.label'}} + + + {{t 'resources.storage-bucket.form.role_session_name.help'}} + + {{#if @model.errors.role_session_name}} + + {{#each @model.errors.role_session_name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + + {{t 'resources.storage-bucket.form.role_tags.label'}} + + + {{t 'resources.storage-bucket.form.role_tags.help'}} + + {{#if @model.errors.role_tags}} + + {{#each @model.errors.role_tags as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + {{/if}} - - {{t - 'resources.storage-bucket.form.disable_credential_rotation.label' - }} - - {{t 'resources.storage-bucket.form.disable_credential_rotation.help'}} - - {{t 'actions.learn-more'}} - - - - + {{#unless this.showDynamicCredentials}} + + {{t + 'resources.storage-bucket.form.disable_credential_rotation.label' + }} + + {{t 'resources.storage-bucket.form.disable_credential_rotation.help'}} + + {{t 'actions.learn-more'}} + + + + {{/unless}} {{#if (can 'save model' @model)}} i !== index); + set(this.args.model, field, newArray); + } + updateScopeFieldDescription() { if (this.args.model.scopeModel.isGlobal) { this.scopeFieldDescription = this.intl.t( diff --git a/ui/admin/app/controllers/scopes/scope/storage-buckets.js b/ui/admin/app/controllers/scopes/scope/storage-buckets.js index 249913c40..cf14667ad 100644 --- a/ui/admin/app/controllers/scopes/scope/storage-buckets.js +++ b/ui/admin/app/controllers/scopes/scope/storage-buckets.js @@ -10,6 +10,7 @@ export default class ScopesScopeStorageBucketsController extends Controller { // =services @service intl; + queryParams = ['credentialType']; // = attributes /** diff --git a/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js b/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js index 4206c8b3a..7f26b0f87 100644 --- a/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js +++ b/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js @@ -7,7 +7,6 @@ import Controller from '@ember/controller'; export default class ScopesScopeStorageBucketsStorageBucketController extends Controller { // =attributes - /** * A storage-bucket breadcrumb * @type {string} diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets.js b/ui/admin/app/routes/scopes/scope/storage-buckets.js index 730cb6a26..3d0b4cd66 100644 --- a/ui/admin/app/routes/scopes/scope/storage-buckets.js +++ b/ui/admin/app/routes/scopes/scope/storage-buckets.js @@ -18,6 +18,13 @@ export default class ScopesScopeStorageBucketsRoute extends Route { @service can; @service router; + // =attributes + + queryParams = { + credentialType: { + refreshModel: true, + }, + }; // =methods /** @@ -88,4 +95,13 @@ export default class ScopesScopeStorageBucketsRoute extends Route { async delete(storageBucket) { await storageBucket.destroyRecord(); } + + /** + * Updates type of credential + * @param {string} credentialType + */ + @action + changeType(credentialType) { + this.router.replaceWith({ queryParams: { credentialType } }); + } } diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets/new.js b/ui/admin/app/routes/scopes/scope/storage-buckets/new.js index 080365109..fe8df1d22 100644 --- a/ui/admin/app/routes/scopes/scope/storage-buckets/new.js +++ b/ui/admin/app/routes/scopes/scope/storage-buckets/new.js @@ -19,13 +19,27 @@ export default class ScopesScopeStorageBucketsNewRoute extends Route { /** * Creates a new unsaved storage bucket. + * Also rollback/destroy any new, unsaved instances from this route before + * creating another, but reuse name/description/other fields if available. * @return {StorageBucket} */ model() { const scopeModel = this.store.peekRecord('scope', 'global'); + let name, description, scope, bucket_name, bucket_prefix, region; + if (this.currentModel?.isNew) { + ({ name, description, scope, bucket_name, bucket_prefix, region } = + this.currentModel); + this.currentModel.rollbackAttributes(); + } const record = this.store.createRecord('storage-bucket', { type: TYPE_STORAGE_BUCKET_PLUGIN, compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + name, + description, + scope, + bucket_name, + bucket_prefix, + region, }); record.scopeModel = scopeModel; return record; diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs index 4f8aa93a9..8b2e3d110 100644 --- a/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs @@ -23,6 +23,7 @@ @submit={{route-action 'save' @model}} @cancel={{route-action 'cancel' @model}} @scopes={{this.scopes}} + @changeType={{route-action 'changeType'}} /> diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs index 1ce337527..115fb83a7 100644 --- a/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs @@ -36,6 +36,7 @@ @submit={{route-action 'save' @model}} @cancel={{route-action 'cancel' @model}} @scopes={{this.scopes}} + @changeType={{route-action 'changeType'}} /> \ No newline at end of file diff --git a/ui/admin/tests/acceptance/storage-buckets/create-test.js b/ui/admin/tests/acceptance/storage-buckets/create-test.js index e035bba4a..9b25adbe3 100644 --- a/ui/admin/tests/acceptance/storage-buckets/create-test.js +++ b/ui/admin/tests/acceptance/storage-buckets/create-test.js @@ -26,7 +26,9 @@ module('Acceptance | storage-buckets | create', function (hooks) { const NAME_FIELD_TEXT = 'random string'; const BUCKET_NAME_FIELD_SELECTOR = '[name="bucket_name"]'; const BUCKET_PREFIX_FIELD_SELECTOR = '[name="bucket_prefix"]'; - + const STATIC_CREDENTIAL_SELECTOR = '[value="static"]'; + const DYNAMIC_CREDENTIAL_SELECTOR = '[value="dynamic"]'; + const ROLE_ARN_SELECTOR = '[name="role_arn"]'; const instances = { scopes: { global: null, @@ -105,6 +107,44 @@ module('Acceptance | storage-buckets | create', function (hooks) { assert.strictEqual(getStorageBucketCount(), storageBucketCount + 1); }); + test('users can create a new storage bucket with dynamic credentials', async function (assert) { + assert.expect(3); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.storageBuckets); + + await click(`[href="${urls.newStorageBucket}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + assert.dom('.hds-form-radio-card').exists({ count: 2 }); + + await click(DYNAMIC_CREDENTIAL_SELECTOR); + await fillIn(ROLE_ARN_SELECTOR, 'test-arn-id'); + await click(SAVE_BTN_SELECTOR); + const storageBucket = this.server.schema.storageBuckets.findBy({ + name: NAME_FIELD_TEXT, + }); + assert.strictEqual(storageBucket.name, NAME_FIELD_TEXT); + assert.strictEqual(getStorageBucketCount(), storageBucketCount + 1); + }); + + test('users can create a new storage bucket with static credentials', async function (assert) { + assert.expect(3); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.storageBuckets); + + await click(`[href="${urls.newStorageBucket}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + assert.dom('.hds-form-radio-card').exists({ count: 2 }); + + await click(STATIC_CREDENTIAL_SELECTOR); + await click(SAVE_BTN_SELECTOR); + + const storageBucket = this.server.schema.storageBuckets.findBy({ + name: NAME_FIELD_TEXT, + }); + assert.strictEqual(storageBucket.name, NAME_FIELD_TEXT); + assert.strictEqual(getStorageBucketCount(), storageBucketCount + 1); + }); + test('user can cancel new storage bucket creation', async function (assert) { assert.expect(2); const storageBucketCount = getStorageBucketCount();