diff --git a/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts new file mode 100644 index 00000000000..f8bcbf57a26 --- /dev/null +++ b/packages/destination-actions/src/destinations/aws-kinesis/__test__/index.test.ts @@ -0,0 +1,72 @@ +import destination from '../index' +import { assumeRole } from '../../../lib/AWS/sts' +import { validateIamRoleArnFormat } from '../utils' +import { APP_AWS_REGION } from '../../../lib/AWS/utils' +import type { Settings } from '../generated-types' +import { createTestIntegration } from '../../../../../core/src/create-test-integration' + +// --- Mock all dependencies --- +jest.mock('../../../lib/AWS/sts', () => ({ + assumeRole: jest.fn() +})) + +jest.mock('../utils', () => ({ + validateIamRoleArnFormat: jest.fn() +})) + +jest.mock('@segment/actions-core', () => ({ + IntegrationError: jest.fn().mockImplementation((message, code, status) => ({ + name: 'IntegrationError', + message, + code, + status + })) +})) + +jest.mock('../../../lib/AWS/utils', () => ({ + APP_AWS_REGION: 'us-east-1' +})) + +const testDestination = createTestIntegration(destination) + +describe('AWS Kinesis Destination - testAuthentication', () => { + const validSettings: Settings = { + iamRoleArn: 'arn:aws:iam::123456789012:role/MyRole', + iamExternalId: 'external-id' + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should call assumeRole when IAM Role ARN format is valid', async () => { + ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(true) + ;(assumeRole as jest.Mock).mockResolvedValue({ + accessKeyId: 'AKIA...', + secretAccessKey: 'SECRET...', + sessionToken: 'TOKEN...' + }) + + await expect(testDestination.testAuthentication(validSettings)).resolves.not.toThrow() + + expect(validateIamRoleArnFormat).toHaveBeenCalledWith(validSettings.iamRoleArn) + expect(assumeRole).toHaveBeenCalledWith(validSettings.iamRoleArn, validSettings.iamExternalId, APP_AWS_REGION) + }) + + it('should throw IntegrationError if IAM Role ARN format is invalid', async () => { + ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(false) + + const result = testDestination.testAuthentication(validSettings) + + await expect(result).rejects.toThrow('Credentials are invalid: The provided IAM Role ARN format is not valid') + + expect(assumeRole).not.toHaveBeenCalled() + }) + + it('should propagate errors from assumeRole', async () => { + ;(validateIamRoleArnFormat as jest.Mock).mockReturnValue(true) + ;(assumeRole as jest.Mock).mockRejectedValue(new Error('AssumeRole failed')) + + await expect(testDestination.testAuthentication(validSettings)).rejects.toThrow('AssumeRole failed') + }) +}) diff --git a/packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts b/packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts new file mode 100644 index 00000000000..f1d85f6df6a --- /dev/null +++ b/packages/destination-actions/src/destinations/aws-kinesis/__test__/utils.test.ts @@ -0,0 +1,56 @@ +import { validateIamRoleArnFormat } from '../utils' + +describe('validateIamRoleArnFormat', () => { + it('should return true for a valid IAM Role ARN', () => { + const validArns = [ + 'arn:aws:iam::123456789012:role/MyRole', + 'arn:aws:iam::000000000000:role/service-role/My-Service_Role', + 'arn:aws:iam::987654321098:role/path/to/MyRole', + 'arn:aws:iam::111122223333:role/MyRole-With.Special@Chars_+=,.' + ] + + for (const arn of validArns) { + expect(validateIamRoleArnFormat(arn)).toBe(true) + } + }) + + it('should return false for an ARN with invalid prefix', () => { + const invalidArn = 'arn:aws:s3::123456789012:role/MyRole' + expect(validateIamRoleArnFormat(invalidArn)).toBe(false) + }) + + it('should return false if missing account ID', () => { + const invalidArn = 'arn:aws:iam:::role/MyRole' + expect(validateIamRoleArnFormat(invalidArn)).toBe(false) + }) + + it('should return false if account ID is not 12 digits', () => { + const invalidArns = ['arn:aws:iam::12345:role/MyRole', 'arn:aws:iam::1234567890123:role/MyRole'] + for (const arn of invalidArns) { + expect(validateIamRoleArnFormat(arn)).toBe(false) + } + }) + + it('should return false if missing "role/" segment', () => { + const invalidArn = 'arn:aws:iam::123456789012:MyRole' + expect(validateIamRoleArnFormat(invalidArn)).toBe(false) + }) + + it('should return false if role name contains invalid characters', () => { + const invalidArns = [ + 'arn:aws:iam::123456789012:role/My Role', // space + 'arn:aws:iam::123456789012:role/MyRole#InvalidChar' + ] + for (const arn of invalidArns) { + expect(validateIamRoleArnFormat(arn)).toBe(false) + } + }) + + it('should return false for empty or null values', () => { + expect(validateIamRoleArnFormat('')).toBe(false) + // @ts-expect-error testing invalid input type + expect(validateIamRoleArnFormat(null)).toBe(false) + // @ts-expect-error testing invalid input type + expect(validateIamRoleArnFormat(undefined)).toBe(false) + }) +}) diff --git a/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts b/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts index 4ab2786ec60..21b6b7e5bf0 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/generated-types.ts @@ -1,3 +1,12 @@ // Generated file. DO NOT MODIFY IT BY HAND. -export interface Settings {} +export interface Settings { + /** + * The ARN of the IAM Role to assume for sending data to Kinesis. + */ + iamRoleArn: string + /** + * The external ID to use when assuming the IAM Role. Generate a secure string and treat it like a password. This is often used as an additional security measure. + */ + iamExternalId: string +} diff --git a/packages/destination-actions/src/destinations/aws-kinesis/index.ts b/packages/destination-actions/src/destinations/aws-kinesis/index.ts index f6ef61f86aa..1641ea1979f 100644 --- a/packages/destination-actions/src/destinations/aws-kinesis/index.ts +++ b/packages/destination-actions/src/destinations/aws-kinesis/index.ts @@ -1,5 +1,9 @@ import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' +import { IntegrationError } from '@segment/actions-core' +import { assumeRole } from '../../lib/AWS/sts' +import { validateIamRoleArnFormat } from './utils' +import { APP_AWS_REGION } from '../../lib/AWS/utils' import send from './send' @@ -10,18 +14,29 @@ const destination: DestinationDefinition = { authentication: { scheme: 'custom', - fields: {}, - testAuthentication: (_) => { - // Return a request that tests/validates the user's credentials. - // If you do not have a way to validate the authentication fields safely, - // you can remove the `testAuthentication` function, though discouraged. - } - }, + fields: { + iamRoleArn: { + label: 'IAM Role ARN', + description: 'The ARN of the IAM Role to assume for sending data to Kinesis.', + type: 'string', + required: true + }, + iamExternalId: { + label: 'IAM External ID', + description: + 'The external ID to use when assuming the IAM Role. Generate a secure string and treat it like a password. This is often used as an additional security measure.', + type: 'password', + required: true + } + }, + testAuthentication: async (_, { settings }) => { + const { iamRoleArn, iamExternalId } = settings + if (!validateIamRoleArnFormat(iamRoleArn)) { + throw new IntegrationError('The provided IAM Role ARN format is not valid', 'INVALID_IAM_ROLE_ARN_FORMAT', 400) + } - onDelete: async (_, _1) => { - // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId - // provided in the payload. If your destination does not support GDPR deletion you should not - // implement this function and should remove it completely. + await assumeRole(iamRoleArn, iamExternalId, APP_AWS_REGION) + } }, actions: { diff --git a/packages/destination-actions/src/destinations/aws-kinesis/utils.ts b/packages/destination-actions/src/destinations/aws-kinesis/utils.ts new file mode 100644 index 00000000000..0a5d8006874 --- /dev/null +++ b/packages/destination-actions/src/destinations/aws-kinesis/utils.ts @@ -0,0 +1,4 @@ +export const validateIamRoleArnFormat = (arn: string): boolean => { + const iamRoleArnRegex = /^arn:aws:iam::\d{12}:role\/[A-Za-z0-9+=,.@_\-/]+$/ + return iamRoleArnRegex.test(arn) +} diff --git a/packages/destination-actions/src/lib/AWS/__test__/index.test.ts b/packages/destination-actions/src/lib/AWS/__test__/index.test.ts new file mode 100644 index 00000000000..0b1cb7009ff --- /dev/null +++ b/packages/destination-actions/src/lib/AWS/__test__/index.test.ts @@ -0,0 +1,107 @@ +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts' +import { assumeRole } from '../sts' +import { ErrorCodes } from '@segment/actions-core' + +// Mock dependencies +jest.mock('@aws-sdk/client-sts') +jest.mock('uuid', () => ({ v4: jest.fn(() => 'mocked-session-id') })) +jest.mock('@segment/actions-core', () => ({ + IntegrationError: jest.fn().mockImplementation((message, code, status) => ({ + name: 'IntegrationError', + message, + code, + status + })), + ErrorCodes: { INVALID_AUTHENTICATION: 'INVALID_AUTHENTICATION' } +})) + +describe('assumeRole', () => { + const mockSend = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(STSClient as jest.Mock).mockImplementation(() => ({ + send: mockSend + })) + process.env.AMAZON_S3_ACTIONS_ROLE_ADDRESS = 'arn:aws:iam::111111111111:role/IntermediaryRole' + process.env.AMAZON_S3_ACTIONS_EXTERNAL_ID = 'intermediary-external-id' + }) + + it('should successfully assume both intermediary and target roles', async () => { + // Mock intermediary STS credentials + mockSend + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_INTERMEDIARY', + SecretAccessKey: 'SECRET_INTERMEDIARY', + SessionToken: 'TOKEN_INTERMEDIARY' + } + }) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_FINAL', + SecretAccessKey: 'SECRET_FINAL', + SessionToken: 'TOKEN_FINAL' + } + }) + + const creds = await assumeRole('arn:aws:iam::222222222222:role/TargetRole', 'external-id-final', 'us-east-1') + + expect(STSClient).toHaveBeenCalledTimes(2) + expect(AssumeRoleCommand).toHaveBeenCalledTimes(2) + + expect(creds).toEqual({ + accessKeyId: 'AKIA_FINAL', + secretAccessKey: 'SECRET_FINAL', + sessionToken: 'TOKEN_FINAL' + }) + }) + + it('should throw IntegrationError if STS returns missing credentials', async () => { + mockSend.mockResolvedValueOnce({ + Credentials: { AccessKeyId: 'X' } // missing fields + }) + + const promise = assumeRole('arn:aws:iam::222222222222:role/TargetRole', 'external-id-final', 'us-east-1') + + await expect(promise).rejects.toMatchObject({ + name: 'IntegrationError', + message: 'Failed to assume role', + code: ErrorCodes.INVALID_AUTHENTICATION, + status: 403 + }) + }) + + it('should pass intermediary credentials to second STS call', async () => { + mockSend + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_INTER', + SecretAccessKey: 'SECRET_INTER', + SessionToken: 'TOKEN_INTER' + } + }) + .mockResolvedValueOnce({ + Credentials: { + AccessKeyId: 'AKIA_FINAL', + SecretAccessKey: 'SECRET_FINAL', + SessionToken: 'TOKEN_FINAL' + } + }) + + await assumeRole('arn:aws:iam::333333333333:role/TargetRole', 'external-id-final', 'us-west-2') + + // Check that STSClient was initialized with the intermediary creds for the second call + expect(STSClient).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + credentials: { + accessKeyId: 'AKIA_INTER', + secretAccessKey: 'SECRET_INTER', + sessionToken: 'TOKEN_INTER' + }, + region: 'us-west-2' + }) + ) + }) +}) diff --git a/packages/destination-actions/src/lib/AWS/sts.ts b/packages/destination-actions/src/lib/AWS/sts.ts index 74dd0df60a0..e4315d3de83 100644 --- a/packages/destination-actions/src/lib/AWS/sts.ts +++ b/packages/destination-actions/src/lib/AWS/sts.ts @@ -1,5 +1,8 @@ import { readFileSync } from 'fs' import { RequestClient } from '@segment/actions-core' +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts' +import { IntegrationError, ErrorCodes } from '@segment/actions-core' +import { v4 as uuidv4 } from '@lukeed/uuid' export type AWSCredentials = { accessKeyId: string @@ -128,3 +131,36 @@ export async function getAWSCredentialsFromEKS(request: RequestClient): Promise< return credentials } + +export const assumeRole = async (roleArn: string, externalId: string, region: string): Promise => { + const intermediaryARN = process.env.AMAZON_S3_ACTIONS_ROLE_ADDRESS as string + const intermediaryExternalId = process.env.AMAZON_S3_ACTIONS_EXTERNAL_ID as string + const intermediaryCreds = await getSTSCredentials(intermediaryARN, intermediaryExternalId, region) + return getSTSCredentials(roleArn, externalId, region, intermediaryCreds) +} + +const getSTSCredentials = async (roleId: string, externalId: string, region: string, credentials?: AWSCredentials) => { + const options = { credentials, region: region } + const stsClient = new STSClient(options) + const roleSessionName: string = uuidv4() + const command = new AssumeRoleCommand({ + RoleArn: roleId, + RoleSessionName: roleSessionName, + ExternalId: externalId + }) + const result = await stsClient.send(command) + if ( + !result.Credentials || + !result.Credentials.AccessKeyId || + !result.Credentials.SecretAccessKey || + !result.Credentials.SessionToken + ) { + throw new IntegrationError('Failed to assume role', ErrorCodes.INVALID_AUTHENTICATION, 403) + } + + return { + accessKeyId: result.Credentials.AccessKeyId, + secretAccessKey: result.Credentials.SecretAccessKey, + sessionToken: result.Credentials.SessionToken + } +} diff --git a/packages/destination-actions/src/lib/AWS/utils.ts b/packages/destination-actions/src/lib/AWS/utils.ts new file mode 100644 index 00000000000..5492b40f1c8 --- /dev/null +++ b/packages/destination-actions/src/lib/AWS/utils.ts @@ -0,0 +1 @@ +export const APP_AWS_REGION = process.env['AWS_REGION'] || `us-west-2`