Merge pull request #33680 from rolandfuszenecker-seon/feat/aws-rds-iam-auth

feat(backend-defaults): add AWS RDS IAM authentication support for PostgreSQL
This commit is contained in:
Fredrik Adelöw
2026-04-10 16:47:13 +02:00
committed by GitHub
6 changed files with 438 additions and 89 deletions
+29
View File
@@ -632,6 +632,35 @@ export interface Config {
*/
ipAddressType?: 'PUBLIC' | 'PRIVATE' | 'PSC';
}
| {
/**
* The specific config for AWS RDS connections with IAM authentication.
* Requires the `@aws-sdk/rds-signer` package to be installed.
* The IAM role or user must have the `rds-db:connect` permission for the database user.
*/
type: 'rds';
/**
* The hostname of the RDS instance.
*/
host: string;
/**
* The port number the database is listening on.
*/
port: number;
/**
* The database user to authenticate as. This user must have the `rds_iam` role granted.
*/
user: string;
/**
* The AWS region where the RDS instance is located.
* Falls back to the AWS_REGION or AWS_DEFAULT_REGION environment variables if not set.
*/
region?: string;
/**
* Other connection settings
*/
[key: string]: unknown;
}
| {
/**
* The rest config for default, regular connections
+1
View File
@@ -130,6 +130,7 @@
"@aws-sdk/client-codecommit": "^3.350.0",
"@aws-sdk/client-s3": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/rds-signer": "^3.0.0",
"@aws-sdk/types": "^3.347.0",
"@azure/identity": "^4.0.0",
"@azure/storage-blob": "^12.5.0",
@@ -26,6 +26,7 @@ import { type Knex } from 'knex';
jest.mock('@google-cloud/cloud-sql-connector');
jest.mock('@azure/identity');
jest.mock('@aws-sdk/rds-signer');
describe('postgres', () => {
const createMockConnection = () => ({
@@ -587,6 +588,221 @@ describe('postgres', () => {
});
});
it('uses the correct config when using rds IAM auth', async () => {
const { Signer } = jest.requireMock('@aws-sdk/rds-signer') as jest.Mocked<
typeof import('@aws-sdk/rds-signer')
>;
Signer.prototype.getAuthToken.mockResolvedValue('mock-iam-token');
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
}),
);
expect(Signer).toHaveBeenCalledWith({
hostname: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
username: 'postgres',
region: 'eu-west-1',
});
expect(configResult).toMatchObject({
client: 'pg',
connection: expect.any(Function),
useNullAsDefault: true,
});
const connectionResult = await (
configResult.connection as () => Promise<any>
)();
expect(connectionResult).toMatchObject({
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
password: 'mock-iam-token',
});
expect(connectionResult).not.toHaveProperty('type');
expect(connectionResult).not.toHaveProperty('region');
});
it('generates a fresh IAM token on each connection factory call', async () => {
const { Signer } = jest.requireMock('@aws-sdk/rds-signer') as jest.Mocked<
typeof import('@aws-sdk/rds-signer')
>;
Signer.prototype.getAuthToken
.mockResolvedValueOnce('token-1')
.mockResolvedValueOnce('token-2');
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
}),
);
const conn1 = await (configResult.connection as () => Promise<any>)();
const conn2 = await (configResult.connection as () => Promise<any>)();
expect(conn1.password).toBe('token-1');
expect(conn2.password).toBe('token-2');
});
it('returns an expirationChecker that reflects the token TTL', async () => {
const { Signer } = jest.requireMock('@aws-sdk/rds-signer') as jest.Mocked<
typeof import('@aws-sdk/rds-signer')
>;
Signer.prototype.getAuthToken.mockResolvedValue('mock-iam-token');
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
}),
);
const conn = await (configResult.connection as () => Promise<any>)();
expect(conn.expirationChecker).toBeInstanceOf(Function);
// Token was just issued, so it should not yet be considered expired.
expect(conn.expirationChecker()).toBe(false);
});
it('throws when port is missing for rds connection', async () => {
await expect(
buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
user: 'postgres',
region: 'eu-west-1',
},
}),
),
).rejects.toThrow(/connection\.port/);
});
it('falls back to AWS_REGION env var when region is not set in config', async () => {
const { Signer } = jest.requireMock('@aws-sdk/rds-signer') as jest.Mocked<
typeof import('@aws-sdk/rds-signer')
>;
Signer.prototype.getAuthToken.mockResolvedValue('mock-iam-token');
const originalRegion = process.env.AWS_REGION;
process.env.AWS_REGION = 'us-east-1';
try {
await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.us-east-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
},
}),
);
expect(Signer).toHaveBeenCalledWith(
expect.objectContaining({ region: 'us-east-1' }),
);
} finally {
if (originalRegion === undefined) {
delete process.env.AWS_REGION;
} else {
process.env.AWS_REGION = originalRegion;
}
}
});
it('throws when host is missing for rds connection', async () => {
await expect(
buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
port: 5432,
user: 'postgres',
region: 'eu-west-1',
},
}),
),
).rejects.toThrow(/connection\.host/);
});
it('throws when user is missing for rds connection', async () => {
await expect(
buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
region: 'eu-west-1',
},
}),
),
).rejects.toThrow(/connection\.user/);
});
it('throws when region is missing and no env var is set for rds connection', async () => {
const originalRegion = process.env.AWS_REGION;
const originalDefaultRegion = process.env.AWS_DEFAULT_REGION;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
try {
await expect(
buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'rds',
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
port: 5432,
user: 'postgres',
},
}),
),
).rejects.toThrow(/Missing region for AWS RDS IAM auth/);
} finally {
if (originalRegion !== undefined) {
process.env.AWS_REGION = originalRegion;
}
if (originalDefaultRegion !== undefined) {
process.env.AWS_DEFAULT_REGION = originalDefaultRegion;
}
}
});
it('throws an error when the connection type is not supported', async () => {
await expect(
buildPgDatabaseConfig(
@@ -108,6 +108,8 @@ export async function buildPgDatabaseConfig(
return buildAzurePgConfig(mergedConfigReader);
case 'cloudsql':
return buildCloudSqlConfig(mergedConfigReader);
case 'rds':
return buildRdsPgConfig(mergedConfigReader);
default:
throw new Error(`Unknown connection type: ${config.connection.type}`);
}
@@ -271,6 +273,69 @@ export async function buildCloudSqlConfig(
};
}
export async function buildRdsPgConfig(config: Config): Promise<Knex.Config> {
const { Signer } =
require('@aws-sdk/rds-signer') as typeof import('@aws-sdk/rds-signer');
let hostname: string;
let port: number;
let username: string;
try {
hostname = config.getString('connection.host');
port = config.getNumber('connection.port');
username = config.getString('connection.user');
} catch (err) {
throw new ForwardedError(
'AWS RDS IAM auth: missing required database connection config — make sure connection.host, connection.port, and connection.user are set and any environment variables they reference are set',
err,
);
}
const region =
config.getOptionalString('connection.region') ??
process.env.AWS_REGION ??
process.env.AWS_DEFAULT_REGION;
if (!region) {
throw new Error(
'Missing region for AWS RDS IAM auth: set connection.region or the AWS_REGION environment variable',
);
}
const rawConfig = config.get() as Record<string, unknown>;
const sanitizedConnection = omit(
config.get('connection') as Record<string, unknown>,
['type', 'region'],
) as Partial<Knex.StaticConnectionConfig>;
const signer = new Signer({ hostname, port, username, region });
// RDS IAM auth tokens are valid for 15 minutes. Renew 1 minute early so
// that pooled connections are refreshed before the token actually expires.
const tokenTtlMs = 15 * 60 * 1000;
const renewalOffsetMs = 60 * 1000;
async function getConnectionConfig() {
try {
const password = await signer.getAuthToken();
const tokenExpiration = Date.now() + tokenTtlMs - renewalOffsetMs;
return {
...sanitizedConnection,
password,
expirationChecker: () => tokenExpiration <= Date.now(),
};
} catch (err) {
throw new ForwardedError(
`AWS RDS IAM auth token acquisition failed for ${username}@${hostname}:${port}`,
err,
);
}
}
return {
...(rawConfig as Record<string, unknown>),
connection: getConnectionConfig,
};
}
/**
* Gets the postgres connection config
*