From 5a032fc00285c94d01e41a9ec64c93fda8a634b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 12:33:39 +0100 Subject: [PATCH 01/25] WIP --- packages/openops/src/lib/aws/sts-common.ts | 88 +++++++++++++++++++ .../shared/src/lib/system/system-prop.ts | 3 + 2 files changed, 91 insertions(+) diff --git a/packages/openops/src/lib/aws/sts-common.ts b/packages/openops/src/lib/aws/sts-common.ts index 2ec646b327..61f524db1d 100644 --- a/packages/openops/src/lib/aws/sts-common.ts +++ b/packages/openops/src/lib/aws/sts-common.ts @@ -1,9 +1,11 @@ import { AssumeRoleCommand, + AssumeRoleWithWebIdentityCommand, Credentials, GetCallerIdentityCommand, STSClient, } from '@aws-sdk/client-sts'; +import { SharedSystemProp, system } from '@openops/server-shared'; import { v4 as uuidv4 } from 'uuid'; import { getAwsClient } from './get-client'; @@ -26,17 +28,103 @@ export async function assumeRole( externalId?: string, endpoint?: string | undefined | null, ): Promise { + if ( + !accessKeyId && + system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE) + // && Azure deployment) + ) { + const x = await assumeRoleFromAzureManagedIdentity(defaultRegion); + + const client = getAwsClient( + STSClient, + { + accessKeyId: x!.AccessKeyId!, + secretAccessKey: x!.SecretAccessKey!, + sessionToken: x!.SessionToken, + endpoint, + }, + defaultRegion, + ); + + const command = new AssumeRoleCommand({ + RoleArn: roleArn, + ExternalId: externalId || undefined, + RoleSessionName: 'openops-' + uuidv4(), + }); + + const response = await client.send(command); + + return response.Credentials; + } + const client = getAwsClient( STSClient, { accessKeyId, secretAccessKey, endpoint }, defaultRegion, ); + const command = new AssumeRoleCommand({ RoleArn: roleArn, ExternalId: externalId || undefined, RoleSessionName: 'openops-' + uuidv4(), }); + const response = await client.send(command); return response.Credentials; } + +export async function assumeRoleFromAzureManagedIdentity( + defaultRegion: string, + // roleArn: string, + // azureTokenAudience?: string, + // endpoint?: string | undefined | null, +): Promise { + // if (!azureTokenAudience) { + // throw new Error('Azure deployment is not supported yet'); + // } + + const webIdentityToken = await getAzureManagedIdentityToken(); + + const client = new STSClient({ + region: defaultRegion, + }); + + const arn = system.get(SharedSystemProp.AWS_IMPLICIT_ROLE_ARN)!; + + const command = new AssumeRoleWithWebIdentityCommand({ + RoleArn: arn, + RoleSessionName: 'openops-' + uuidv4(), + WebIdentityToken: webIdentityToken, + }); + + const response = await client.send(command); + + return response.Credentials; +} + +async function getAzureManagedIdentityToken(): Promise { + const resource = system.get(SharedSystemProp.AWS_IMPLICIT_ROLE_AUD)!; + + const url = + `http://169.254.169.254/metadata/identity/oauth2/token` + + `?api-version=2018-02-01` + + `&resource=${encodeURIComponent(resource)}`; + + const response = await fetch(url, { + headers: { + Metadata: 'true', + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to get Azure managed identity token: ${ + response.status + } ${await response.text()}`, + ); + } + + const data = (await response.json()) as { access_token: string }; + return data.access_token; +} diff --git a/packages/server/shared/src/lib/system/system-prop.ts b/packages/server/shared/src/lib/system/system-prop.ts index eb03efee51..e8f6209dbe 100644 --- a/packages/server/shared/src/lib/system/system-prop.ts +++ b/packages/server/shared/src/lib/system/system-prop.ts @@ -159,6 +159,9 @@ export enum SharedSystemProp { ENABLE_HOST_VALIDATION = 'ENABLE_HOST_VALIDATION', SMTP_ALLOWED_PORTS = 'SMTP_ALLOWED_PORTS', + + AWS_IMPLICIT_ROLE_ARN = 'AWS_IMPLICIT_ROLE_ARN', + AWS_IMPLICIT_ROLE_AUD = 'AWS_IMPLICIT_ROLE_AUD', } export enum WorkerSystemProps { From 57712a0876281c5922d7fb50182d9ce06682c7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 13:01:33 +0100 Subject: [PATCH 02/25] WIP --- packages/openops/src/lib/aws/sts-common.ts | 52 ++++++++++++---------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/openops/src/lib/aws/sts-common.ts b/packages/openops/src/lib/aws/sts-common.ts index 61f524db1d..f21b70d3de 100644 --- a/packages/openops/src/lib/aws/sts-common.ts +++ b/packages/openops/src/lib/aws/sts-common.ts @@ -5,7 +5,7 @@ import { GetCallerIdentityCommand, STSClient, } from '@aws-sdk/client-sts'; -import { SharedSystemProp, system } from '@openops/server-shared'; +import { logger, SharedSystemProp, system } from '@openops/server-shared'; import { v4 as uuidv4 } from 'uuid'; import { getAwsClient } from './get-client'; @@ -33,28 +33,33 @@ export async function assumeRole( system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE) // && Azure deployment) ) { - const x = await assumeRoleFromAzureManagedIdentity(defaultRegion); - - const client = getAwsClient( - STSClient, - { - accessKeyId: x!.AccessKeyId!, - secretAccessKey: x!.SecretAccessKey!, - sessionToken: x!.SessionToken, - endpoint, - }, - defaultRegion, - ); - - const command = new AssumeRoleCommand({ - RoleArn: roleArn, - ExternalId: externalId || undefined, - RoleSessionName: 'openops-' + uuidv4(), - }); - - const response = await client.send(command); - - return response.Credentials; + try { + const x = await assumeRoleFromAzureManagedIdentity(defaultRegion); + + const client = getAwsClient( + STSClient, + { + accessKeyId: x!.AccessKeyId!, + secretAccessKey: x!.SecretAccessKey!, + sessionToken: x!.SessionToken, + endpoint, + }, + defaultRegion, + ); + + const command = new AssumeRoleCommand({ + RoleArn: roleArn, + ExternalId: externalId || undefined, + RoleSessionName: 'openops-' + uuidv4(), + }); + + const response = await client.send(command); + + return response.Credentials; + } catch (error) { + logger.error('Failed to assume role from Azure Managed Identity:', error); + throw error; + } } const client = getAwsClient( @@ -100,6 +105,7 @@ export async function assumeRoleFromAzureManagedIdentity( const response = await client.send(command); + logger.info('Assumed role from Azure Managed Identity'); return response.Credentials; } From 7adddea3b1fd646b11e5b2253ff6492e0cdcbcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 13:56:10 +0100 Subject: [PATCH 03/25] WIP --- packages/openops/src/lib/aws/get-client.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index 1b973c6a7b..8d140dae6a 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -1,5 +1,6 @@ import { SharedSystemProp, system } from '@openops/server-shared'; import { AwsCredentials } from './auth'; +import { assumeRoleFromAzureManagedIdentity } from './sts-common'; export function getAwsClient( ClientConstructor: new (config: { @@ -23,6 +24,11 @@ export function getAwsClient( ); } + // 👇 Lazy async provider (THIS is the trick) + config.credentials = async () => { + return assumeRoleFromAzureManagedIdentity(region); + }; + if (credentials.endpoint) { config.endpoint = credentials.endpoint; } From 3e11609143c97430baae0187399f07f2516859a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 14:14:38 +0100 Subject: [PATCH 04/25] WIP --- packages/openops/src/lib/aws/auth.ts | 5 ++- packages/openops/src/lib/aws/get-client.ts | 45 ++++++++++++---------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 311d9cb9b8..17df70bf86 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ import { BlockAuth, Property } from '@openops/blocks-framework'; -import { SharedSystemProp, system } from '@openops/server-shared'; +import { logger, SharedSystemProp, system } from '@openops/server-shared'; import { parseArn } from './arn-handler'; import { assumeRole, getAccountId } from './sts-common'; @@ -340,16 +340,19 @@ For large or complex setups, enhanced features are available, including: }, required: true, validate: async ({ auth }) => { + logger.info('validateRequiredFields'); const fieldValidation = await validateRequiredFields(auth); if (!fieldValidation.valid) { return fieldValidation; } + logger.info('validateBaseCredentials'); const baseCredentialsValidation = await validateBaseCredentials(auth); if (!baseCredentialsValidation.valid) { return baseCredentialsValidation; } + logger.info('validateRoleAssumptions'); const roleValidation = await validateRoleAssumptions(auth); if (!roleValidation.valid) { return roleValidation; diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index 8d140dae6a..7c10543e17 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -1,4 +1,4 @@ -import { SharedSystemProp, system } from '@openops/server-shared'; +import { logger, SharedSystemProp, system } from '@openops/server-shared'; import { AwsCredentials } from './auth'; import { assumeRoleFromAzureManagedIdentity } from './sts-common'; @@ -11,27 +11,32 @@ export function getAwsClient( credentials: AwsCredentials, region: string, ): T { - const config: any = { region }; - if (credentials.accessKeyId) { - config.credentials = { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, + try { + const config: any = { region }; + if (credentials.accessKeyId) { + config.credentials = { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }; + } else if (!system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE)) { + throw new Error( + 'AWS credentials are required, please provide accessKeyId and secretAccessKey', + ); + } + + // 👇 Lazy async provider (THIS is the trick) + config.credentials = async () => { + return assumeRoleFromAzureManagedIdentity(region); }; - } else if (!system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE)) { - throw new Error( - 'AWS credentials are required, please provide accessKeyId and secretAccessKey', - ); - } - // 👇 Lazy async provider (THIS is the trick) - config.credentials = async () => { - return assumeRoleFromAzureManagedIdentity(region); - }; + if (credentials.endpoint) { + config.endpoint = credentials.endpoint; + } - if (credentials.endpoint) { - config.endpoint = credentials.endpoint; + return new ClientConstructor(config); + } catch (error) { + logger.error('Failed to create AWS client', error); + throw error; } - - return new ClientConstructor(config); } From 35faf5bd11f7e9a883918126c951b917a99f4ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 14:34:43 +0100 Subject: [PATCH 05/25] WIP --- packages/openops/src/lib/aws/auth.ts | 36 +++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 17df70bf86..a852f0f99f 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -242,6 +242,8 @@ async function validateBaseCredentials(auth: any): Promise { return { valid: true }; } catch (error) { const errorMessage = extractErrorMessage(error); + logger.info('validateBaseCredentials Fail', errorMessage); + return { valid: false, error: errorMessage, @@ -340,23 +342,23 @@ For large or complex setups, enhanced features are available, including: }, required: true, validate: async ({ auth }) => { - logger.info('validateRequiredFields'); - const fieldValidation = await validateRequiredFields(auth); - if (!fieldValidation.valid) { - return fieldValidation; - } - - logger.info('validateBaseCredentials'); - const baseCredentialsValidation = await validateBaseCredentials(auth); - if (!baseCredentialsValidation.valid) { - return baseCredentialsValidation; - } - - logger.info('validateRoleAssumptions'); - const roleValidation = await validateRoleAssumptions(auth); - if (!roleValidation.valid) { - return roleValidation; - } + // logger.info('validateRequiredFields'); + // const fieldValidation = await validateRequiredFields(auth); + // if (!fieldValidation.valid) { + // return fieldValidation; + // } + // + // logger.info('validateBaseCredentials'); + // const baseCredentialsValidation = await validateBaseCredentials(auth); + // if (!baseCredentialsValidation.valid) { + // return baseCredentialsValidation; + // } + // + // logger.info('validateRoleAssumptions'); + // const roleValidation = await validateRoleAssumptions(auth); + // if (!roleValidation.valid) { + // return roleValidation; + // } return { valid: true }; }, From 41769e07a2dae47fab7a6b4e578265693cece81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 15:54:26 +0100 Subject: [PATCH 06/25] WIP --- packages/openops/src/lib/aws/sts-common.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/openops/src/lib/aws/sts-common.ts b/packages/openops/src/lib/aws/sts-common.ts index f21b70d3de..0840c4e270 100644 --- a/packages/openops/src/lib/aws/sts-common.ts +++ b/packages/openops/src/lib/aws/sts-common.ts @@ -36,6 +36,8 @@ export async function assumeRole( try { const x = await assumeRoleFromAzureManagedIdentity(defaultRegion); + logger.error('getAwsClient 2'); + const client = getAwsClient( STSClient, { @@ -81,20 +83,17 @@ export async function assumeRole( export async function assumeRoleFromAzureManagedIdentity( defaultRegion: string, - // roleArn: string, - // azureTokenAudience?: string, - // endpoint?: string | undefined | null, ): Promise { - // if (!azureTokenAudience) { - // throw new Error('Azure deployment is not supported yet'); - // } - const webIdentityToken = await getAzureManagedIdentityToken(); const client = new STSClient({ region: defaultRegion, }); + logger.info('AssumeRoleWithWebIdentityCommand', { + check: webIdentityToken, + }); + const arn = system.get(SharedSystemProp.AWS_IMPLICIT_ROLE_ARN)!; const command = new AssumeRoleWithWebIdentityCommand({ @@ -106,6 +105,7 @@ export async function assumeRoleFromAzureManagedIdentity( const response = await client.send(command); logger.info('Assumed role from Azure Managed Identity'); + return response.Credentials; } From 6840491243c8b6a5c76bac6dcbe0eebba2d42a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 16:25:58 +0100 Subject: [PATCH 07/25] WIP --- packages/openops/src/lib/aws/sts-common.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/openops/src/lib/aws/sts-common.ts b/packages/openops/src/lib/aws/sts-common.ts index 0840c4e270..9a40ee7c10 100644 --- a/packages/openops/src/lib/aws/sts-common.ts +++ b/packages/openops/src/lib/aws/sts-common.ts @@ -36,7 +36,7 @@ export async function assumeRole( try { const x = await assumeRoleFromAzureManagedIdentity(defaultRegion); - logger.error('getAwsClient 2'); + logger.error('getAwsClient 2', x); const client = getAwsClient( STSClient, @@ -90,10 +90,6 @@ export async function assumeRoleFromAzureManagedIdentity( region: defaultRegion, }); - logger.info('AssumeRoleWithWebIdentityCommand', { - check: webIdentityToken, - }); - const arn = system.get(SharedSystemProp.AWS_IMPLICIT_ROLE_ARN)!; const command = new AssumeRoleWithWebIdentityCommand({ @@ -104,8 +100,6 @@ export async function assumeRoleFromAzureManagedIdentity( const response = await client.send(command); - logger.info('Assumed role from Azure Managed Identity'); - return response.Credentials; } From 2d317e8ebcf9bb4e98f721675e038427a86cfabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 16:32:04 +0100 Subject: [PATCH 08/25] WIP --- packages/openops/src/lib/aws/get-client.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index 7c10543e17..647db73b61 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -23,13 +23,13 @@ export function getAwsClient( throw new Error( 'AWS credentials are required, please provide accessKeyId and secretAccessKey', ); + } else { + // 👇 Lazy async provider (THIS is the trick) + config.credentials = async () => { + return assumeRoleFromAzureManagedIdentity(region); + }; } - // 👇 Lazy async provider (THIS is the trick) - config.credentials = async () => { - return assumeRoleFromAzureManagedIdentity(region); - }; - if (credentials.endpoint) { config.endpoint = credentials.endpoint; } From 346e93b91df022fdb302d0dd95c6e672ae669ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 17:16:13 +0100 Subject: [PATCH 09/25] WIP --- .../src/lib/aws/azure-aws-federation.ts | 92 +++++++++++++++++++ packages/openops/src/lib/aws/get-client.ts | 52 +++++------ packages/openops/src/lib/aws/sts-common.ts | 91 ++---------------- .../shared/src/lib/system/system-prop.ts | 5 +- 4 files changed, 128 insertions(+), 112 deletions(-) create mode 100644 packages/openops/src/lib/aws/azure-aws-federation.ts diff --git a/packages/openops/src/lib/aws/azure-aws-federation.ts b/packages/openops/src/lib/aws/azure-aws-federation.ts new file mode 100644 index 0000000000..b66ab02aa6 --- /dev/null +++ b/packages/openops/src/lib/aws/azure-aws-federation.ts @@ -0,0 +1,92 @@ +import { + AssumeRoleCommand, + AssumeRoleWithWebIdentityCommand, + Credentials, + STSClient, +} from '@aws-sdk/client-sts'; +import { logger, SharedSystemProp, system } from '@openops/server-shared'; +import { v4 as uuidv4 } from 'uuid'; +import { getAwsClient } from './get-client'; + +export async function assumeTargetRoleViaAzureFederation( + defaultRegion: string, + roleArn: string, + externalId?: string, + endpoint?: string | undefined | null, +): Promise { + const sourceCredentials = await getAwsCredentialsFromAzureIdentity( + defaultRegion, + ); + + if (!sourceCredentials?.AccessKeyId || !sourceCredentials.SecretAccessKey) { + throw new Error('Failed to get AWS credentials from Azure identity'); + } + + const client = getAwsClient( + STSClient, + { + accessKeyId: sourceCredentials.AccessKeyId, + secretAccessKey: sourceCredentials.SecretAccessKey, + sessionToken: sourceCredentials.SessionToken, + endpoint, + }, + defaultRegion, + ); + + const command = new AssumeRoleCommand({ + RoleArn: roleArn, + ExternalId: externalId || undefined, + RoleSessionName: 'openops-' + uuidv4(), + }); + + const response = await client.send(command); + + return response.Credentials; +} + +export async function getAwsCredentialsFromAzureIdentity( + defaultRegion: string, +): Promise { + const webIdentityToken = await getAzureOidcTokenForAws(); + const client = new STSClient({ + region: defaultRegion, + }); + + const federationRoleArn = system.getOrThrow( + SharedSystemProp.AWS_AZURE_FEDERATION_ROLE_ARN, + ); + + const command = new AssumeRoleWithWebIdentityCommand({ + RoleArn: federationRoleArn, + RoleSessionName: 'openops-' + uuidv4(), + WebIdentityToken: webIdentityToken, + }); + + const response = await client.send(command); + + return response.Credentials; +} + +async function getAzureOidcTokenForAws(): Promise { + const resource = 'api://AzureADTokenExchange'; + + const url = + `http://169.254.169.254/metadata/identity/oauth2/token` + + `?api-version=2018-02-01` + + `&resource=${encodeURIComponent(resource)}`; + + const response = await fetch(url, { + headers: { + Metadata: 'true', + }, + }); + + if (!response.ok) { + logger.info('Failed to get Azure managed identity token.', response); + throw new Error('Failed to get Azure managed identity token.'); + } + + const data = (await response.json()) as { access_token: string }; + + return data.access_token; +} diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index 647db73b61..c444c67c84 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -1,6 +1,6 @@ -import { logger, SharedSystemProp, system } from '@openops/server-shared'; +import { SharedSystemProp, system } from '@openops/server-shared'; import { AwsCredentials } from './auth'; -import { assumeRoleFromAzureManagedIdentity } from './sts-common'; +import { getAwsCredentialsFromAzureIdentity } from './azure-aws-federation'; export function getAwsClient( ClientConstructor: new (config: { @@ -11,32 +11,28 @@ export function getAwsClient( credentials: AwsCredentials, region: string, ): T { - try { - const config: any = { region }; - if (credentials.accessKeyId) { - config.credentials = { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }; - } else if (!system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE)) { - throw new Error( - 'AWS credentials are required, please provide accessKeyId and secretAccessKey', - ); - } else { - // 👇 Lazy async provider (THIS is the trick) - config.credentials = async () => { - return assumeRoleFromAzureManagedIdentity(region); - }; - } - - if (credentials.endpoint) { - config.endpoint = credentials.endpoint; - } + const config: any = { region }; + if (credentials.accessKeyId) { + config.credentials = { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }; + } else if (!system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE)) { + throw new Error( + 'AWS credentials are required, please provide accessKeyId and secretAccessKey', + ); + } else if ( + system.getBoolean(SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY) + ) { + config.credentials = async () => { + return getAwsCredentialsFromAzureIdentity(region); + }; + } - return new ClientConstructor(config); - } catch (error) { - logger.error('Failed to create AWS client', error); - throw error; + if (credentials.endpoint) { + config.endpoint = credentials.endpoint; } + + return new ClientConstructor(config); } diff --git a/packages/openops/src/lib/aws/sts-common.ts b/packages/openops/src/lib/aws/sts-common.ts index 9a40ee7c10..e3012706ca 100644 --- a/packages/openops/src/lib/aws/sts-common.ts +++ b/packages/openops/src/lib/aws/sts-common.ts @@ -1,12 +1,12 @@ import { AssumeRoleCommand, - AssumeRoleWithWebIdentityCommand, Credentials, GetCallerIdentityCommand, STSClient, } from '@aws-sdk/client-sts'; -import { logger, SharedSystemProp, system } from '@openops/server-shared'; +import { SharedSystemProp, system } from '@openops/server-shared'; import { v4 as uuidv4 } from 'uuid'; +import { assumeTargetRoleViaAzureFederation } from './azure-aws-federation'; import { getAwsClient } from './get-client'; export async function getAccountId( @@ -30,38 +30,15 @@ export async function assumeRole( ): Promise { if ( !accessKeyId && - system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE) - // && Azure deployment) + system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE) && + system.getBoolean(SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY) ) { - try { - const x = await assumeRoleFromAzureManagedIdentity(defaultRegion); - - logger.error('getAwsClient 2', x); - - const client = getAwsClient( - STSClient, - { - accessKeyId: x!.AccessKeyId!, - secretAccessKey: x!.SecretAccessKey!, - sessionToken: x!.SessionToken, - endpoint, - }, - defaultRegion, - ); - - const command = new AssumeRoleCommand({ - RoleArn: roleArn, - ExternalId: externalId || undefined, - RoleSessionName: 'openops-' + uuidv4(), - }); - - const response = await client.send(command); - - return response.Credentials; - } catch (error) { - logger.error('Failed to assume role from Azure Managed Identity:', error); - throw error; - } + return assumeTargetRoleViaAzureFederation( + defaultRegion, + roleArn, + externalId, + endpoint, + ); } const client = getAwsClient( @@ -80,51 +57,3 @@ export async function assumeRole( return response.Credentials; } - -export async function assumeRoleFromAzureManagedIdentity( - defaultRegion: string, -): Promise { - const webIdentityToken = await getAzureManagedIdentityToken(); - - const client = new STSClient({ - region: defaultRegion, - }); - - const arn = system.get(SharedSystemProp.AWS_IMPLICIT_ROLE_ARN)!; - - const command = new AssumeRoleWithWebIdentityCommand({ - RoleArn: arn, - RoleSessionName: 'openops-' + uuidv4(), - WebIdentityToken: webIdentityToken, - }); - - const response = await client.send(command); - - return response.Credentials; -} - -async function getAzureManagedIdentityToken(): Promise { - const resource = system.get(SharedSystemProp.AWS_IMPLICIT_ROLE_AUD)!; - - const url = - `http://169.254.169.254/metadata/identity/oauth2/token` + - `?api-version=2018-02-01` + - `&resource=${encodeURIComponent(resource)}`; - - const response = await fetch(url, { - headers: { - Metadata: 'true', - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to get Azure managed identity token: ${ - response.status - } ${await response.text()}`, - ); - } - - const data = (await response.json()) as { access_token: string }; - return data.access_token; -} diff --git a/packages/server/shared/src/lib/system/system-prop.ts b/packages/server/shared/src/lib/system/system-prop.ts index e8f6209dbe..37dd3ec847 100644 --- a/packages/server/shared/src/lib/system/system-prop.ts +++ b/packages/server/shared/src/lib/system/system-prop.ts @@ -152,6 +152,8 @@ export enum SharedSystemProp { SLACK_ENABLE_INTERACTIONS = 'SLACK_ENABLE_INTERACTIONS', AWS_ENABLE_IMPLICIT_ROLE = 'AWS_ENABLE_IMPLICIT_ROLE', + AWS_USE_AZURE_MANAGED_IDENTITY = 'AWS_USE_AZURE_MANAGED_IDENTITY', + AWS_AZURE_FEDERATION_ROLE_ARN = 'AWS_AZURE_FEDERATION_ROLE_ARN', LANGFUSE_SECRET_KEY = 'LANGFUSE_SECRET_KEY', LANGFUSE_PUBLIC_KEY = 'LANGFUSE_PUBLIC_KEY', @@ -159,9 +161,6 @@ export enum SharedSystemProp { ENABLE_HOST_VALIDATION = 'ENABLE_HOST_VALIDATION', SMTP_ALLOWED_PORTS = 'SMTP_ALLOWED_PORTS', - - AWS_IMPLICIT_ROLE_ARN = 'AWS_IMPLICIT_ROLE_ARN', - AWS_IMPLICIT_ROLE_AUD = 'AWS_IMPLICIT_ROLE_AUD', } export enum WorkerSystemProps { From a02e7a783ccda15f3cf1347cb3ba6952c322e3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 17:39:00 +0100 Subject: [PATCH 10/25] Add unit tests --- packages/openops/src/lib/aws/auth.ts | 38 ++-- .../test/aws/azure-aws-federation.test.ts | 167 ++++++++++++++++++ 2 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 packages/openops/test/aws/azure-aws-federation.test.ts diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index a852f0f99f..cea5eb089b 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ import { BlockAuth, Property } from '@openops/blocks-framework'; -import { logger, SharedSystemProp, system } from '@openops/server-shared'; +import { SharedSystemProp, system } from '@openops/server-shared'; import { parseArn } from './arn-handler'; import { assumeRole, getAccountId } from './sts-common'; @@ -242,8 +242,6 @@ async function validateBaseCredentials(auth: any): Promise { return { valid: true }; } catch (error) { const errorMessage = extractErrorMessage(error); - logger.info('validateBaseCredentials Fail', errorMessage); - return { valid: false, error: errorMessage, @@ -342,23 +340,23 @@ For large or complex setups, enhanced features are available, including: }, required: true, validate: async ({ auth }) => { - // logger.info('validateRequiredFields'); - // const fieldValidation = await validateRequiredFields(auth); - // if (!fieldValidation.valid) { - // return fieldValidation; - // } - // - // logger.info('validateBaseCredentials'); - // const baseCredentialsValidation = await validateBaseCredentials(auth); - // if (!baseCredentialsValidation.valid) { - // return baseCredentialsValidation; - // } - // - // logger.info('validateRoleAssumptions'); - // const roleValidation = await validateRoleAssumptions(auth); - // if (!roleValidation.valid) { - // return roleValidation; - // } + const fieldValidation = await validateRequiredFields(auth); + if (!fieldValidation.valid) { + return fieldValidation; + } + + const hasCredentials = auth.accessKeyId && auth.secretAccessKey; + if (!isImplicitRoleEnabled || hasCredentials) { + const baseCredentialsValidation = await validateBaseCredentials(auth); + if (!baseCredentialsValidation.valid) { + return baseCredentialsValidation; + } + } + + const roleValidation = await validateRoleAssumptions(auth); + if (!roleValidation.valid) { + return roleValidation; + } return { valid: true }; }, diff --git a/packages/openops/test/aws/azure-aws-federation.test.ts b/packages/openops/test/aws/azure-aws-federation.test.ts new file mode 100644 index 0000000000..6b4ea141f8 --- /dev/null +++ b/packages/openops/test/aws/azure-aws-federation.test.ts @@ -0,0 +1,167 @@ +import { + AssumeRoleCommand, + AssumeRoleWithWebIdentityCommand, + STSClient, +} from '@aws-sdk/client-sts'; +import { logger, system } from '@openops/server-shared'; +import { v4 as uuidv4 } from 'uuid'; +import { + assumeTargetRoleViaAzureFederation, + getAwsCredentialsFromAzureIdentity, +} from '../../src/lib/aws/azure-aws-federation'; +import { getAwsClient } from '../../src/lib/aws/get-client'; + +jest.mock('@aws-sdk/client-sts', () => { + return { + STSClient: jest.fn().mockImplementation(() => ({ + send: jest.fn(), + })), + AssumeRoleCommand: jest.fn(), + AssumeRoleWithWebIdentityCommand: jest.fn(), + }; +}); +jest.mock('@openops/server-shared'); +jest.mock('uuid'); +jest.mock('../../src/lib/aws/get-client'); + +describe('azure-aws-federation', () => { + const mockRegion = 'us-east-1'; + const mockRoleArn = 'arn:aws:iam::123456789012:role/target-role'; + const mockFederationRoleArn = + 'arn:aws:iam::123456789012:role/federation-role'; + const mockExternalId = 'external-id'; + const mockAccessToken = 'azure-access-token'; + const mockUuid = 'mock-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + (uuidv4 as jest.Mock).mockReturnValue(mockUuid); + global.fetch = jest.fn(); + }); + + describe('getAwsCredentialsFromAzureIdentity', () => { + it('should return credentials when successful', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ access_token: mockAccessToken }), + }); + + (system.getOrThrow as jest.Mock).mockReturnValue(mockFederationRoleArn); + + const mockCredentials = { + AccessKeyId: 'AKIA', + SecretAccessKey: 'SECRET', + SessionToken: 'TOKEN', + }; + + const mockSend = jest + .fn() + .mockResolvedValue({ Credentials: mockCredentials }); + (STSClient as jest.Mock).mockImplementation(() => ({ + send: mockSend, + })); + + const result = await getAwsCredentialsFromAzureIdentity(mockRegion); + + expect(result).toEqual(mockCredentials); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('resource=api%3A%2F%2FAzureADTokenExchange'), + expect.objectContaining({ + headers: { Metadata: 'true' }, + }), + ); + expect(STSClient).toHaveBeenCalledWith({ region: mockRegion }); + expect(AssumeRoleWithWebIdentityCommand).toHaveBeenCalledWith({ + RoleArn: mockFederationRoleArn, + RoleSessionName: `openops-${mockUuid}`, + WebIdentityToken: mockAccessToken, + }); + }); + + it('should throw error when fetch fails', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + }); + + await expect( + getAwsCredentialsFromAzureIdentity(mockRegion), + ).rejects.toThrow('Failed to get Azure managed identity token.'); + expect(logger.info).toHaveBeenCalled(); + }); + }); + + describe('assumeTargetRoleViaAzureFederation', () => { + it('should assume role and return credentials', async () => { + const mockSourceCredentials = { + AccessKeyId: 'AKIA-SOURCE', + SecretAccessKey: 'SECRET-SOURCE', + SessionToken: 'TOKEN-SOURCE', + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ access_token: mockAccessToken }), + }); + (system.getOrThrow as jest.Mock).mockReturnValue(mockFederationRoleArn); + + const mockStsClientForFederation = { + send: jest + .fn() + .mockResolvedValue({ Credentials: mockSourceCredentials }), + }; + const mockStsClientForTarget = { + send: jest + .fn() + .mockResolvedValue({ Credentials: { AccessKeyId: 'AKIA-TARGET' } }), + }; + + (STSClient as jest.Mock).mockImplementationOnce( + () => mockStsClientForFederation, + ); + (getAwsClient as jest.Mock).mockReturnValue(mockStsClientForTarget); + + const result = await assumeTargetRoleViaAzureFederation( + mockRegion, + mockRoleArn, + mockExternalId, + ); + + expect(result).toEqual({ AccessKeyId: 'AKIA-TARGET' }); + expect(getAwsClient).toHaveBeenCalledWith( + STSClient, + { + accessKeyId: mockSourceCredentials.AccessKeyId, + secretAccessKey: mockSourceCredentials.SecretAccessKey, + sessionToken: mockSourceCredentials.SessionToken, + endpoint: undefined, + }, + mockRegion, + ); + expect(AssumeRoleCommand).toHaveBeenCalledWith({ + RoleArn: mockRoleArn, + ExternalId: mockExternalId, + RoleSessionName: `openops-${mockUuid}`, + }); + }); + + it('should throw error if source credentials are missing required fields', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ access_token: mockAccessToken }), + }); + (system.getOrThrow as jest.Mock).mockReturnValue(mockFederationRoleArn); + + const mockSend = jest + .fn() + .mockResolvedValue({ Credentials: { AccessKeyId: 'AKIA' } }); + (STSClient as jest.Mock).mockImplementation(() => ({ + send: mockSend, + })); + + await expect( + assumeTargetRoleViaAzureFederation(mockRegion, mockRoleArn), + ).rejects.toThrow('Failed to get AWS credentials from Azure identity'); + }); + }); +}); From bb700011ae024fff04a804276666a0ead36d1ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 20:10:11 +0100 Subject: [PATCH 11/25] Add unit tests --- .../openops/test/aws/azure-aws-federation.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/openops/test/aws/azure-aws-federation.test.ts b/packages/openops/test/aws/azure-aws-federation.test.ts index 6b4ea141f8..f0e163f350 100644 --- a/packages/openops/test/aws/azure-aws-federation.test.ts +++ b/packages/openops/test/aws/azure-aws-federation.test.ts @@ -36,12 +36,12 @@ describe('azure-aws-federation', () => { beforeEach(() => { jest.clearAllMocks(); (uuidv4 as jest.Mock).mockReturnValue(mockUuid); - global.fetch = jest.fn(); + globalThis.fetch = jest.fn(); }); describe('getAwsCredentialsFromAzureIdentity', () => { it('should return credentials when successful', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => ({ access_token: mockAccessToken }), }); @@ -64,7 +64,7 @@ describe('azure-aws-federation', () => { const result = await getAwsCredentialsFromAzureIdentity(mockRegion); expect(result).toEqual(mockCredentials); - expect(global.fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('resource=api%3A%2F%2FAzureADTokenExchange'), expect.objectContaining({ headers: { Metadata: 'true' }, @@ -79,7 +79,7 @@ describe('azure-aws-federation', () => { }); it('should throw error when fetch fails', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500, }); @@ -99,7 +99,7 @@ describe('azure-aws-federation', () => { SessionToken: 'TOKEN-SOURCE', }; - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => ({ access_token: mockAccessToken }), }); @@ -146,7 +146,7 @@ describe('azure-aws-federation', () => { }); it('should throw error if source credentials are missing required fields', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => ({ access_token: mockAccessToken }), }); From c6fde05f65bebd7b97dd3f9cd02f6fb4b06debe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Mon, 4 May 2026 20:19:48 +0100 Subject: [PATCH 12/25] Add unit tests --- packages/openops/test/aws/get-client.test.ts | 47 +++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/openops/test/aws/get-client.test.ts b/packages/openops/test/aws/get-client.test.ts index c9986396b0..b768c83970 100644 --- a/packages/openops/test/aws/get-client.test.ts +++ b/packages/openops/test/aws/get-client.test.ts @@ -3,9 +3,15 @@ jest.mock('@openops/server-shared', () => ({ system: mockSystem, SharedSystemProp: { AWS_ENABLE_IMPLICIT_ROLE: 'AWS_ENABLE_IMPLICIT_ROLE', + AWS_USE_AZURE_MANAGED_IDENTITY: 'AWS_USE_AZURE_MANAGED_IDENTITY', }, })); +jest.mock('../../src/lib/aws/azure-aws-federation', () => ({ + getAwsCredentialsFromAzureIdentity: jest.fn(), +})); + +import { getAwsCredentialsFromAzureIdentity } from '../../src/lib/aws/azure-aws-federation'; import { getAwsClient } from '../../src/lib/aws/get-client'; class MockServiceClient { @@ -75,7 +81,9 @@ describe('getClient', () => { }); test('should not throw an error if credentials are not required', () => { - mockSystem.getBoolean.mockReturnValue(true); + mockSystem.getBoolean.mockReturnValueOnce(true); + mockSystem.getBoolean.mockReturnValueOnce(false); + const credentials = { accessKeyId: '', secretAccessKey: '', @@ -91,4 +99,41 @@ describe('getClient', () => { mockSystem.getBoolean.mockReturnValue(false); } }); + + test('should use Azure managed identity when configured', async () => { + mockSystem.getBoolean.mockImplementation((prop) => { + if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') { + return true; + } + if (prop === 'AWS_USE_AZURE_MANAGED_IDENTITY') { + return true; + } + return false; + }); + + const mockCreds = { + accessKeyId: 'azure-key', + secretAccessKey: 'azure-secret', + }; + (getAwsCredentialsFromAzureIdentity as jest.Mock).mockResolvedValue( + mockCreds, + ); + + const credentials = { + accessKeyId: '', + secretAccessKey: '', + }; + + try { + const client = getAwsClient(MockServiceClient, credentials, region); + expect(client).toBeInstanceOf(MockServiceClient); + expect(typeof client.config.credentials).toBe('function'); + + const result = await client.config.credentials(); + expect(result).toEqual(mockCreds); + expect(getAwsCredentialsFromAzureIdentity).toHaveBeenCalledWith(region); + } finally { + mockSystem.getBoolean.mockReturnValue(false); + } + }); }); From 38b560643dfd159abe4f62e60970b93fc0a90475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 15:15:04 +0100 Subject: [PATCH 13/25] Add unit tests --- packages/openops/test/aws/auth.test.ts | 197 +++++++++++++++++++++++-- 1 file changed, 182 insertions(+), 15 deletions(-) diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index 6bc8103f6a..4326716153 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -20,7 +20,15 @@ jest.mock('@openops/server-shared', () => ({ }, })); -import { amazonAuth } from '../../src/lib/aws/auth'; +import { + amazonAuth, + getAwsAccountsMultiSelectDropdown, + getAwsAccountsSingleSelectDropdown, + getCredentialsForAccount, + getCredentialsFromAuth, + getCredentialsListFromAuth, + getRoleForAccount, +} from '../../src/lib/aws/auth'; const EXAMPLE_ACCESS_KEY = 'AKIAIOSFODNN7EXAMPLE'; const EXAMPLE_SECRET_KEY = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; @@ -176,7 +184,7 @@ describe('AWS Auth Validation', () => { }); describe('Implicit role validation', () => { - test('should validate with GetCallerIdentity when implicit role enabled and no credentials', async () => { + test('should validate without calling getAccountId when implicit role enabled and no credentials', async () => { mockSuccessfulAccountId(); const freshAmazonAuth = await reimportAuthWithImplicitRole(); @@ -187,17 +195,10 @@ describe('AWS Auth Validation', () => { }); expect(result).toEqual({ valid: true }); - expect(mockGetAccountId).toHaveBeenCalledWith( - { - accessKeyId: '', - secretAccessKey: '', - endpoint: undefined, - }, - DEFAULT_REGION, - ); + expect(mockGetAccountId).not.toHaveBeenCalled(); }); - test('should fail when implicit role validation fails', async () => { + test('should validate without error even if getAccountId would fail (because it is not called)', async () => { mockGetAccountId.mockRejectedValue( new Error('Unable to locate credentials'), ); @@ -209,10 +210,8 @@ describe('AWS Auth Validation', () => { } as any, }); - expect(result).toEqual({ - valid: false, - error: 'Unable to locate credentials', - }); + expect(result).toEqual({ valid: true }); + expect(mockGetAccountId).not.toHaveBeenCalled(); }); }); @@ -414,4 +413,172 @@ describe('AWS Auth Validation', () => { expect(freshAmazonAuth.props.secretAccessKey.required).toBe(false); }); }); + + describe('getCredentialsFromAuth', () => { + test('should return base credentials if no assumeRoleArn is provided', async () => { + const auth = createAuthObject(); + const result = await getCredentialsFromAuth(auth); + + expect(result).toEqual({ + accessKeyId: EXAMPLE_ACCESS_KEY, + secretAccessKey: EXAMPLE_SECRET_KEY, + endpoint: undefined, + }); + expect(mockAssumeRole).not.toHaveBeenCalled(); + }); + + test('should return assumed role credentials if assumeRoleArn is provided', async () => { + mockSuccessfulAssumeRole(); + const auth = createAuthObject({ + assumeRoleArn: 'arn:aws:iam::123456789012:role/TestRole', + assumeRoleExternalId: 'ext-id', + }); + const result = await getCredentialsFromAuth(auth); + + expect(result).toEqual({ + accessKeyId: 'ASIATEMP', + secretAccessKey: 'tempSecret', + sessionToken: 'tempToken', + endpoint: undefined, + }); + expect(mockAssumeRole).toHaveBeenCalledWith( + EXAMPLE_ACCESS_KEY, + EXAMPLE_SECRET_KEY, + DEFAULT_REGION, + 'arn:aws:iam::123456789012:role/TestRole', + 'ext-id', + undefined, + ); + }); + }); + + describe('getCredentialsListFromAuth', () => { + test('should return base credentials if no roles are provided', async () => { + const auth = createAuthObject(); + const result = await getCredentialsListFromAuth(auth); + + expect(result).toEqual([ + { + accessKeyId: EXAMPLE_ACCESS_KEY, + secretAccessKey: EXAMPLE_SECRET_KEY, + endpoint: undefined, + }, + ]); + }); + + test('should return assumed role credentials for specified accounts', async () => { + mockSuccessfulAssumeRole(); + const auth = createAuthObject({ + roles: [ + createRole('111111111111', 'Prod'), + createRole('222222222222', 'Dev'), + ], + }); + + const result = await getCredentialsListFromAuth(auth, ['111111111111']); + + expect(result).toHaveLength(1); + expect(result[0].accessKeyId).toBe('ASIATEMP'); + expect(mockAssumeRole).toHaveBeenCalledTimes(1); + expect(mockAssumeRole).toHaveBeenCalledWith( + EXAMPLE_ACCESS_KEY, + EXAMPLE_SECRET_KEY, + DEFAULT_REGION, + 'arn:aws:iam::111111111111:role/ProdRole', + undefined, + undefined, + ); + }); + + test('should throw error if no matching roles found for accounts', async () => { + const auth = createAuthObject({ + roles: [createRole('111111111111', 'Prod')], + }); + + await expect( + getCredentialsListFromAuth(auth, ['222222222222']), + ).rejects.toThrow('No credentials found for accounts'); + }); + }); + + describe('getCredentialsForAccount', () => { + test('should return credentials for a single account', async () => { + mockSuccessfulAssumeRole(); + const auth = createAuthObject({ + roles: [createRole('111111111111', 'Prod')], + }); + + const result = await getCredentialsForAccount(auth, '111111111111'); + + expect(result.accessKeyId).toBe('ASIATEMP'); + expect(mockAssumeRole).toHaveBeenCalledWith( + EXAMPLE_ACCESS_KEY, + EXAMPLE_SECRET_KEY, + DEFAULT_REGION, + 'arn:aws:iam::111111111111:role/ProdRole', + undefined, + undefined, + ); + }); + }); + + describe('getRoleForAccount', () => { + test('should return the role for a specific accountId', () => { + const role1 = createRole('111111111111', 'Prod'); + const role2 = createRole('222222222222', 'Dev'); + const auth = { roles: [role1, role2] }; + + const result = getRoleForAccount(auth, '111111111111'); + expect(result).toEqual(role1); + }); + + test('should throw error if role is not found', () => { + const auth = { roles: [createRole('111111111111', 'Prod')] }; + + expect(() => getRoleForAccount(auth, '333333333333')).toThrow( + 'Role not found for account', + ); + }); + + test('should return undefined if roles array is empty', () => { + const auth = { roles: [] }; + expect(getRoleForAccount(auth, '111111111111')).toBeUndefined(); + }); + }); + + describe('Dropdowns', () => { + test('getAwsAccountsMultiSelectDropdown should return a dynamic property', () => { + const dropdown = getAwsAccountsMultiSelectDropdown(); + expect(dropdown.accounts).toBeDefined(); + expect(dropdown.accounts.refreshers).toContain('auth'); + }); + + test('getAwsAccountsSingleSelectDropdown should return a dynamic property', () => { + const dropdown = getAwsAccountsSingleSelectDropdown(); + expect(dropdown.accounts).toBeDefined(); + expect(dropdown.accounts.refreshers).toContain('auth'); + }); + + test('dropdown props function should handle empty roles', async () => { + const dropdown = getAwsAccountsSingleSelectDropdown() as any; + const props = await dropdown.accounts.props({ auth: {} }, {} as any); + expect(props['accounts']).toEqual({}); + }); + + test('dropdown props function should map roles to options', async () => { + const dropdown = getAwsAccountsSingleSelectDropdown() as any; + const auth = { + roles: [ + { + accountName: 'Prod', + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProdRole', + }, + ], + }; + const props = await dropdown.accounts.props({ auth }, {} as any); + expect(props['accounts'].options.options).toEqual([ + { label: 'Prod', value: '111111111111' }, + ]); + }); + }); }); From 62df3c52d752b18d5e90c1beec936e7f930bd941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 15:26:23 +0100 Subject: [PATCH 14/25] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/openops/src/lib/aws/get-client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index c444c67c84..0cf4bcb96b 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -26,7 +26,12 @@ export function getAwsClient( system.getBoolean(SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY) ) { config.credentials = async () => { - return getAwsCredentialsFromAzureIdentity(region); + const stsCredentials = await getAwsCredentialsFromAzureIdentity(region); + return { + accessKeyId: stsCredentials.AccessKeyId, + secretAccessKey: stsCredentials.SecretAccessKey, + sessionToken: stsCredentials.SessionToken, + }; }; } From 147179466a8629beb4d51dced5db5ce6741a6721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 15:34:59 +0100 Subject: [PATCH 15/25] Add unit tests --- packages/openops/src/lib/aws/auth.ts | 16 ++++++++++------ packages/openops/src/lib/aws/get-client.ts | 6 +++--- packages/openops/test/aws/auth.test.ts | 22 ++++------------------ 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index cea5eb089b..b06fe345b2 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -346,6 +346,15 @@ For large or complex setups, enhanced features are available, including: } const hasCredentials = auth.accessKeyId && auth.secretAccessKey; + const hasRoles = auth.roles && auth.roles.length > 0; + + if (isImplicitRoleEnabled && !hasCredentials && !hasRoles) { + return { + valid: false, + error: 'Either credentials or at least one role must be provided', + }; + } + if (!isImplicitRoleEnabled || hasCredentials) { const baseCredentialsValidation = await validateBaseCredentials(auth); if (!baseCredentialsValidation.valid) { @@ -353,12 +362,7 @@ For large or complex setups, enhanced features are available, including: } } - const roleValidation = await validateRoleAssumptions(auth); - if (!roleValidation.valid) { - return roleValidation; - } - - return { valid: true }; + return validateRoleAssumptions(auth); }, }); diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index 0cf4bcb96b..c8f97b3b0f 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -28,9 +28,9 @@ export function getAwsClient( config.credentials = async () => { const stsCredentials = await getAwsCredentialsFromAzureIdentity(region); return { - accessKeyId: stsCredentials.AccessKeyId, - secretAccessKey: stsCredentials.SecretAccessKey, - sessionToken: stsCredentials.SessionToken, + accessKeyId: stsCredentials?.AccessKeyId, + secretAccessKey: stsCredentials?.SecretAccessKey, + sessionToken: stsCredentials?.SessionToken, }; }; } diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index 4326716153..f912388c8d 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -184,8 +184,7 @@ describe('AWS Auth Validation', () => { }); describe('Implicit role validation', () => { - test('should validate without calling getAccountId when implicit role enabled and no credentials', async () => { - mockSuccessfulAccountId(); + test('should fail when implicit role enabled, no credentials and no roles', async () => { const freshAmazonAuth = await reimportAuthWithImplicitRole(); const result = await freshAmazonAuth.validate!({ @@ -194,23 +193,10 @@ describe('AWS Auth Validation', () => { } as any, }); - expect(result).toEqual({ valid: true }); - expect(mockGetAccountId).not.toHaveBeenCalled(); - }); - - test('should validate without error even if getAccountId would fail (because it is not called)', 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: 'Either credentials or at least one role must be provided', }); - - expect(result).toEqual({ valid: true }); expect(mockGetAccountId).not.toHaveBeenCalled(); }); }); From 244a0c429b56e0c1349fa07c71e60c57fcc8bb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 15:37:53 +0100 Subject: [PATCH 16/25] Add unit tests --- packages/openops/test/sts-common.test.ts | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/openops/test/sts-common.test.ts b/packages/openops/test/sts-common.test.ts index 364b122be4..f823c5b2a4 100644 --- a/packages/openops/test/sts-common.test.ts +++ b/packages/openops/test/sts-common.test.ts @@ -35,6 +35,24 @@ const ACCESS_KEY_ID = 'random accessKeyId'; const SECRET_ACCESS_KEY = 'random secretAccessKey'; const DEFAULT_REGION = 'random defaultRegion'; +const mockAssumeTargetRoleViaAzureFederation = jest.fn(); + +jest.mock('../src/lib/aws/azure-aws-federation', () => ({ + assumeTargetRoleViaAzureFederation: (...args: any[]) => + mockAssumeTargetRoleViaAzureFederation(...args), +})); + +const mockSystemGetBoolean = jest.fn(); +jest.mock('@openops/server-shared', () => ({ + SharedSystemProp: { + AWS_ENABLE_IMPLICIT_ROLE: 'AWS_ENABLE_IMPLICIT_ROLE', + AWS_USE_AZURE_MANAGED_IDENTITY: 'AWS_USE_AZURE_MANAGED_IDENTITY', + }, + system: { + getBoolean: (...args: any[]) => mockSystemGetBoolean(...args), + }, +})); + import { assumeRole, getAccountId } from '../src/lib/aws/sts-common'; describe('assumeRole tests', () => { @@ -123,4 +141,93 @@ describe('getAccountId tests', () => { }, }); }); + + test('should return empty string if account is missing', async () => { + mockSend.mockResolvedValueOnce({}); + const result = await getAccountId( + { + accessKeyId: ACCESS_KEY_ID, + secretAccessKey: SECRET_ACCESS_KEY, + }, + DEFAULT_REGION, + ); + + expect(result).toBe(''); + }); +}); + +describe('assumeRole with Azure Federation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should use Azure Federation when credentials are missing and enabled', async () => { + mockSystemGetBoolean.mockImplementation((prop) => { + if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') { + return true; + } + if (prop === 'AWS_USE_AZURE_MANAGED_IDENTITY') { + return true; + } + return false; + }); + mockAssumeTargetRoleViaAzureFederation.mockResolvedValue( + 'azure credentials', + ); + + const result = await assumeRole( + '', + '', + DEFAULT_REGION, + 'some role arn', + 'external id', + 'some endpoint', + ); + + expect(result).toBe('azure credentials'); + expect(mockAssumeTargetRoleViaAzureFederation).toHaveBeenCalledWith( + DEFAULT_REGION, + 'some role arn', + 'external id', + 'some endpoint', + ); + expect(mockCreateStsClient).not.toHaveBeenCalled(); + }); + + test('should NOT use Azure Federation when AWS_ENABLE_IMPLICIT_ROLE is false', async () => { + mockSystemGetBoolean.mockImplementation((prop) => { + if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') { + return false; + } + if (prop === 'AWS_USE_AZURE_MANAGED_IDENTITY') { + return true; + } + return false; + }); + + await expect( + assumeRole('', '', DEFAULT_REGION, 'some role arn', 'external id'), + ).rejects.toThrow( + 'AWS credentials are required, please provide accessKeyId and secretAccessKey', + ); + + expect(mockAssumeTargetRoleViaAzureFederation).not.toHaveBeenCalled(); + }); + + test('should NOT use Azure Federation when AWS_USE_AZURE_MANAGED_IDENTITY is false', async () => { + mockSystemGetBoolean.mockImplementation((prop) => { + if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') { + return true; + } + if (prop === 'AWS_USE_AZURE_MANAGED_IDENTITY') { + return false; + } + return false; + }); + + await assumeRole('', '', DEFAULT_REGION, 'some role arn', 'external id'); + + expect(mockAssumeTargetRoleViaAzureFederation).not.toHaveBeenCalled(); + expect(mockCreateStsClient).toHaveBeenCalled(); + }); }); From fdc41d3773e709a28264d75ad92d5b87f3abf7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 16:07:52 +0100 Subject: [PATCH 17/25] Add unit tests --- packages/openops/src/lib/aws/auth.ts | 24 ++- packages/openops/test/aws/auth.test.ts | 159 ++++++++++++++++++- packages/openops/test/aws/get-client.test.ts | 9 +- 3 files changed, 184 insertions(+), 8 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index b06fe345b2..195ca5255c 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -8,6 +8,10 @@ const isImplicitRoleEnabled = system.getBoolean( SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE, ); +const isAzureFederationEnabled = system.getBoolean( + SharedSystemProp.AWS_AZURE_FEDERATION_ROLE_ARN, +); + export interface AwsCredentials { accessKeyId: string; secretAccessKey: string; @@ -249,6 +253,20 @@ async function validateBaseCredentials(auth: any): Promise { } } +function isMissingAuthConfiguration(auth: any): boolean { + const hasRoles = Boolean(auth?.roles?.length); + const implicitRoleEnabled = Boolean(isImplicitRoleEnabled); + const azureFederationEnabled = Boolean(isAzureFederationEnabled); + const hasCredentials = Boolean(auth?.accessKeyId && auth?.secretAccessKey); + + return ( + implicitRoleEnabled && + azureFederationEnabled && + !hasCredentials && + !hasRoles + ); +} + async function validateRoleAssumptions(auth: any): Promise { if (!auth.roles || auth.roles.length === 0) { return { valid: true }; @@ -345,16 +363,14 @@ For large or complex setups, enhanced features are available, including: return fieldValidation; } - const hasCredentials = auth.accessKeyId && auth.secretAccessKey; - const hasRoles = auth.roles && auth.roles.length > 0; - - if (isImplicitRoleEnabled && !hasCredentials && !hasRoles) { + if (isMissingAuthConfiguration(auth)) { return { valid: false, error: 'Either credentials or at least one role must be provided', }; } + const hasCredentials = auth.accessKeyId && auth.secretAccessKey; if (!isImplicitRoleEnabled || hasCredentials) { const baseCredentialsValidation = await validateBaseCredentials(auth); if (!baseCredentialsValidation.valid) { diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index f912388c8d..5b63b13785 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -17,6 +17,7 @@ jest.mock('@openops/server-shared', () => ({ SharedSystemProp: { AWS_ENABLE_IMPLICIT_ROLE: 'AWS_ENABLE_IMPLICIT_ROLE', ENABLE_HOST_SESSION: 'ENABLE_HOST_SESSION', + AWS_AZURE_FEDERATION_ROLE_ARN: 'AWS_AZURE_FEDERATION_ROLE_ARN', }, })); @@ -78,6 +79,19 @@ async function reimportAuthWithImplicitRole() { return freshAmazonAuth; } +async function reimportAuthWithAzureFederation() { + mockSystem.getBoolean.mockImplementation((prop) => { + if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') return true; + if (prop === 'AWS_AZURE_FEDERATION_ROLE_ARN') return true; + return false; + }); + jest.resetModules(); + const { amazonAuth: freshAmazonAuth } = await import( + '../../src/lib/aws/auth' + ); + return freshAmazonAuth; +} + describe('AWS Auth Validation', () => { beforeEach(() => { jest.clearAllMocks(); @@ -199,6 +213,49 @@ describe('AWS Auth Validation', () => { }); expect(mockGetAccountId).not.toHaveBeenCalled(); }); + + test('should fail when azure federation and implicit role enabled, no credentials and no roles', async () => { + const freshAmazonAuth = await reimportAuthWithAzureFederation(); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + } as any, + }); + + expect(result).toEqual({ + valid: false, + error: 'Either credentials or at least one role must be provided', + }); + }); + + test('should skip base credentials validation when implicit role enabled and no credentials provided', async () => { + const freshAmazonAuth = await reimportAuthWithImplicitRole(); + + const result = await freshAmazonAuth.validate!({ + auth: { + defaultRegion: DEFAULT_REGION, + roles: [createRole('111111111111', 'Prod')], + } as any, + }); + + expect(result.valid).toBe(true); + expect(mockGetAccountId).not.toHaveBeenCalled(); + }); + + test('should validate base credentials when implicit role enabled but credentials provided', async () => { + const freshAmazonAuth = await reimportAuthWithImplicitRole(); + mockSuccessfulAccountId(); + + const result = await freshAmazonAuth.validate!({ + auth: createAuthObject({ + roles: [createRole('111111111111', 'Prod')], + }), + }); + + expect(result.valid).toBe(true); + expect(mockGetAccountId).toHaveBeenCalled(); + }); }); describe('Role validation', () => { @@ -329,6 +386,42 @@ describe('AWS Auth Validation', () => { undefined, ); }); + + test('should validate roles in batches of 5', async () => { + mockSuccessfulAssumeRole(); + const roles = Array.from({ length: 7 }, (_, i) => + createRole(`11111111111${i}`, `Account${i}`), + ); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ roles }), + }); + + expect(result.valid).toBe(true); + expect(mockAssumeRole).toHaveBeenCalledTimes(7); + }); + + test('should fail early if a batch fails', async () => { + mockAssumeRole + .mockResolvedValueOnce({}) // 1 + .mockResolvedValueOnce({}) // 2 + .mockRejectedValueOnce(new Error('Batch fail')); // 3 + + const roles = Array.from({ length: 6 }, (_, i) => + createRole(`11111111111${i}`, `Account${i}`), + ); + + const result = await amazonAuth.validate!({ + auth: createAuthObject({ roles }), + }); + + expect(result.valid).toBe(false); + expect((result as any).error).toContain('Batch fail'); + // It should have called 5 times for the first batch, and failed at the first rejection in Promise.all + // but wait, validateRoleBatch uses Promise.allSettled and then checks results. + // so for a batch of 5, it will always call assumeRole 5 times. + expect(mockAssumeRole).toHaveBeenCalledTimes(5); + }); }); describe('Error handling', () => { @@ -476,6 +569,25 @@ describe('AWS Auth Validation', () => { ); }); + test('should handle implicit role in getCredentialsListFromAuth when credentials missing', async () => { + mockSuccessfulAssumeRole(); + const auth = { + defaultRegion: DEFAULT_REGION, + roles: [createRole('111111111111', 'Prod')], + }; + + const result = await getCredentialsListFromAuth(auth, ['111111111111']); + expect(result[0].accessKeyId).toBe('ASIATEMP'); + expect(mockAssumeRole).toHaveBeenCalledWith( + undefined, + undefined, + DEFAULT_REGION, + 'arn:aws:iam::111111111111:role/ProdRole', + undefined, + undefined, + ); + }); + test('should throw error if no matching roles found for accounts', async () => { const auth = createAuthObject({ roles: [createRole('111111111111', 'Prod')], @@ -485,6 +597,33 @@ describe('AWS Auth Validation', () => { getCredentialsListFromAuth(auth, ['222222222222']), ).rejects.toThrow('No credentials found for accounts'); }); + + test('should return multiple assumed role credentials', async () => { + mockAssumeRole.mockResolvedValueOnce({ + AccessKeyId: 'AK1', + SecretAccessKey: 'SK1', + }); + mockAssumeRole.mockResolvedValueOnce({ + AccessKeyId: 'AK2', + SecretAccessKey: 'SK2', + }); + + const auth = createAuthObject({ + roles: [ + createRole('111111111111', 'Prod'), + createRole('222222222222', 'Dev'), + ], + }); + + const result = await getCredentialsListFromAuth(auth, [ + '111111111111', + '222222222222', + ]); + + expect(result).toHaveLength(2); + expect(result[0].accessKeyId).toBe('AK1'); + expect(result[1].accessKeyId).toBe('AK2'); + }); }); describe('getCredentialsForAccount', () => { @@ -551,7 +690,7 @@ describe('AWS Auth Validation', () => { expect(props['accounts']).toEqual({}); }); - test('dropdown props function should map roles to options', async () => { + test('dropdown props function should map roles to options for single select', async () => { const dropdown = getAwsAccountsSingleSelectDropdown() as any; const auth = { roles: [ @@ -565,6 +704,24 @@ describe('AWS Auth Validation', () => { expect(props['accounts'].options.options).toEqual([ { label: 'Prod', value: '111111111111' }, ]); + expect(props['accounts'].displayName).toBe('Account'); + }); + + test('dropdown props function should map roles to options for multi select', async () => { + const dropdown = getAwsAccountsMultiSelectDropdown() as any; + const auth = { + roles: [ + { + accountName: 'Prod', + assumeRoleArn: 'arn:aws:iam::111111111111:role/ProdRole', + }, + ], + }; + const props = await dropdown.accounts.props({ auth }, {} as any); + expect(props['accounts'].options.options).toEqual([ + { label: 'Prod', value: '111111111111' }, + ]); + expect(props['accounts'].displayName).toBe('Accounts'); }); }); }); diff --git a/packages/openops/test/aws/get-client.test.ts b/packages/openops/test/aws/get-client.test.ts index b768c83970..cf59d0e10c 100644 --- a/packages/openops/test/aws/get-client.test.ts +++ b/packages/openops/test/aws/get-client.test.ts @@ -112,8 +112,8 @@ describe('getClient', () => { }); const mockCreds = { - accessKeyId: 'azure-key', - secretAccessKey: 'azure-secret', + AccessKeyId: 'azure-key', + SecretAccessKey: 'azure-secret', }; (getAwsCredentialsFromAzureIdentity as jest.Mock).mockResolvedValue( mockCreds, @@ -130,7 +130,10 @@ describe('getClient', () => { expect(typeof client.config.credentials).toBe('function'); const result = await client.config.credentials(); - expect(result).toEqual(mockCreds); + expect(result).toEqual({ + accessKeyId: 'azure-key', + secretAccessKey: 'azure-secret', + }); expect(getAwsCredentialsFromAzureIdentity).toHaveBeenCalledWith(region); } finally { mockSystem.getBoolean.mockReturnValue(false); From 8f77129828fd3039c3b0c17e2471c46a5dece418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 16:35:49 +0100 Subject: [PATCH 18/25] Add unit tests --- packages/openops/src/lib/aws/auth.ts | 53 +++++++++++++------------- packages/openops/test/aws/auth.test.ts | 4 +- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 195ca5255c..ceb704ea73 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -8,8 +8,8 @@ const isImplicitRoleEnabled = system.getBoolean( SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE, ); -const isAzureFederationEnabled = system.getBoolean( - SharedSystemProp.AWS_AZURE_FEDERATION_ROLE_ARN, +const isAzureManagedIdentityEnabled = system.getBoolean( + SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY, ); export interface AwsCredentials { @@ -252,19 +252,17 @@ async function validateBaseCredentials(auth: any): Promise { }; } } +async function validateManagedIdentityRoles( + auth: any, +): Promise { + if (!auth.roles || auth.roles.length === 0) { + return { + valid: false, + error: 'Either credentials or at least one role must be provided', + }; + } -function isMissingAuthConfiguration(auth: any): boolean { - const hasRoles = Boolean(auth?.roles?.length); - const implicitRoleEnabled = Boolean(isImplicitRoleEnabled); - const azureFederationEnabled = Boolean(isAzureFederationEnabled); - const hasCredentials = Boolean(auth?.accessKeyId && auth?.secretAccessKey); - - return ( - implicitRoleEnabled && - azureFederationEnabled && - !hasCredentials && - !hasRoles - ); + return validateRoleAssumptions(auth); } async function validateRoleAssumptions(auth: any): Promise { @@ -363,22 +361,25 @@ For large or complex setups, enhanced features are available, including: return fieldValidation; } - if (isMissingAuthConfiguration(auth)) { - return { - valid: false, - error: 'Either credentials or at least one role must be provided', - }; + const hasCredentials = auth.accessKeyId && auth.secretAccessKey; + const shouldValidateWithAzureManagedIdentity = + !hasCredentials && isImplicitRoleEnabled && isAzureManagedIdentityEnabled; + + if (shouldValidateWithAzureManagedIdentity) { + return validateManagedIdentityRoles(auth); } - const hasCredentials = auth.accessKeyId && auth.secretAccessKey; - if (!isImplicitRoleEnabled || hasCredentials) { - const baseCredentialsValidation = await validateBaseCredentials(auth); - if (!baseCredentialsValidation.valid) { - return baseCredentialsValidation; - } + const baseCredentialsValidation = await validateBaseCredentials(auth); + if (!baseCredentialsValidation.valid) { + return baseCredentialsValidation; + } + + const roleValidation = await validateRoleAssumptions(auth); + if (!roleValidation.valid) { + return roleValidation; } - return validateRoleAssumptions(auth); + return { valid: true }; }, }); diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index 5b63b13785..b8877e63a2 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -17,7 +17,7 @@ jest.mock('@openops/server-shared', () => ({ SharedSystemProp: { AWS_ENABLE_IMPLICIT_ROLE: 'AWS_ENABLE_IMPLICIT_ROLE', ENABLE_HOST_SESSION: 'ENABLE_HOST_SESSION', - AWS_AZURE_FEDERATION_ROLE_ARN: 'AWS_AZURE_FEDERATION_ROLE_ARN', + AWS_USE_AZURE_MANAGED_IDENTITY: 'AWS_USE_AZURE_MANAGED_IDENTITY', }, })); @@ -82,7 +82,7 @@ async function reimportAuthWithImplicitRole() { async function reimportAuthWithAzureFederation() { mockSystem.getBoolean.mockImplementation((prop) => { if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') return true; - if (prop === 'AWS_AZURE_FEDERATION_ROLE_ARN') return true; + if (prop === 'AWS_USE_AZURE_MANAGED_IDENTITY') return true; return false; }); jest.resetModules(); From feddc1f5222e6c03ff8b7d5f8d385ec1c92bfc3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 16:37:10 +0100 Subject: [PATCH 19/25] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/openops/src/lib/aws/get-client.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index c8f97b3b0f..5489e14cb3 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -27,10 +27,15 @@ export function getAwsClient( ) { config.credentials = async () => { const stsCredentials = await getAwsCredentialsFromAzureIdentity(region); + if (!stsCredentials?.AccessKeyId || !stsCredentials?.SecretAccessKey) { + throw new Error( + 'Failed to obtain AWS credentials from Azure managed identity', + ); + } return { - accessKeyId: stsCredentials?.AccessKeyId, - secretAccessKey: stsCredentials?.SecretAccessKey, - sessionToken: stsCredentials?.SessionToken, + accessKeyId: stsCredentials.AccessKeyId, + secretAccessKey: stsCredentials.SecretAccessKey, + sessionToken: stsCredentials.SessionToken, }; }; } From 268ffb0a3c005876d27004479cf37cc9be9d54f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 16:42:06 +0100 Subject: [PATCH 20/25] Add unit tests --- packages/openops/test/aws/auth.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index b8877e63a2..da24a70de4 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -70,9 +70,11 @@ function mockSuccessfulAccountId() { } async function reimportAuthWithImplicitRole() { - mockSystem.getBoolean.mockReturnValue(true); + mockSystem.getBoolean.mockImplementation((prop) => { + if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') return true; + return false; + }); jest.resetModules(); - mockSystem.getBoolean.mockReturnValue(true); const { amazonAuth: freshAmazonAuth } = await import( '../../src/lib/aws/auth' ); @@ -198,8 +200,9 @@ describe('AWS Auth Validation', () => { }); describe('Implicit role validation', () => { - test('should fail when implicit role enabled, no credentials and no roles', async () => { + test('should succeed when implicit role enabled, no credentials and no roles', async () => { const freshAmazonAuth = await reimportAuthWithImplicitRole(); + mockSuccessfulAccountId(); const result = await freshAmazonAuth.validate!({ auth: { @@ -208,10 +211,9 @@ describe('AWS Auth Validation', () => { }); expect(result).toEqual({ - valid: false, - error: 'Either credentials or at least one role must be provided', + valid: true, }); - expect(mockGetAccountId).not.toHaveBeenCalled(); + expect(mockGetAccountId).toHaveBeenCalled(); }); test('should fail when azure federation and implicit role enabled, no credentials and no roles', async () => { @@ -229,8 +231,9 @@ describe('AWS Auth Validation', () => { }); }); - test('should skip base credentials validation when implicit role enabled and no credentials provided', async () => { + test('should NOT skip base credentials validation when implicit role enabled and no credentials provided', async () => { const freshAmazonAuth = await reimportAuthWithImplicitRole(); + mockSuccessfulAccountId(); const result = await freshAmazonAuth.validate!({ auth: { @@ -240,7 +243,7 @@ describe('AWS Auth Validation', () => { }); expect(result.valid).toBe(true); - expect(mockGetAccountId).not.toHaveBeenCalled(); + expect(mockGetAccountId).toHaveBeenCalled(); }); test('should validate base credentials when implicit role enabled but credentials provided', async () => { From 5e87752a1b51dd2a62bdb904f709e6f82287c832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 17:05:29 +0100 Subject: [PATCH 21/25] WIP --- packages/openops/src/lib/aws/auth.ts | 12 ++++----- .../src/lib/aws/azure-aws-federation.ts | 25 +++++++++++++++++++ packages/openops/src/lib/aws/get-client.ts | 12 ++++++++- .../test/aws/azure-aws-federation.test.ts | 2 ++ 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index ceb704ea73..705177a32a 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -255,12 +255,12 @@ async function validateBaseCredentials(auth: any): Promise { async function validateManagedIdentityRoles( auth: any, ): Promise { - if (!auth.roles || auth.roles.length === 0) { - return { - valid: false, - error: 'Either credentials or at least one role must be provided', - }; - } + // if (!auth.roles || auth.roles.length === 0) { + // return { + // valid: false, + // error: 'Either credentials or at least one role must be provided', + // }; + // } return validateRoleAssumptions(auth); } diff --git a/packages/openops/src/lib/aws/azure-aws-federation.ts b/packages/openops/src/lib/aws/azure-aws-federation.ts index b66ab02aa6..abec45ad71 100644 --- a/packages/openops/src/lib/aws/azure-aws-federation.ts +++ b/packages/openops/src/lib/aws/azure-aws-federation.ts @@ -8,6 +8,15 @@ import { logger, SharedSystemProp, system } from '@openops/server-shared'; import { v4 as uuidv4 } from 'uuid'; import { getAwsClient } from './get-client'; +let cachedCredentials: { + credentials: Credentials; + expiresAt: number; +} | null = null; + +export function clearAzureFederationCache() { + cachedCredentials = null; +} + export async function assumeTargetRoleViaAzureFederation( defaultRegion: string, roleArn: string, @@ -47,6 +56,13 @@ export async function assumeTargetRoleViaAzureFederation( export async function getAwsCredentialsFromAzureIdentity( defaultRegion: string, ): Promise { + const now = Date.now(); + const buffer = 5 * 60 * 1000; + + if (cachedCredentials && cachedCredentials.expiresAt > now + buffer) { + return cachedCredentials.credentials; + } + const webIdentityToken = await getAzureOidcTokenForAws(); const client = new STSClient({ region: defaultRegion, @@ -64,6 +80,15 @@ export async function getAwsCredentialsFromAzureIdentity( const response = await client.send(command); + if (response.Credentials) { + cachedCredentials = { + credentials: response.Credentials, + expiresAt: response.Credentials.Expiration + ? new Date(response.Credentials.Expiration).getTime() + : now + 3600 * 1000, + }; + } + return response.Credentials; } diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index 5489e14cb3..b3430838b4 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -25,18 +25,28 @@ export function getAwsClient( } else if ( system.getBoolean(SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY) ) { + let clientCredentials: any = null; config.credentials = async () => { + if ( + clientCredentials && + (!clientCredentials.expiration || + clientCredentials.expiration > new Date()) + ) { + return clientCredentials; + } const stsCredentials = await getAwsCredentialsFromAzureIdentity(region); if (!stsCredentials?.AccessKeyId || !stsCredentials?.SecretAccessKey) { throw new Error( 'Failed to obtain AWS credentials from Azure managed identity', ); } - return { + clientCredentials = { accessKeyId: stsCredentials.AccessKeyId, secretAccessKey: stsCredentials.SecretAccessKey, sessionToken: stsCredentials.SessionToken, + expiration: stsCredentials.Expiration, }; + return clientCredentials; }; } diff --git a/packages/openops/test/aws/azure-aws-federation.test.ts b/packages/openops/test/aws/azure-aws-federation.test.ts index f0e163f350..2e07081d31 100644 --- a/packages/openops/test/aws/azure-aws-federation.test.ts +++ b/packages/openops/test/aws/azure-aws-federation.test.ts @@ -7,6 +7,7 @@ import { logger, system } from '@openops/server-shared'; import { v4 as uuidv4 } from 'uuid'; import { assumeTargetRoleViaAzureFederation, + clearAzureFederationCache, getAwsCredentialsFromAzureIdentity, } from '../../src/lib/aws/azure-aws-federation'; import { getAwsClient } from '../../src/lib/aws/get-client'; @@ -35,6 +36,7 @@ describe('azure-aws-federation', () => { beforeEach(() => { jest.clearAllMocks(); + clearAzureFederationCache(); (uuidv4 as jest.Mock).mockReturnValue(mockUuid); globalThis.fetch = jest.fn(); }); From 88e45cc81fdb8e67b77a7e25effad768a20543c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 5 May 2026 18:03:10 +0100 Subject: [PATCH 22/25] WIP --- packages/openops/src/lib/aws/auth.ts | 14 +-------- packages/openops/test/aws/auth.test.ts | 41 ++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 705177a32a..2616f089b2 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -252,18 +252,6 @@ async function validateBaseCredentials(auth: any): Promise { }; } } -async function validateManagedIdentityRoles( - auth: any, -): Promise { - // if (!auth.roles || auth.roles.length === 0) { - // return { - // valid: false, - // error: 'Either credentials or at least one role must be provided', - // }; - // } - - return validateRoleAssumptions(auth); -} async function validateRoleAssumptions(auth: any): Promise { if (!auth.roles || auth.roles.length === 0) { @@ -366,7 +354,7 @@ For large or complex setups, enhanced features are available, including: !hasCredentials && isImplicitRoleEnabled && isAzureManagedIdentityEnabled; if (shouldValidateWithAzureManagedIdentity) { - return validateManagedIdentityRoles(auth); + return validateRoleAssumptions(auth); } const baseCredentialsValidation = await validateBaseCredentials(auth); diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index da24a70de4..a8490496d8 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -216,7 +216,7 @@ describe('AWS Auth Validation', () => { expect(mockGetAccountId).toHaveBeenCalled(); }); - test('should fail when azure federation and implicit role enabled, no credentials and no roles', async () => { + test('should succeed when azure federation and implicit role enabled, no credentials and no roles', async () => { const freshAmazonAuth = await reimportAuthWithAzureFederation(); const result = await freshAmazonAuth.validate!({ @@ -226,8 +226,7 @@ describe('AWS Auth Validation', () => { }); expect(result).toEqual({ - valid: false, - error: 'Either credentials or at least one role must be provided', + valid: true, }); }); @@ -532,6 +531,15 @@ describe('AWS Auth Validation', () => { undefined, ); }); + + test('should throw error if assumeRole fails', async () => { + mockAssumeRole.mockRejectedValue(new Error('STS Error')); + const auth = createAuthObject({ + assumeRoleArn: 'arn:aws:iam::123456789012:role/TestRole', + }); + + await expect(getCredentialsFromAuth(auth)).rejects.toThrow('STS Error'); + }); }); describe('getCredentialsListFromAuth', () => { @@ -627,6 +635,23 @@ describe('AWS Auth Validation', () => { expect(result[0].accessKeyId).toBe('AK1'); expect(result[1].accessKeyId).toBe('AK2'); }); + + test('should throw error if any assumeRole fails', async () => { + mockAssumeRole + .mockResolvedValueOnce({ AccessKeyId: 'AK1', SecretAccessKey: 'SK1' }) + .mockRejectedValueOnce(new Error('STS Error')); + + const auth = createAuthObject({ + roles: [ + createRole('111111111111', 'Prod'), + createRole('222222222222', 'Dev'), + ], + }); + + await expect( + getCredentialsListFromAuth(auth, ['111111111111', '222222222222']), + ).rejects.toThrow('STS Error'); + }); }); describe('getCredentialsForAccount', () => { @@ -648,6 +673,16 @@ describe('AWS Auth Validation', () => { undefined, ); }); + + test('should throw error if no matching role is found for the account', async () => { + const auth = createAuthObject({ + roles: [createRole('111111111111', 'Prod')], + }); + + await expect( + getCredentialsForAccount(auth, '222222222222'), + ).rejects.toThrow('No credentials found for accounts'); + }); }); describe('getRoleForAccount', () => { From 6bf1ed9d761f0377a506340d7649ac623851f16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 6 May 2026 10:08:22 +0100 Subject: [PATCH 23/25] WIP --- packages/openops/src/lib/aws/auth.ts | 14 +- packages/openops/src/lib/aws/get-client.ts | 141 ++++++++++++++++----- 2 files changed, 114 insertions(+), 41 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index 2616f089b2..b1731f2254 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -349,13 +349,13 @@ For large or complex setups, enhanced features are available, including: return fieldValidation; } - const hasCredentials = auth.accessKeyId && auth.secretAccessKey; - const shouldValidateWithAzureManagedIdentity = - !hasCredentials && isImplicitRoleEnabled && isAzureManagedIdentityEnabled; - - if (shouldValidateWithAzureManagedIdentity) { - return validateRoleAssumptions(auth); - } + // const hasCredentials = auth.accessKeyId && auth.secretAccessKey; + // const shouldValidateWithAzureManagedIdentity = + // !hasCredentials && isImplicitRoleEnabled && isAzureManagedIdentityEnabled; + // + // if (shouldValidateWithAzureManagedIdentity) { + // return validateRoleAssumptions(auth); + // } const baseCredentialsValidation = await validateBaseCredentials(auth); if (!baseCredentialsValidation.valid) { diff --git a/packages/openops/src/lib/aws/get-client.ts b/packages/openops/src/lib/aws/get-client.ts index b3430838b4..ab917def26 100644 --- a/packages/openops/src/lib/aws/get-client.ts +++ b/packages/openops/src/lib/aws/get-client.ts @@ -2,22 +2,35 @@ import { SharedSystemProp, system } from '@openops/server-shared'; import { AwsCredentials } from './auth'; import { getAwsCredentialsFromAzureIdentity } from './azure-aws-federation'; +type AwsClientConfig = { + region: string; + credentials?: AwsCredentials | (() => Promise); + endpoint?: string; +}; + +type CachedAwsCredentials = AwsCredentials & { + expiration?: Date; +}; + +const azureCredentialCache = new Map< + string, + { + credentials: CachedAwsCredentials | null; + promise: Promise | null; + } +>(); + export function getAwsClient( - ClientConstructor: new (config: { - region: string; - credentials: AwsCredentials | undefined; - endpoint?: string; - }) => T, + ClientConstructor: new (config: AwsClientConfig) => T, credentials: AwsCredentials, region: string, ): T { - const config: any = { region }; + const config: AwsClientConfig = { + region, + }; + if (credentials.accessKeyId) { - config.credentials = { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }; + config.credentials = createStaticCredentials(credentials); } else if (!system.getBoolean(SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE)) { throw new Error( 'AWS credentials are required, please provide accessKeyId and secretAccessKey', @@ -25,29 +38,7 @@ export function getAwsClient( } else if ( system.getBoolean(SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY) ) { - let clientCredentials: any = null; - config.credentials = async () => { - if ( - clientCredentials && - (!clientCredentials.expiration || - clientCredentials.expiration > new Date()) - ) { - return clientCredentials; - } - const stsCredentials = await getAwsCredentialsFromAzureIdentity(region); - if (!stsCredentials?.AccessKeyId || !stsCredentials?.SecretAccessKey) { - throw new Error( - 'Failed to obtain AWS credentials from Azure managed identity', - ); - } - clientCredentials = { - accessKeyId: stsCredentials.AccessKeyId, - secretAccessKey: stsCredentials.SecretAccessKey, - sessionToken: stsCredentials.SessionToken, - expiration: stsCredentials.Expiration, - }; - return clientCredentials; - }; + config.credentials = createAzureManagedIdentityCredentialsProvider(region); } if (credentials.endpoint) { @@ -56,3 +47,85 @@ export function getAwsClient( return new ClientConstructor(config); } + +function createStaticCredentials(credentials: AwsCredentials): AwsCredentials { + return { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }; +} + +function createAzureManagedIdentityCredentialsProvider( + region: string, +): () => Promise { + const cache = getOrCreateAzureCredentialCache(region); + + return async () => { + if (hasValidCredentials(cache.credentials)) { + return cache.credentials; + } + + if (cache.promise) { + return cache.promise; + } + + cache.promise = fetchAzureManagedIdentityCredentials(region); + + try { + cache.credentials = await cache.promise; + return cache.credentials; + } finally { + cache.promise = null; + } + }; +} + +function getOrCreateAzureCredentialCache(region: string) { + let cache = azureCredentialCache.get(region); + + if (!cache) { + cache = { + credentials: null, + promise: null, + }; + + azureCredentialCache.set(region, cache); + } + + return cache; +} + +function hasValidCredentials( + credentials: CachedAwsCredentials | null, +): credentials is CachedAwsCredentials { + if (!credentials) { + return false; + } + + if (!credentials.expiration) { + return true; + } + + // Refresh 1 minute before expiration + return credentials.expiration.getTime() > Date.now() + 60_000; +} + +async function fetchAzureManagedIdentityCredentials( + region: string, +): Promise { + const stsCredentials = await getAwsCredentialsFromAzureIdentity(region); + + if (!stsCredentials?.AccessKeyId || !stsCredentials?.SecretAccessKey) { + throw new Error( + 'Failed to obtain AWS credentials from Azure managed identity', + ); + } + + return { + accessKeyId: stsCredentials.AccessKeyId, + secretAccessKey: stsCredentials.SecretAccessKey, + sessionToken: stsCredentials.SessionToken, + expiration: stsCredentials.Expiration, + }; +} From 5bcd072ed3bd0e7bcd07bbb66fbed8990ad9a140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 6 May 2026 10:19:46 +0100 Subject: [PATCH 24/25] WIP --- packages/openops/src/lib/aws/auth.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/openops/src/lib/aws/auth.ts b/packages/openops/src/lib/aws/auth.ts index b1731f2254..311d9cb9b8 100644 --- a/packages/openops/src/lib/aws/auth.ts +++ b/packages/openops/src/lib/aws/auth.ts @@ -8,10 +8,6 @@ const isImplicitRoleEnabled = system.getBoolean( SharedSystemProp.AWS_ENABLE_IMPLICIT_ROLE, ); -const isAzureManagedIdentityEnabled = system.getBoolean( - SharedSystemProp.AWS_USE_AZURE_MANAGED_IDENTITY, -); - export interface AwsCredentials { accessKeyId: string; secretAccessKey: string; @@ -349,14 +345,6 @@ For large or complex setups, enhanced features are available, including: return fieldValidation; } - // const hasCredentials = auth.accessKeyId && auth.secretAccessKey; - // const shouldValidateWithAzureManagedIdentity = - // !hasCredentials && isImplicitRoleEnabled && isAzureManagedIdentityEnabled; - // - // if (shouldValidateWithAzureManagedIdentity) { - // return validateRoleAssumptions(auth); - // } - const baseCredentialsValidation = await validateBaseCredentials(auth); if (!baseCredentialsValidation.valid) { return baseCredentialsValidation; From 5e56de6d8062a0c20c25af4c99b861662415dd59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 6 May 2026 10:20:36 +0100 Subject: [PATCH 25/25] WIP --- packages/openops/test/aws/auth.test.ts | 390 ++----------------------- 1 file changed, 21 insertions(+), 369 deletions(-) diff --git a/packages/openops/test/aws/auth.test.ts b/packages/openops/test/aws/auth.test.ts index a8490496d8..6bc8103f6a 100644 --- a/packages/openops/test/aws/auth.test.ts +++ b/packages/openops/test/aws/auth.test.ts @@ -17,19 +17,10 @@ jest.mock('@openops/server-shared', () => ({ SharedSystemProp: { AWS_ENABLE_IMPLICIT_ROLE: 'AWS_ENABLE_IMPLICIT_ROLE', ENABLE_HOST_SESSION: 'ENABLE_HOST_SESSION', - AWS_USE_AZURE_MANAGED_IDENTITY: 'AWS_USE_AZURE_MANAGED_IDENTITY', }, })); -import { - amazonAuth, - getAwsAccountsMultiSelectDropdown, - getAwsAccountsSingleSelectDropdown, - getCredentialsForAccount, - getCredentialsFromAuth, - getCredentialsListFromAuth, - getRoleForAccount, -} from '../../src/lib/aws/auth'; +import { amazonAuth } from '../../src/lib/aws/auth'; const EXAMPLE_ACCESS_KEY = 'AKIAIOSFODNN7EXAMPLE'; const EXAMPLE_SECRET_KEY = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; @@ -70,24 +61,9 @@ function mockSuccessfulAccountId() { } async function reimportAuthWithImplicitRole() { - mockSystem.getBoolean.mockImplementation((prop) => { - if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') return true; - return false; - }); - jest.resetModules(); - const { amazonAuth: freshAmazonAuth } = await import( - '../../src/lib/aws/auth' - ); - return freshAmazonAuth; -} - -async function reimportAuthWithAzureFederation() { - mockSystem.getBoolean.mockImplementation((prop) => { - if (prop === 'AWS_ENABLE_IMPLICIT_ROLE') return true; - if (prop === 'AWS_USE_AZURE_MANAGED_IDENTITY') return true; - return false; - }); + mockSystem.getBoolean.mockReturnValue(true); jest.resetModules(); + mockSystem.getBoolean.mockReturnValue(true); const { amazonAuth: freshAmazonAuth } = await import( '../../src/lib/aws/auth' ); @@ -200,9 +176,9 @@ describe('AWS Auth Validation', () => { }); describe('Implicit role validation', () => { - test('should succeed when implicit role enabled, no credentials and no roles', async () => { - const freshAmazonAuth = await reimportAuthWithImplicitRole(); + test('should validate with GetCallerIdentity when implicit role enabled and no credentials', async () => { mockSuccessfulAccountId(); + const freshAmazonAuth = await reimportAuthWithImplicitRole(); const result = await freshAmazonAuth.validate!({ auth: { @@ -210,14 +186,22 @@ describe('AWS Auth Validation', () => { } as any, }); - expect(result).toEqual({ - valid: true, - }); - expect(mockGetAccountId).toHaveBeenCalled(); + expect(result).toEqual({ valid: true }); + expect(mockGetAccountId).toHaveBeenCalledWith( + { + accessKeyId: '', + secretAccessKey: '', + endpoint: undefined, + }, + DEFAULT_REGION, + ); }); - test('should succeed when azure federation and implicit role enabled, no credentials and no roles', async () => { - const freshAmazonAuth = await reimportAuthWithAzureFederation(); + 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: { @@ -226,37 +210,9 @@ describe('AWS Auth Validation', () => { }); expect(result).toEqual({ - valid: true, - }); - }); - - test('should NOT skip base credentials validation when implicit role enabled and no credentials provided', async () => { - const freshAmazonAuth = await reimportAuthWithImplicitRole(); - mockSuccessfulAccountId(); - - const result = await freshAmazonAuth.validate!({ - auth: { - defaultRegion: DEFAULT_REGION, - roles: [createRole('111111111111', 'Prod')], - } as any, + valid: false, + error: 'Unable to locate credentials', }); - - expect(result.valid).toBe(true); - expect(mockGetAccountId).toHaveBeenCalled(); - }); - - test('should validate base credentials when implicit role enabled but credentials provided', async () => { - const freshAmazonAuth = await reimportAuthWithImplicitRole(); - mockSuccessfulAccountId(); - - const result = await freshAmazonAuth.validate!({ - auth: createAuthObject({ - roles: [createRole('111111111111', 'Prod')], - }), - }); - - expect(result.valid).toBe(true); - expect(mockGetAccountId).toHaveBeenCalled(); }); }); @@ -388,42 +344,6 @@ describe('AWS Auth Validation', () => { undefined, ); }); - - test('should validate roles in batches of 5', async () => { - mockSuccessfulAssumeRole(); - const roles = Array.from({ length: 7 }, (_, i) => - createRole(`11111111111${i}`, `Account${i}`), - ); - - const result = await amazonAuth.validate!({ - auth: createAuthObject({ roles }), - }); - - expect(result.valid).toBe(true); - expect(mockAssumeRole).toHaveBeenCalledTimes(7); - }); - - test('should fail early if a batch fails', async () => { - mockAssumeRole - .mockResolvedValueOnce({}) // 1 - .mockResolvedValueOnce({}) // 2 - .mockRejectedValueOnce(new Error('Batch fail')); // 3 - - const roles = Array.from({ length: 6 }, (_, i) => - createRole(`11111111111${i}`, `Account${i}`), - ); - - const result = await amazonAuth.validate!({ - auth: createAuthObject({ roles }), - }); - - expect(result.valid).toBe(false); - expect((result as any).error).toContain('Batch fail'); - // It should have called 5 times for the first batch, and failed at the first rejection in Promise.all - // but wait, validateRoleBatch uses Promise.allSettled and then checks results. - // so for a batch of 5, it will always call assumeRole 5 times. - expect(mockAssumeRole).toHaveBeenCalledTimes(5); - }); }); describe('Error handling', () => { @@ -494,272 +414,4 @@ describe('AWS Auth Validation', () => { expect(freshAmazonAuth.props.secretAccessKey.required).toBe(false); }); }); - - describe('getCredentialsFromAuth', () => { - test('should return base credentials if no assumeRoleArn is provided', async () => { - const auth = createAuthObject(); - const result = await getCredentialsFromAuth(auth); - - expect(result).toEqual({ - accessKeyId: EXAMPLE_ACCESS_KEY, - secretAccessKey: EXAMPLE_SECRET_KEY, - endpoint: undefined, - }); - expect(mockAssumeRole).not.toHaveBeenCalled(); - }); - - test('should return assumed role credentials if assumeRoleArn is provided', async () => { - mockSuccessfulAssumeRole(); - const auth = createAuthObject({ - assumeRoleArn: 'arn:aws:iam::123456789012:role/TestRole', - assumeRoleExternalId: 'ext-id', - }); - const result = await getCredentialsFromAuth(auth); - - expect(result).toEqual({ - accessKeyId: 'ASIATEMP', - secretAccessKey: 'tempSecret', - sessionToken: 'tempToken', - endpoint: undefined, - }); - expect(mockAssumeRole).toHaveBeenCalledWith( - EXAMPLE_ACCESS_KEY, - EXAMPLE_SECRET_KEY, - DEFAULT_REGION, - 'arn:aws:iam::123456789012:role/TestRole', - 'ext-id', - undefined, - ); - }); - - test('should throw error if assumeRole fails', async () => { - mockAssumeRole.mockRejectedValue(new Error('STS Error')); - const auth = createAuthObject({ - assumeRoleArn: 'arn:aws:iam::123456789012:role/TestRole', - }); - - await expect(getCredentialsFromAuth(auth)).rejects.toThrow('STS Error'); - }); - }); - - describe('getCredentialsListFromAuth', () => { - test('should return base credentials if no roles are provided', async () => { - const auth = createAuthObject(); - const result = await getCredentialsListFromAuth(auth); - - expect(result).toEqual([ - { - accessKeyId: EXAMPLE_ACCESS_KEY, - secretAccessKey: EXAMPLE_SECRET_KEY, - endpoint: undefined, - }, - ]); - }); - - test('should return assumed role credentials for specified accounts', async () => { - mockSuccessfulAssumeRole(); - const auth = createAuthObject({ - roles: [ - createRole('111111111111', 'Prod'), - createRole('222222222222', 'Dev'), - ], - }); - - const result = await getCredentialsListFromAuth(auth, ['111111111111']); - - expect(result).toHaveLength(1); - expect(result[0].accessKeyId).toBe('ASIATEMP'); - expect(mockAssumeRole).toHaveBeenCalledTimes(1); - expect(mockAssumeRole).toHaveBeenCalledWith( - EXAMPLE_ACCESS_KEY, - EXAMPLE_SECRET_KEY, - DEFAULT_REGION, - 'arn:aws:iam::111111111111:role/ProdRole', - undefined, - undefined, - ); - }); - - test('should handle implicit role in getCredentialsListFromAuth when credentials missing', async () => { - mockSuccessfulAssumeRole(); - const auth = { - defaultRegion: DEFAULT_REGION, - roles: [createRole('111111111111', 'Prod')], - }; - - const result = await getCredentialsListFromAuth(auth, ['111111111111']); - expect(result[0].accessKeyId).toBe('ASIATEMP'); - expect(mockAssumeRole).toHaveBeenCalledWith( - undefined, - undefined, - DEFAULT_REGION, - 'arn:aws:iam::111111111111:role/ProdRole', - undefined, - undefined, - ); - }); - - test('should throw error if no matching roles found for accounts', async () => { - const auth = createAuthObject({ - roles: [createRole('111111111111', 'Prod')], - }); - - await expect( - getCredentialsListFromAuth(auth, ['222222222222']), - ).rejects.toThrow('No credentials found for accounts'); - }); - - test('should return multiple assumed role credentials', async () => { - mockAssumeRole.mockResolvedValueOnce({ - AccessKeyId: 'AK1', - SecretAccessKey: 'SK1', - }); - mockAssumeRole.mockResolvedValueOnce({ - AccessKeyId: 'AK2', - SecretAccessKey: 'SK2', - }); - - const auth = createAuthObject({ - roles: [ - createRole('111111111111', 'Prod'), - createRole('222222222222', 'Dev'), - ], - }); - - const result = await getCredentialsListFromAuth(auth, [ - '111111111111', - '222222222222', - ]); - - expect(result).toHaveLength(2); - expect(result[0].accessKeyId).toBe('AK1'); - expect(result[1].accessKeyId).toBe('AK2'); - }); - - test('should throw error if any assumeRole fails', async () => { - mockAssumeRole - .mockResolvedValueOnce({ AccessKeyId: 'AK1', SecretAccessKey: 'SK1' }) - .mockRejectedValueOnce(new Error('STS Error')); - - const auth = createAuthObject({ - roles: [ - createRole('111111111111', 'Prod'), - createRole('222222222222', 'Dev'), - ], - }); - - await expect( - getCredentialsListFromAuth(auth, ['111111111111', '222222222222']), - ).rejects.toThrow('STS Error'); - }); - }); - - describe('getCredentialsForAccount', () => { - test('should return credentials for a single account', async () => { - mockSuccessfulAssumeRole(); - const auth = createAuthObject({ - roles: [createRole('111111111111', 'Prod')], - }); - - const result = await getCredentialsForAccount(auth, '111111111111'); - - expect(result.accessKeyId).toBe('ASIATEMP'); - expect(mockAssumeRole).toHaveBeenCalledWith( - EXAMPLE_ACCESS_KEY, - EXAMPLE_SECRET_KEY, - DEFAULT_REGION, - 'arn:aws:iam::111111111111:role/ProdRole', - undefined, - undefined, - ); - }); - - test('should throw error if no matching role is found for the account', async () => { - const auth = createAuthObject({ - roles: [createRole('111111111111', 'Prod')], - }); - - await expect( - getCredentialsForAccount(auth, '222222222222'), - ).rejects.toThrow('No credentials found for accounts'); - }); - }); - - describe('getRoleForAccount', () => { - test('should return the role for a specific accountId', () => { - const role1 = createRole('111111111111', 'Prod'); - const role2 = createRole('222222222222', 'Dev'); - const auth = { roles: [role1, role2] }; - - const result = getRoleForAccount(auth, '111111111111'); - expect(result).toEqual(role1); - }); - - test('should throw error if role is not found', () => { - const auth = { roles: [createRole('111111111111', 'Prod')] }; - - expect(() => getRoleForAccount(auth, '333333333333')).toThrow( - 'Role not found for account', - ); - }); - - test('should return undefined if roles array is empty', () => { - const auth = { roles: [] }; - expect(getRoleForAccount(auth, '111111111111')).toBeUndefined(); - }); - }); - - describe('Dropdowns', () => { - test('getAwsAccountsMultiSelectDropdown should return a dynamic property', () => { - const dropdown = getAwsAccountsMultiSelectDropdown(); - expect(dropdown.accounts).toBeDefined(); - expect(dropdown.accounts.refreshers).toContain('auth'); - }); - - test('getAwsAccountsSingleSelectDropdown should return a dynamic property', () => { - const dropdown = getAwsAccountsSingleSelectDropdown(); - expect(dropdown.accounts).toBeDefined(); - expect(dropdown.accounts.refreshers).toContain('auth'); - }); - - test('dropdown props function should handle empty roles', async () => { - const dropdown = getAwsAccountsSingleSelectDropdown() as any; - const props = await dropdown.accounts.props({ auth: {} }, {} as any); - expect(props['accounts']).toEqual({}); - }); - - test('dropdown props function should map roles to options for single select', async () => { - const dropdown = getAwsAccountsSingleSelectDropdown() as any; - const auth = { - roles: [ - { - accountName: 'Prod', - assumeRoleArn: 'arn:aws:iam::111111111111:role/ProdRole', - }, - ], - }; - const props = await dropdown.accounts.props({ auth }, {} as any); - expect(props['accounts'].options.options).toEqual([ - { label: 'Prod', value: '111111111111' }, - ]); - expect(props['accounts'].displayName).toBe('Account'); - }); - - test('dropdown props function should map roles to options for multi select', async () => { - const dropdown = getAwsAccountsMultiSelectDropdown() as any; - const auth = { - roles: [ - { - accountName: 'Prod', - assumeRoleArn: 'arn:aws:iam::111111111111:role/ProdRole', - }, - ], - }; - const props = await dropdown.accounts.props({ auth }, {} as any); - expect(props['accounts'].options.options).toEqual([ - { label: 'Prod', value: '111111111111' }, - ]); - expect(props['accounts'].displayName).toBe('Accounts'); - }); - }); });