feat: 🎸 add support for dynamic credentials in storage bucket (#1897)

* feat: 🎸 add support for dynamic credentials in storage bucket
This commit is contained in:
Dharini Jeeva authored and GitHub committed 2023-09-18 13:26:38 -07:00
1 parent 3cb8417a89
commit 4910070c95
13 files changed
+420 -96

No files matched your search

+21
View File
@@ -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}
@@ -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;
@@ -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');
@@ -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
@@ -176,93 +176,220 @@
{{/if}}
</Hds::Form::TextInput::Field>
{{#if @model.isNew}}
<Hds::Form::TextInput::Field
@isRequired={{true}}
@value={{@model.access_key_id}}
@isInvalid={{@model.errors.secrets}}
@type='password'
name='access_key_id'
{{on 'input' (set-from-event @model 'access_key_id')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.access_key_id.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.access_key_id.help'
}}</F.HelperText>
{{#if @model.errors.secrets}}
<F.Error as |E|>
{{#each @model.errors.secrets as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::RadioCard::Group @name='credential_type' as |G|>
<G.Legend>{{t 'resources.storage-bucket.types.credential'}}</G.Legend>
{{#each this.credentials as |credentialType|}}
<G.RadioCard
@name={{credentialType}}
@value={{credentialType}}
@checked={{eq credentialType this.selectedCredentialType}}
@disabled={{form.disabled}}
{{on 'input' (fn this.updateTypeSelection credentialType)}}
as |R|
>
<R.Label>{{t
(concat 'resources.storage-bucket.types.' credentialType '.title')
}}</R.Label>
<R.Description>{{t
(concat
'resources.storage-bucket.types.' credentialType '.description'
)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{#unless this.showDynamicCredentials}}
{{#if @model.isNew}}
<Hds::Form::TextInput::Field
@isRequired={{true}}
@value={{@model.access_key_id}}
@isInvalid={{@model.errors.secrets}}
@type='password'
name='access_key_id'
{{on 'input' (set-from-event @model 'access_key_id')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.access_key_id.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.access_key_id.help'
}}</F.HelperText>
{{#if @model.errors.secrets}}
<F.Error as |E|>
{{#each @model.errors.secrets as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::TextInput::Field
@isRequired={{true}}
@value={{@model.secret_access_key}}
@isInvalid={{@model.errors.secrets}}
@type='password'
name='secret_access_key'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'secret_access_key')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.secret_access_key.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.secret_access_key.help'
}}</F.HelperText>
{{#if @model.errors.secrets}}
<F.Error as |E|>
{{#each @model.errors.secrets as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{else}}
<Form::Field::SecretInput
@isRequired={{true}}
@value={{@model.access_key_id}}
@isInvalid={{@model.errors.access_key_id}}
@name='access_key_id'
@isDisabled={{form.disabled}}
@showEditButton={{true}}
@cancel={{fn this.rollbackSecretAttrs 'access_key_id'}}
{{on 'input' (set-from-event @model 'access_key_id')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.access_key_id.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.access_key_id.help'
}}</F.HelperText>
</Form::Field::SecretInput>
<Form::Field::SecretInput
@isRequired={{true}}
@value={{@model.secret_access_key}}
@isInvalid={{@model.errors.secret_access_key}}
@name='secret_access_key'
@isDisabled={{form.disabled}}
@showEditButton={{true}}
@cancel={{fn this.rollbackSecretAttrs 'secret_access_key'}}
{{on 'input' (set-from-event @model 'secret_access_key')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.secret_access_key.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.secret_access_key.help'
}}</F.HelperText>
</Form::Field::SecretInput>
{{/if}}
{{/unless}}
{{#if this.showDynamicCredentials}}
<Hds::Form::TextInput::Field
@isRequired={{true}}
@value={{@model.secret_access_key}}
@isInvalid={{@model.errors.secrets}}
@type='password'
name='secret_access_key'
@value={{@model.role_arn}}
@isInvalid={{@model.errors.role_arn}}
@type='text'
name='role_arn'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'secret_access_key')}}
{{on 'input' (set-from-event @model 'role_arn')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.secret_access_key.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.secret_access_key.help'
}}</F.HelperText>
{{#if @model.errors.secrets}}
<F.Label>{{t 'resources.storage-bucket.form.role_arn.label'}}</F.Label>
<F.HelperText>
{{t 'resources.storage-bucket.form.role_arn.help'}}
</F.HelperText>
{{#if @model.errors.role_arn}}
<F.Error as |E|>
{{#each @model.errors.secrets as |error|}}
{{#each @model.errors.role_arn as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{else}}
<Form::Field::SecretInput
@isRequired={{true}}
@value={{@model.access_key_id}}
@isInvalid={{@model.errors.access_key_id}}
@name='access_key_id'
@isDisabled={{form.disabled}}
@showEditButton={{true}}
@cancel={{fn this.rollbackSecretAttrs 'access_key_id'}}
{{on 'input' (set-from-event @model 'access_key_id')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.access_key_id.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.access_key_id.help'
}}</F.HelperText>
</Form::Field::SecretInput>
<Form::Field::SecretInput
@isRequired={{true}}
@value={{@model.secret_access_key}}
@isInvalid={{@model.errors.secret_access_key}}
@name='secret_access_key'
@isDisabled={{form.disabled}}
@showEditButton={{true}}
@cancel={{fn this.rollbackSecretAttrs 'secret_access_key'}}
{{on 'input' (set-from-event @model 'secret_access_key')}}
<Hds::Form::TextInput::Field
@isOptional={{true}}
@value={{@model.role_external_id}}
@isInvalid={{@model.errors.role_external_id}}
@type='text'
name='role_external_id'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'role_external_id')}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.secret_access_key.label'
}}</F.Label>
<F.HelperText>{{t
'resources.storage-bucket.form.secret_access_key.help'
}}</F.HelperText>
</Form::Field::SecretInput>
<F.Label>
{{t 'resources.storage-bucket.form.role_external_id.label'}}
</F.Label>
<F.HelperText>
{{t 'resources.storage-bucket.form.role_external_id.help'}}
</F.HelperText>
{{#if @model.errors.role_external_id}}
<F.Error as |E|>
{{#each @model.errors.role_external_id as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::TextInput::Field
@isOptional={{true}}
@value={{@model.role_session_name}}
@isInvalid={{@model.errors.role_session_name}}
@type='text'
name='role_session_name'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'role_session_name')}}
as |F|
>
<F.Label>
{{t 'resources.storage-bucket.form.role_session_name.label'}}
</F.Label>
<F.HelperText>
{{t 'resources.storage-bucket.form.role_session_name.help'}}
</F.HelperText>
{{#if @model.errors.role_session_name}}
<F.Error as |E|>
{{#each @model.errors.role_session_name as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Form::Field::KeyValueList
@layout='horizontal'
@isOptional={{true}}
@name='role_tags'
@options={{@model.role_tags}}
@addOption={{fn this.addOption 'role_tags'}}
@removeOptionByIndex={{fn this.removeOptionByIndex 'role_tags'}}
@disabled={{form.disabled}}
as |M|
>
<M.Legend>
{{t 'resources.storage-bucket.form.role_tags.label'}}
</M.Legend>
<M.HelperText>
{{t 'resources.storage-bucket.form.role_tags.help'}}
</M.HelperText>
{{#if @model.errors.role_tags}}
<M.Error as |E|>
{{#each @model.errors.role_tags as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</M.Error>
{{/if}}
</Form::Field::KeyValueList>
{{/if}}
<Hds::Form::TextInput::Field
@@ -291,26 +418,27 @@
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::Checkbox::Field
name='disable_credential_rotation'
disabled={{form.disabled}}
checked={{@model.disable_credential_rotation}}
{{on 'change' this.toggleDisableCredentialRotation}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.disable_credential_rotation.label'
}}</F.Label>
<F.HelperText>
{{t 'resources.storage-bucket.form.disable_credential_rotation.help'}}
<Hds::Link::Inline
@href={{doc-url 'storage-bucket.disable-credential-rotation'}}
>
{{t 'actions.learn-more'}}
</Hds::Link::Inline>
</F.HelperText>
</Hds::Form::Checkbox::Field>
{{#unless this.showDynamicCredentials}}
<Hds::Form::Checkbox::Field
name='disable_credential_rotation'
disabled={{form.disabled}}
checked={{@model.disable_credential_rotation}}
{{on 'change' this.toggleDisableCredentialRotation}}
as |F|
>
<F.Label>{{t
'resources.storage-bucket.form.disable_credential_rotation.label'
}}</F.Label>
<F.HelperText>
{{t 'resources.storage-bucket.form.disable_credential_rotation.help'}}
<Hds::Link::Inline
@href={{doc-url 'storage-bucket.disable-credential-rotation'}}
>
{{t 'actions.learn-more'}}
</Hds::Link::Inline>
</F.HelperText>
</Hds::Form::Checkbox::Field>
{{/unless}}
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@@ -9,14 +9,74 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { set } from '@ember/object';
import {
TYPES_CREDENTIALS,
TYPE_CREDENTIAL_DYNAMIC,
} from 'api/models/storage-bucket';
export default class FormStorageBucketComponent extends Component {
// =actions
// =attributes
@service intl;
@tracked scopeFieldDescription = this.intl.t(
'resources.storage-bucket.form.scope.help'
);
@tracked selectedCredentialType = this.args.model.credentialType;
@tracked showDynamicCredentials =
this.args.model.credentialType === TYPE_CREDENTIAL_DYNAMIC;
/**
* returns an array of available credential types
* @type {object}
*/
get credentials() {
return TYPES_CREDENTIALS;
}
// =actions
/**
* Allows to update the credential type
* @param type {string}
*/
@action
updateTypeSelection(type) {
this.selectedCredentialType = type;
if (type === TYPE_CREDENTIAL_DYNAMIC) {
this.showDynamicCredentials = true;
} else {
this.showDynamicCredentials = false;
}
this.args.changeType(type);
}
/**
* Adds a key/value option object. We recreate a new array after adding
* so that ember is aware that the array has been modified.
* @param field {string}
* @param key {string}
* @param value {string}
*/
@action
addOption(field, { key, value }) {
const existingArray = this.args.model[field] ?? [];
const newArray = [...existingArray, { key, value }];
set(this.args.model, field, newArray);
}
/**
* Removes an option by index. We recreate a new array after
* splicing out the item so that ember is aware that the array has been modified.
* @param field {string}
* @param index {number}
*/
@action
removeOptionByIndex(field, index) {
const newArray = this.args.model[field].filter((_, i) => i !== index);
set(this.args.model, field, newArray);
}
updateScopeFieldDescription() {
if (this.args.model.scopeModel.isGlobal) {
this.scopeFieldDescription = this.intl.t(
@@ -10,6 +10,7 @@ export default class ScopesScopeStorageBucketsController extends Controller {
// =services
@service intl;
queryParams = ['credentialType'];
// = attributes
/**
@@ -7,7 +7,6 @@ import Controller from '@ember/controller';
export default class ScopesScopeStorageBucketsStorageBucketController extends Controller {
// =attributes
/**
* A storage-bucket breadcrumb
* @type {string}
@@ -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 } });
}
}
@@ -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;
@@ -23,6 +23,7 @@
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
@scopes={{this.scopes}}
@changeType={{route-action 'changeType'}}
/>
</page.body>
@@ -36,6 +36,7 @@
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
@scopes={{this.scopes}}
@changeType={{route-action 'changeType'}}
/>
</page.body>
</Rose::Layout::Page>
@@ -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();