feat(backend-defaults): add AWS RDS IAM authentication support for PostgreSQL
Signed-off-by: Roland Fuszenecker <roland.fuszenecker@seon.io>
This commit is contained in:
+29
@@ -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. Defaults to 5432.
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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,201 @@ 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',
|
||||
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('defaults port to 5432 for rds connections', 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');
|
||||
|
||||
await buildPgDatabaseConfig(
|
||||
new ConfigReader({
|
||||
client: 'pg',
|
||||
connection: {
|
||||
type: 'rds',
|
||||
host: 'mydb.cluster.eu-west-1.rds.amazonaws.com',
|
||||
user: 'postgres',
|
||||
region: 'eu-west-1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Signer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ port: 5432 }),
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
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',
|
||||
user: 'postgres',
|
||||
region: 'eu-west-1',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Missing host in connection config for AWS RDS IAM auth/,
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
region: 'eu-west-1',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Missing user in connection config for AWS RDS IAM auth/,
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
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,56 @@ 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');
|
||||
|
||||
const rawConfig = config.get() as Record<string, unknown>;
|
||||
const normalized = normalizeConnection(rawConfig.connection as any);
|
||||
const sanitizedConnection = omit(normalized, [
|
||||
'type',
|
||||
'region',
|
||||
]) as Partial<Knex.StaticConnectionConfig>;
|
||||
|
||||
const hostname = (normalized as any).host as string;
|
||||
if (!hostname) {
|
||||
throw new Error('Missing host in connection config for AWS RDS IAM auth');
|
||||
}
|
||||
const port = ((normalized as any).port as number | undefined) ?? 5432;
|
||||
const username = (normalized as any).user as string;
|
||||
if (!username) {
|
||||
throw new Error('Missing user in connection config for AWS RDS IAM auth');
|
||||
}
|
||||
const region =
|
||||
((normalized as any).region as string | undefined) ??
|
||||
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 signer = new Signer({ hostname, port, username, region });
|
||||
|
||||
async function getConnectionConfig() {
|
||||
try {
|
||||
const password = await signer.getAuthToken();
|
||||
return { ...sanitizedConnection, password };
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`AWS RDS IAM auth token acquisition failed for ${username}@${hostname}:${port}: ${err}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(rawConfig as Record<string, unknown>),
|
||||
connection: getConnectionConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the postgres connection config
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user