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();