From 70bbabb48597ee440ebf68bdefe096217230ef8a Mon Sep 17 00:00:00 2001 From: Cezar Dascal Date: Wed, 1 Apr 2026 14:50:49 +0300 Subject: [PATCH 01/13] Validate AWS connection credentials at creation time --- packages/openops/src/lib/aws/auth.ts | 88 +++- packages/openops/test/aws/auth.test.ts | 432 ++++++++++++++++++ .../create-edit-connection-dialog-content.tsx | 31 +- 3 files changed, 537 insertions(+), 14 deletions(-) create mode 100644 packages/openops/test/aws/auth.test.ts diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 28842532ed..310cf7476a 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, @@ -166,6 +166,77 @@ export function getAwsAccountsSingleSelectDropdown() { return createAwsAccountsDropdown(false); } +async function validateRequiredFields( + auth: any, +): Promise<{ valid: true } | { valid: false; error: string }> { + 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<{ valid: true } | { valid: false; error: string }> { + try { + const credentials = { + accessKeyId: auth.accessKeyId || '', + secretAccessKey: auth.secretAccessKey || '', + endpoint: auth.endpoint, + }; + await getAccountId(credentials, auth.defaultRegion); + return { valid: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + return { + valid: false, + error: `Base credentials validation failed: ${errorMessage}`, + }; + } +} + +async function validateRoleAssumptions( + auth: any, +): Promise<{ valid: true } | { valid: false; error: string }> { + if (!auth.roles || auth.roles.length === 0) { + return { valid: true }; + } + + const accessKeyId = auth.accessKeyId || ''; + const secretAccessKey = auth.secretAccessKey || ''; + + for (const role of auth.roles as Role[]) { + try { + await assumeRole( + accessKeyId, + secretAccessKey, + auth.defaultRegion, + role.assumeRoleArn, + role.assumeRoleExternalId, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + return { + valid: false, + error: `Role validation failed for ARN "${role.assumeRoleArn}" (${role.accountName}): ${errorMessage}`, + }; + } + } + + return { valid: true }; +} + export const amazonAuth = BlockAuth.CustomAuth({ authProviderKey: 'AWS', authProviderDisplayName: 'AWS', @@ -229,16 +300,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/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts new file mode 100644 index 0000000000..771b1acdbc --- /dev/null +++ b/packages/openops/test/aws/auth.test.ts @@ -0,0 +1,432 @@ +/* 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'; + +describe('AWS Auth Validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSystem.getBoolean.mockReturnValue(false); // Default: implicit role disabled + }); + + describe('Field validation', () => { + test('should fail when defaultRegion is missing', async () => { + const result = await amazonAuth.validate!({ + auth: { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + } 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: 'us-east-1', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + } 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: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + } 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 () => { + mockGetAccountId.mockResolvedValue('123456789012'); + + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + } as any, + }); + + expect(result).toEqual({ valid: true }); + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + endpoint: undefined, + }, + 'us-east-1', + ); + }); + + 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: { + defaultRegion: 'us-east-1', + accessKeyId: 'INVALID_KEY', + secretAccessKey: 'INVALID_SECRET', + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: + 'Base credentials validation failed: The security token included in the request is invalid', + }); + }); + + test('should pass endpoint to getAccountId when provided', async () => { + mockGetAccountId.mockResolvedValue('123456789012'); + + await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + endpoint: 'http://localhost:4566', + } as any, + }); + + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + endpoint: 'http://localhost:4566', + }, + 'us-east-1', + ); + }); + }); + + describe('Implicit role validation', () => { + test('should validate with GetCallerIdentity when implicit role enabled and no credentials', async () => { + mockSystem.getBoolean.mockReturnValue(true); + mockGetAccountId.mockResolvedValue('123456789012'); + + // Re-import to get fresh auth with new system setting + jest.resetModules(); + mockSystem.getBoolean.mockReturnValue(true); + const { amazonAuth: freshAmazonAuth } = await import( + '../../src/lib/aws/auth' + ); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + } as any, + }); + + expect(result).toEqual({ valid: true }); + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: '', + secretAccessKey: '', + endpoint: undefined, + }, + 'us-east-1', + ); + }); + + test('should fail when implicit role validation fails', async () => { + mockSystem.getBoolean.mockReturnValue(true); + mockGetAccountId.mockRejectedValue( + new Error('Unable to locate credentials'), + ); + + jest.resetModules(); + mockSystem.getBoolean.mockReturnValue(true); + const { amazonAuth: freshAmazonAuth } = await import( + '../../src/lib/aws/auth' + ); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Base credentials validation failed: Unable to locate credentials', + }); + }); + }); + + describe('Role validation', () => { + test('should validate all roles successfully', async () => { + mockGetAccountId.mockResolvedValue('123456789012'); + mockAssumeRole.mockResolvedValue({ + AccessKeyId: 'ASIATEMP', + SecretAccessKey: 'tempSecret', + SessionToken: 'tempToken', + }); + + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + roles: [ + { + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProductionRole', + accountName: 'Production', + }, + { + assumeRoleArn: 'arn:aws:iam::222222222222:role/StagingRole', + accountName: 'Staging', + assumeRoleExternalId: 'external123', + }, + ], + } as any, + }); + + expect(result).toEqual({ valid: true }); + expect(mockAssumeRole).toHaveBeenCalledTimes(2); + expect(mockAssumeRole).toHaveBeenNthCalledWith( + 1, + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'us-east-1', + 'arn:aws:iam::111111111111:role/ProductionRole', + undefined, + ); + expect(mockAssumeRole).toHaveBeenNthCalledWith( + 2, + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'us-east-1', + 'arn:aws:iam::222222222222:role/StagingRole', + 'external123', + ); + }); + + test('should fail when first role validation fails', async () => { + mockGetAccountId.mockResolvedValue('123456789012'); + mockAssumeRole.mockRejectedValue( + new Error( + 'User: arn:aws:iam::123456789012:user/ops is not authorized to perform: sts:AssumeRole', + ), + ); + + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + roles: [ + { + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProductionRole', + accountName: 'Production', + }, + ], + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: + 'Role validation failed for ARN "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 () => { + mockGetAccountId.mockResolvedValue('123456789012'); + mockAssumeRole + .mockResolvedValueOnce({ + AccessKeyId: 'ASIATEMP', + SecretAccessKey: 'tempSecret', + SessionToken: 'tempToken', + }) + .mockRejectedValueOnce(new Error('External ID mismatch')); + + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + roles: [ + { + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProductionRole', + accountName: 'Production', + }, + { + assumeRoleArn: 'arn:aws:iam::222222222222:role/StagingRole', + accountName: 'Staging', + assumeRoleExternalId: 'wrong-external-id', + }, + ], + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: + 'Role validation failed for ARN "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 () => { + mockSystem.getBoolean.mockReturnValue(true); + mockGetAccountId.mockResolvedValue('123456789012'); + mockAssumeRole.mockResolvedValue({ + AccessKeyId: 'ASIATEMP', + SecretAccessKey: 'tempSecret', + SessionToken: 'tempToken', + }); + + jest.resetModules(); + mockSystem.getBoolean.mockReturnValue(true); + const { amazonAuth: freshAmazonAuth } = await import( + '../../src/lib/aws/auth' + ); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + roles: [ + { + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProductionRole', + accountName: 'Production', + }, + ], + } as any, + }); + + expect(result).toEqual({ valid: true }); + expect(mockAssumeRole).toHaveBeenCalledWith( + '', + '', + 'us-east-1', + 'arn:aws:iam::111111111111:role/ProductionRole', + undefined, + ); + }); + }); + + describe('Error handling', () => { + test('should handle non-Error exceptions gracefully', async () => { + mockGetAccountId.mockRejectedValue('string error'); + + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Base credentials validation failed: Unknown error', + }); + }); + + test('should handle non-Error exceptions in role validation', async () => { + mockGetAccountId.mockResolvedValue('123456789012'); + mockAssumeRole.mockRejectedValue({ code: 'AccessDenied' }); + + const result = await amazonAuth.validate!({ + auth: { + defaultRegion: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + roles: [ + { + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProductionRole', + accountName: 'Production', + }, + ], + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: + 'Role validation failed for ARN "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 () => { + mockSystem.getBoolean.mockReturnValue(true); + jest.resetModules(); + mockSystem.getBoolean.mockReturnValue(true); + const { amazonAuth: freshAmazonAuth } = await import( + '../../src/lib/aws/auth' + ); + + 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..778ac58d3d 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,7 +42,7 @@ 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 { appConnectionsHooks } from '../lib/app-connections-hooks'; @@ -125,6 +125,12 @@ const CreateEditConnectionDialogContent = ({ const [errorMessage, setErrorMessage] = useState(''); const queryClient = useQueryClient(); + const formValues = form.watch(); + const hasRoles = + authProviderKey === 'AWS' && + formValues?.request?.value?.props?.roles && + formValues.request.value.props.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} +
+ )} +