diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 28842532ed..311d9cb9b8 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -2,7 +2,7 @@ import { BlockAuth, Property } from '@openops/blocks-framework'; import { SharedSystemProp, system } from '@openops/server-shared'; import { parseArn } from './arn-handler'; -import { assumeRole } from './sts-common'; +import { assumeRole, getAccountId } from './sts-common'; const isImplicitRoleEnabled = system.getBoolean( SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE, @@ -45,6 +45,7 @@ export async function getCredentialsFromAuth( auth.defaultRegion, auth.assumeRoleArn, auth.assumeRoleExternalId, + auth.endpoint, ); return { @@ -94,6 +95,7 @@ export async function getCredentialsListFromAuth( auth.defaultRegion, role.assumeRoleArn, role.assumeRoleExternalId, + auth.endpoint, ), ); @@ -166,6 +168,115 @@ export function getAwsAccountsSingleSelectDropdown() { return createAwsAccountsDropdown(false); } +const ROLE_VALIDATION_BATCH_SIZE = 5; + +type ValidationResult = { valid: true } | { valid: false; error: string }; + +function extractErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +function formatRoleValidationError(role: Role, errorMessage: string): string { + return `role "${role.assumeRoleArn}" (${role.accountName}): ${errorMessage}`; +} + +async function validateRoleBatch( + roles: Role[], + accessKeyId: string, + secretAccessKey: string, + defaultRegion: string, + endpoint?: string | undefined | null, +): Promise { + const results = await Promise.allSettled( + roles.map((role) => + assumeRole( + accessKeyId, + secretAccessKey, + defaultRegion, + role.assumeRoleArn, + role.assumeRoleExternalId, + endpoint, + ), + ), + ); + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'rejected') { + const role = roles[i]; + const errorMessage = extractErrorMessage(result.reason); + return { + valid: false, + error: formatRoleValidationError(role, errorMessage), + }; + } + } + + return { valid: true }; +} + +async function validateRequiredFields(auth: any): Promise { + if (!auth.defaultRegion) { + return { valid: false, error: 'Default region is required' }; + } + + const hasCredentials = auth.accessKeyId && auth.secretAccessKey; + if (!hasCredentials && !isImplicitRoleEnabled) { + return { + valid: false, + error: 'Access Key ID and Secret Access Key are required', + }; + } + + return { valid: true }; +} + +async function validateBaseCredentials(auth: any): Promise { + try { + const credentials = { + accessKeyId: auth.accessKeyId || '', + secretAccessKey: auth.secretAccessKey || '', + endpoint: auth.endpoint, + }; + await getAccountId(credentials, auth.defaultRegion); + return { valid: true }; + } catch (error) { + const errorMessage = extractErrorMessage(error); + return { + valid: false, + error: errorMessage, + }; + } +} + +async function validateRoleAssumptions(auth: any): Promise { + if (!auth.roles || auth.roles.length === 0) { + return { valid: true }; + } + + const accessKeyId = auth.accessKeyId || ''; + const secretAccessKey = auth.secretAccessKey || ''; + const roles = auth.roles as Role[]; + + for (let i = 0; i < roles.length; i += ROLE_VALIDATION_BATCH_SIZE) { + const batch = roles.slice(i, i + ROLE_VALIDATION_BATCH_SIZE); + + const result = await validateRoleBatch( + batch, + accessKeyId, + secretAccessKey, + auth.defaultRegion, + auth.endpoint, + ); + + if (!result.valid) { + return result; + } + } + + return { valid: true }; +} + export const amazonAuth = BlockAuth.CustomAuth({ authProviderKey: 'AWS', authProviderDisplayName: 'AWS', @@ -229,16 +340,19 @@ For large or complex setups, enhanced features are available, including: }, required: true, validate: async ({ auth }) => { - if (!auth.defaultRegion) { - return { valid: false, error: 'Default region is required' }; + const fieldValidation = await validateRequiredFields(auth); + if (!fieldValidation.valid) { + return fieldValidation; } - if (!auth.accessKeyId && !isImplicitRoleEnabled) { - return { valid: false, error: 'Access Key ID is required' }; + const baseCredentialsValidation = await validateBaseCredentials(auth); + if (!baseCredentialsValidation.valid) { + return baseCredentialsValidation; } - if (!auth.secretAccessKey && !isImplicitRoleEnabled) { - return { valid: false, error: 'Secret Access Key is required' }; + const roleValidation = await validateRoleAssumptions(auth); + if (!roleValidation.valid) { + return roleValidation; } return { valid: true }; diff --git a/packages/openops/src/lib/aws/sts-common.ts b/packages/openops/src/lib/aws/sts-common.ts index ad937d90fb..2ec646b327 100644 --- a/packages/openops/src/lib/aws/sts-common.ts +++ b/packages/openops/src/lib/aws/sts-common.ts @@ -24,10 +24,11 @@ export async function assumeRole( defaultRegion: string, roleArn: string, externalId?: string, + endpoint?: string | undefined | null, ): Promise { const client = getAwsClient( STSClient, - { accessKeyId, secretAccessKey }, + { accessKeyId, secretAccessKey, endpoint }, defaultRegion, ); const command = new AssumeRoleCommand({ diff --git a/packages/openops/test/auth.test.ts b/packages/openops/test/auth.test.ts index 3fb3617e4c..d6aea69c3f 100644 --- a/packages/openops/test/auth.test.ts +++ b/packages/openops/test/auth.test.ts @@ -74,6 +74,7 @@ describe('getCredentialsFromAuth tests', () => { 'some region', 'some role', 'some external id', + undefined, ); }); @@ -225,6 +226,7 @@ describe('getCredentialsListFromAuth tests', () => { 'region', 'arn:aws:iam::2:user/roleName2', 'externalId2', + undefined, ); }); @@ -334,6 +336,7 @@ describe('getCredentialsForAccount tests', () => { 'region', 'arn:aws:iam::2:user/roleName2', 'externalId2', + undefined, ); }); diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts new file mode 100644 index 0000000000..6bc8103f6a --- /dev/null +++ b/packages/openops/test/aws/auth.test.ts @@ -0,0 +1,417 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const mockGetAccountId = jest.fn(); +const mockAssumeRole = jest.fn(); + +jest.mock('../../src/lib/aws/sts-common', () => ({ + getAccountId: mockGetAccountId, + assumeRole: mockAssumeRole, +})); + +const mockSystem = { + getBoolean: jest.fn().mockReturnValue(false), +}; + +jest.mock('@openops/server-shared', () => ({ + system: mockSystem, + SharedSystemProp: { + AWS_ENABLE_IMPLICIT_ROLE: 'AWS_ENABLE_IMPLICIT_ROLE', + ENABLE_HOST_SESSION: 'ENABLE_HOST_SESSION', + }, +})); + +import { amazonAuth } from '../../src/lib/aws/auth'; + +const EXAMPLE_ACCESS_KEY = 'AKIAIOSFODNN7EXAMPLE'; +const EXAMPLE_SECRET_KEY = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; +const DEFAULT_REGION = 'us-east-1'; +const LOCALSTACK_ENDPOINT = 'http://localhost:4566'; + +function createAuthObject(overrides: any = {}) { + return { + defaultRegion: DEFAULT_REGION, + accessKeyId: EXAMPLE_ACCESS_KEY, + secretAccessKey: EXAMPLE_SECRET_KEY, + ...overrides, + }; +} + +function createRole( + arnSuffix: string, + accountName: string, + externalId?: string, +) { + return { + assumeRoleArn: `arn:aws:iam::${arnSuffix}:role/${accountName}Role`, + accountName, + ...(externalId && { assumeRoleExternalId: externalId }), + }; +} + +function mockSuccessfulAssumeRole() { + mockAssumeRole.mockResolvedValue({ + AccessKeyId: 'ASIATEMP', + SecretAccessKey: 'tempSecret', + SessionToken: 'tempToken', + }); +} + +function mockSuccessfulAccountId() { + mockGetAccountId.mockResolvedValue('123456789012'); +} + +async function reimportAuthWithImplicitRole() { + mockSystem.getBoolean.mockReturnValue(true); + jest.resetModules(); + mockSystem.getBoolean.mockReturnValue(true); + const { amazonAuth: freshAmazonAuth } = await import( + '../../src/lib/aws/auth' + ); + return freshAmazonAuth; +} + +describe('AWS Auth Validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSystem.getBoolean.mockReturnValue(false); + }); + + describe('Field validation', () => { + test('should fail when defaultRegion is missing', async () => { + const result = await amazonAuth.validate!({ + auth: { + accessKeyId: EXAMPLE_ACCESS_KEY, + secretAccessKey: EXAMPLE_SECRET_KEY, + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Default region is required', + }); + }); + + test('should fail when accessKeyId is missing and implicit role disabled', async () => { + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + secretAccessKey: EXAMPLE_SECRET_KEY, + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Access Key ID and Secret Access Key are required', + }); + }); + + test('should fail when secretAccessKey is missing and implicit role disabled', async () => { + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + accessKeyId: EXAMPLE_ACCESS_KEY, + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Access Key ID and Secret Access Key are required', + }); + }); + }); + + describe('Base credentials validation', () => { + test('should validate successfully with correct base credentials', async () => { + mockSuccessfulAccountId(); + + const result = await amazonAuth.validate!({ + auth: createAuthObject(), + }); + + expect(result).toEqual({ valid: true }); + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: EXAMPLE_ACCESS_KEY, + secretAccessKey: EXAMPLE_SECRET_KEY, + endpoint: undefined, + }, + DEFAULT_REGION, + ); + }); + + test('should fail with invalid base credentials', async () => { + mockGetAccountId.mockRejectedValue( + new Error('The security token included in the request is invalid'), + ); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ + accessKeyId: 'INVALID_KEY', + secretAccessKey: 'INVALID_SECRET', + }), + }); + + expect(result).toEqual({ + valid: false, + error: 'The security token included in the request is invalid', + }); + }); + + test('should pass endpoint to getAccountId when provided', async () => { + mockSuccessfulAccountId(); + + await amazonAuth.validate!({ + auth: createAuthObject({ endpoint: LOCALSTACK_ENDPOINT }), + }); + + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: EXAMPLE_ACCESS_KEY, + secretAccessKey: EXAMPLE_SECRET_KEY, + endpoint: LOCALSTACK_ENDPOINT, + }, + DEFAULT_REGION, + ); + }); + }); + + describe('Implicit role validation', () => { + test('should validate with GetCallerIdentity when implicit role enabled and no credentials', async () => { + mockSuccessfulAccountId(); + const freshAmazonAuth = await reimportAuthWithImplicitRole(); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + } as any, + }); + + expect(result).toEqual({ valid: true }); + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: '', + secretAccessKey: '', + endpoint: undefined, + }, + DEFAULT_REGION, + ); + }); + + test('should fail when implicit role validation fails', async () => { + mockGetAccountId.mockRejectedValue( + new Error('Unable to locate credentials'), + ); + const freshAmazonAuth = await reimportAuthWithImplicitRole(); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Unable to locate credentials', + }); + }); + }); + + describe('Role validation', () => { + test('should validate all roles successfully', async () => { + mockSuccessfulAccountId(); + mockSuccessfulAssumeRole(); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ + roles: [ + createRole('111111111111', 'Production'), + createRole('222222222222', 'Staging', 'external123'), + ], + }), + }); + + expect(result).toEqual({ valid: true }); + expect(mockAssumeRole).toHaveBeenCalledTimes(2); + expect(mockAssumeRole).toHaveBeenNthCalledWith( + 1, + EXAMPLE_ACCESS_KEY, + EXAMPLE_SECRET_KEY, + DEFAULT_REGION, + 'arn:aws:iam::111111111111:role/ProductionRole', + undefined, + undefined, + ); + expect(mockAssumeRole).toHaveBeenNthCalledWith( + 2, + EXAMPLE_ACCESS_KEY, + EXAMPLE_SECRET_KEY, + DEFAULT_REGION, + 'arn:aws:iam::222222222222:role/StagingRole', + 'external123', + undefined, + ); + }); + + test('should pass endpoint to assumeRole when provided', async () => { + mockSuccessfulAccountId(); + mockSuccessfulAssumeRole(); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ + endpoint: LOCALSTACK_ENDPOINT, + roles: [createRole('111111111111', 'Production')], + }), + }); + + expect(result).toEqual({ valid: true }); + expect(mockAssumeRole).toHaveBeenCalledWith( + EXAMPLE_ACCESS_KEY, + EXAMPLE_SECRET_KEY, + DEFAULT_REGION, + 'arn:aws:iam::111111111111:role/ProductionRole', + undefined, + LOCALSTACK_ENDPOINT, + ); + }); + + test('should fail when first role validation fails', async () => { + mockSuccessfulAccountId(); + mockAssumeRole.mockRejectedValue( + new Error( + 'User: arn:aws:iam::123456789012:user/ops is not authorized to perform: sts:AssumeRole', + ), + ); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ + roles: [createRole('111111111111', 'Production')], + }), + }); + + expect(result).toEqual({ + valid: false, + error: + 'role "arn:aws:iam::111111111111:role/ProductionRole" (Production): User: arn:aws:iam::123456789012:user/ops is not authorized to perform: sts:AssumeRole', + }); + }); + + test('should fail on second role when first succeeds but second fails', async () => { + mockSuccessfulAccountId(); + mockAssumeRole + .mockResolvedValueOnce({ + AccessKeyId: 'ASIATEMP', + SecretAccessKey: 'tempSecret', + SessionToken: 'tempToken', + }) + .mockRejectedValueOnce(new Error('External ID mismatch')); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ + roles: [ + createRole('111111111111', 'Production'), + createRole('222222222222', 'Staging', 'wrong-external-id'), + ], + }), + }); + + expect(result).toEqual({ + valid: false, + error: + 'role "arn:aws:iam::222222222222:role/StagingRole" (Staging): External ID mismatch', + }); + expect(mockAssumeRole).toHaveBeenCalledTimes(2); + }); + + test('should validate roles using implicit role credentials when no explicit credentials provided', async () => { + mockSuccessfulAccountId(); + mockSuccessfulAssumeRole(); + const freshAmazonAuth = await reimportAuthWithImplicitRole(); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + roles: [createRole('111111111111', 'Production')], + } as any, + }); + + expect(result).toEqual({ valid: true }); + expect(mockAssumeRole).toHaveBeenCalledWith( + '', + '', + DEFAULT_REGION, + 'arn:aws:iam::111111111111:role/ProductionRole', + undefined, + undefined, + ); + }); + }); + + describe('Error handling', () => { + test('should handle non-Error exceptions gracefully', async () => { + mockGetAccountId.mockRejectedValue('string error'); + + const result = await amazonAuth.validate!({ + auth: createAuthObject(), + }); + + expect(result).toEqual({ + valid: false, + error: 'Unknown error', + }); + }); + + test('should handle non-Error exceptions in role validation', async () => { + mockSuccessfulAccountId(); + mockAssumeRole.mockRejectedValue({ code: 'AccessDenied' }); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ + roles: [createRole('111111111111', 'Production')], + }), + }); + + expect(result).toEqual({ + valid: false, + error: + 'role "arn:aws:iam::111111111111:role/ProductionRole" (Production): Unknown error', + }); + }); + }); + + describe('Auth property structure', () => { + test('should have expected properties', () => { + expect(amazonAuth.authProviderKey).toBe('AWS'); + expect(amazonAuth.displayName).toBe('Connection'); + expect(amazonAuth.type).toBe('CUSTOM_AUTH'); + expect(amazonAuth.required).toBe(true); + + expect(amazonAuth.props.defaultRegion.displayName).toBe('Default Region'); + expect(amazonAuth.props.defaultRegion.required).toBe(true); + expect(amazonAuth.props.defaultRegion.defaultValue).toBe('us-east-1'); + + expect(amazonAuth.props.accessKeyId.type).toBe('SECRET_TEXT'); + expect(amazonAuth.props.secretAccessKey.type).toBe('SECRET_TEXT'); + + expect(amazonAuth.props.endpoint.displayName).toBe( + 'Custom Endpoint (optional)', + ); + expect(amazonAuth.props.endpoint.required).toBe(false); + + expect(amazonAuth.props.roles.type).toBe('ARRAY'); + expect(amazonAuth.props.roles.required).toBe(false); + }); + + test('should mark credentials as optional when implicit role enabled', async () => { + const freshAmazonAuth = await reimportAuthWithImplicitRole(); + + expect(freshAmazonAuth.props.accessKeyId.displayName).toContain( + 'optional', + ); + expect(freshAmazonAuth.props.secretAccessKey.displayName).toContain( + 'optional', + ); + expect(freshAmazonAuth.props.accessKeyId.required).toBe(false); + expect(freshAmazonAuth.props.secretAccessKey.required).toBe(false); + }); + }); +}); diff --git a/packages/react-ui/src/app/features/connections/components/create-edit-connection-dialog-content.tsx b/packages/react-ui/src/app/features/connections/components/create-edit-connection-dialog-content.tsx index ebcbc3d9df..ff2901934d 100644 --- a/packages/react-ui/src/app/features/connections/components/create-edit-connection-dialog-content.tsx +++ b/packages/react-ui/src/app/features/connections/components/create-edit-connection-dialog-content.tsx @@ -42,9 +42,9 @@ import { import { ScrollArea } from '@radix-ui/react-scroll-area'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { t } from 'i18next'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Info } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { appConnectionsHooks } from '../lib/app-connections-hooks'; import { buildConnectionSchema, @@ -125,6 +125,12 @@ const CreateEditConnectionDialogContent = ({ const [errorMessage, setErrorMessage] = useState(''); const queryClient = useQueryClient(); + const roles = useWatch({ + control: form.control, + name: 'request.value.props.roles', + }); + const hasRoles = authProviderKey === 'AWS' && roles && roles.length > 0; + const { mutate, isPending } = useMutation({ mutationFn: async () => { setErrorMessage(''); @@ -300,6 +306,23 @@ const CreateEditConnectionDialogContent = ({ /> )} + {hasRoles && ( +
+ + + {t( + 'Validating AWS roles may take 10-30 seconds. We will verify access to all configured roles.', + )} + +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +