Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-6094) RFC: Allow the Mongo-AWS creds provider to handle assuming an AWS IAM role. #4081

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/client-side-encryption/auto_encrypter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ export class AutoEncrypter {
* the original ones.
*/
async askForKMSCredentials(): Promise<KMSProviders> {
return await refreshKMSCredentials(this._kmsProviders);
const creds = this._client.options.credentials?.mechanismProperties || {};
return await refreshKMSCredentials(this._kmsProviders, creds);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/client-side-encryption/client_encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,8 @@ export class ClientEncryption {
* the original ones.
*/
async askForKMSCredentials(): Promise<KMSProviders> {
return await refreshKMSCredentials(this._kmsProviders);
const authProps = this._client.options.credentials?.mechanismProperties || {};
return await refreshKMSCredentials(this._kmsProviders, authProps);
}

static get libmongocryptVersion() {
Expand Down
8 changes: 6 additions & 2 deletions src/client-side-encryption/providers/aws.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { AWSSDKCredentialProvider } from '../../cmap/auth/aws_temporary_credentials';
import { type AuthMechanismProperties } from '../../cmap/auth/mongo_credentials';
import { type KMSProviders } from '.';

/**
* @internal
*/
export async function loadAWSCredentials(kmsProviders: KMSProviders): Promise<KMSProviders> {
export async function loadAWSCredentials(
kmsProviders: KMSProviders,
props: AuthMechanismProperties
): Promise<KMSProviders> {
const credentialProvider = new AWSSDKCredentialProvider();

// We shouldn't ever receive a response from the AWS SDK that doesn't have a `SecretAccessKey`
Expand All @@ -14,7 +18,7 @@ export async function loadAWSCredentials(kmsProviders: KMSProviders): Promise<KM
SecretAccessKey = '',
AccessKeyId = '',
Token
} = await credentialProvider.getCredentials();
} = await credentialProvider.getCredentials(props);
const aws: NonNullable<KMSProviders['aws']> = {
secretAccessKey: SecretAccessKey,
accessKeyId: AccessKeyId
Expand Down
8 changes: 6 additions & 2 deletions src/client-side-encryption/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type AuthMechanismProperties } from '../../cmap/auth/mongo_credentials';
import { loadAWSCredentials } from './aws';
import { loadAzureCredentials } from './azure';
import { loadGCPCredentials } from './gcp';
Expand Down Expand Up @@ -150,11 +151,14 @@ export function isEmptyCredentials(
*
* @internal
*/
export async function refreshKMSCredentials(kmsProviders: KMSProviders): Promise<KMSProviders> {
export async function refreshKMSCredentials(
kmsProviders: KMSProviders,
props: AuthMechanismProperties
): Promise<KMSProviders> {
let finalKMSProviders = kmsProviders;

if (isEmptyCredentials('aws', kmsProviders)) {
finalKMSProviders = await loadAWSCredentials(finalKMSProviders);
finalKMSProviders = await loadAWSCredentials(finalKMSProviders, props);
}

if (isEmptyCredentials('gcp', kmsProviders)) {
Expand Down
39 changes: 35 additions & 4 deletions src/cmap/auth/aws_temporary_credentials.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type AWSCredentials, getAwsCredentialProvider } from '../../deps';
import { MongoAWSError } from '../../error';
import { request } from '../../utils';
import { type AuthMechanismProperties } from './mongo_credentials';

const AWS_RELATIVE_URI = 'http://169.254.170.2';
const AWS_EC2_URI = 'http://169.254.169.254';
Expand All @@ -27,7 +28,7 @@ export interface AWSTempCredentials {
* Fetches temporary AWS credentials.
*/
export abstract class AWSTemporaryCredentialProvider {
abstract getCredentials(): Promise<AWSTempCredentials>;
abstract getCredentials(props: AuthMechanismProperties): Promise<AWSTempCredentials>;
private static _awsSDK: ReturnType<typeof getAwsCredentialProvider>;
protected static get awsSDK() {
AWSTemporaryCredentialProvider._awsSDK ??= getAwsCredentialProvider();
Expand All @@ -42,6 +43,7 @@ export abstract class AWSTemporaryCredentialProvider {
/** @internal */
export class AWSSDKCredentialProvider extends AWSTemporaryCredentialProvider {
private _provider?: () => Promise<AWSCredentials>;
private _roleProviders: { [roleArn: string]: () => Promise<AWSCredentials> } = {};
/**
* The AWS SDK caches credentials automatically and handles refresh when the credentials have expired.
* To ensure this occurs, we need to cache the `provider` returned by the AWS sdk and re-use it when fetching credentials.
Expand Down Expand Up @@ -104,7 +106,7 @@ export class AWSSDKCredentialProvider extends AWSTemporaryCredentialProvider {
return this._provider;
}

override async getCredentials(): Promise<AWSTempCredentials> {
override async getCredentials(props: AuthMechanismProperties): Promise<AWSTempCredentials> {
/*
* Creates a credential provider that will attempt to find credentials from the
* following sources (listed in order of precedence):
Expand All @@ -116,7 +118,36 @@ export class AWSSDKCredentialProvider extends AWSTemporaryCredentialProvider {
* - The EC2/ECS Instance Metadata Service
*/
try {
const creds = await this.provider();
const roleArn = props.AWS_ROLE_ARN;
let provider: () => Promise<AWSCredentials>;
if (roleArn) {
// check we really hvae the AWS SDK - we should, but typescript doesn't know it at this point
if ('kModuleError' in AWSTemporaryCredentialProvider.awsSDK) {
throw AWSTemporaryCredentialProvider.awsSDK.kModuleError;
}
// maintain a list of providers for each role arn - I honestly can't imagine any way that
// multiple would be needed, but the way credentials are passed around, I guess it could technically
// be possible for the configuration to get changed at runtime by a user doing weird stuff?
// If we say 'a user can never change the credentials after creating a MongoClient', then the
// map would be overkill and we could memoize a single provider.
if (!this._roleProviders[roleArn]) {
// set up the aws cred provider from @aws-sdk/credential-providers that assumes a role for you
this._roleProviders[roleArn] =
AWSTemporaryCredentialProvider.awsSDK.fromTemporaryCredentials({
// tell it to start from our base provider that we've done a bit of special configuraiton to,
masterCredentials: this.provider,
// and then go and assume the desired role.
params: {
RoleArn: roleArn,
RoleSessionName: 'mongodb'
}
});
}
provider = this._roleProviders[roleArn];
} else {
provider = this.provider;
}
const creds = await provider();
return {
AccessKeyId: creds.accessKeyId,
SecretAccessKey: creds.secretAccessKey,
Expand All @@ -135,7 +166,7 @@ export class AWSSDKCredentialProvider extends AWSTemporaryCredentialProvider {
* section of the Auth spec.
*/
export class LegacyAWSTemporaryCredentialProvider extends AWSTemporaryCredentialProvider {
override async getCredentials(): Promise<AWSTempCredentials> {
override async getCredentials(_props: AuthMechanismProperties): Promise<AWSTempCredentials> {
// If the environment variable AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
// is set then drivers MUST assume that it was set by an AWS ECS agent
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
Expand Down
2 changes: 2 additions & 0 deletions src/cmap/auth/mongo_credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface AuthMechanismProperties extends Document {
ALLOWED_HOSTS?: string[];
/** @experimental */
TOKEN_AUDIENCE?: string;

AWS_ROLE_ARN?: string;
}

/** @public */
Expand Down
4 changes: 3 additions & 1 deletion src/cmap/auth/mongodb_aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ async function makeTempCredentials(
}
});
}
const temporaryCredentials = await awsCredentialFetcher.getCredentials();
const temporaryCredentials = await awsCredentialFetcher.getCredentials(
credentials.mechanismProperties
);

return makeMongoCredentialsFromAWSTemp(temporaryCredentials);
}
Expand Down
8 changes: 8 additions & 0 deletions src/deps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/* eslint-disable @typescript-eslint/no-var-requires */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- optional interface, will gracefully degrade to `any` if `foo` isn't installed
import type { FromTemporaryCredentialsOptions } from '@aws-sdk/credential-providers';

import { type Stream } from './cmap/connect';
import { MongoMissingDependencyError } from './error';
import type { Callback } from './utils';
Expand Down Expand Up @@ -94,6 +98,10 @@ type CredentialProvider = {
options: { clientConfig: { region: string } }
): () => Promise<AWSCredentials>;
fromNodeProviderChain(this: void): () => Promise<AWSCredentials>;
fromTemporaryCredentials(
this: void,
options: FromTemporaryCredentialsOptions
): () => Promise<AWSCredentials>;
};

export function getAwsCredentialProvider():
Expand Down
8 changes: 6 additions & 2 deletions test/unit/client-side-encryption/auto_encrypter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { MongocryptdManager } from '../../../src/client-side-encryption/mongocry
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { StateMachine } from '../../../src/client-side-encryption/state_machine';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { MongoClient } from '../../../src/mongo_client';
import { MongoClient, type MongoOptions } from '../../../src/mongo_client';
import { BSON, type DataKey } from '../../mongodb';
import * as requirements from './requirements.helper';

Expand Down Expand Up @@ -37,7 +37,11 @@ const MOCK_MONGOCRYPTD_RESPONSE = readExtendedJsonToBuffer(
const MOCK_KEYDOCUMENT_RESPONSE = readExtendedJsonToBuffer(`${__dirname}/data/key-document.json`);
const MOCK_KMS_DECRYPT_REPLY = readHttpResponse(`${__dirname}/data/kms-decrypt-reply.txt`);

class MockClient {}
class MockClient {
get options(): Partial<MongoOptions> {
return {};
}
}

const originalAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
const originalSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('#refreshKMSCredentials', function () {
});

it('refreshes the aws credentials', async function () {
const providers = await refreshKMSCredentials(kmsProviders);
const providers = await refreshKMSCredentials(kmsProviders, {});
expect(providers).to.deep.equal({
aws: {
accessKeyId: accessKey,
Expand Down Expand Up @@ -116,7 +116,7 @@ describe('#refreshKMSCredentials', function () {
});

it('refreshes only the aws credentials', async function () {
const providers = await refreshKMSCredentials(kmsProviders);
const providers = await refreshKMSCredentials(kmsProviders, {});
expect(providers).to.deep.equal({
local: {
key: Buffer.alloc(96)
Expand Down