diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 1ff45e6679..12da0a7061 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -644,9 +644,9 @@ export interface Config { */ host: string; /** - * The port number the database is listening on. Defaults to 5432. + * The port number the database is listening on. */ - port?: number; + port: number; /** * The database user to authenticate as. This user must have the `rds_iam` role granted. */ diff --git a/packages/backend-defaults/src/entrypoints/database/connectors/postgres.test.ts b/packages/backend-defaults/src/entrypoints/database/connectors/postgres.test.ts index e095a17e11..012b5d9f13 100644 --- a/packages/backend-defaults/src/entrypoints/database/connectors/postgres.test.ts +++ b/packages/backend-defaults/src/entrypoints/database/connectors/postgres.test.ts @@ -649,6 +649,7 @@ describe('postgres', () => { connection: { type: 'rds', host: 'mydb.cluster.eu-west-1.rds.amazonaws.com', + port: 5432, user: 'postgres', region: 'eu-west-1', }, @@ -662,28 +663,47 @@ describe('postgres', () => { expect(conn2.password).toBe('token-2'); }); - it('defaults port to 5432 for rds connections', async () => { + 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'); - await buildPgDatabaseConfig( + 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( - expect.objectContaining({ port: 5432 }), - ); + const conn = await (configResult.connection as () => Promise)(); + + 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 () => { @@ -703,6 +723,7 @@ describe('postgres', () => { connection: { type: 'rds', host: 'mydb.cluster.us-east-1.rds.amazonaws.com', + port: 5432, user: 'postgres', }, }), @@ -727,14 +748,13 @@ describe('postgres', () => { client: 'pg', connection: { type: 'rds', + port: 5432, user: 'postgres', region: 'eu-west-1', }, }), ), - ).rejects.toThrow( - /Missing host in connection config for AWS RDS IAM auth/, - ); + ).rejects.toThrow(/connection\.host/); }); it('throws when user is missing for rds connection', async () => { @@ -745,13 +765,12 @@ describe('postgres', () => { connection: { type: 'rds', host: 'mydb.cluster.eu-west-1.rds.amazonaws.com', + port: 5432, region: 'eu-west-1', }, }), ), - ).rejects.toThrow( - /Missing user in connection config for AWS RDS IAM auth/, - ); + ).rejects.toThrow(/connection\.user/); }); it('throws when region is missing and no env var is set for rds connection', async () => { @@ -768,6 +787,7 @@ describe('postgres', () => { connection: { type: 'rds', host: 'mydb.cluster.eu-west-1.rds.amazonaws.com', + port: 5432, user: 'postgres', }, }), diff --git a/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts b/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts index 53e00e863c..74e95601bd 100644 --- a/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts +++ b/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts @@ -277,24 +277,21 @@ export async function buildRdsPgConfig(config: Config): Promise { const { Signer } = require('@aws-sdk/rds-signer') as typeof import('@aws-sdk/rds-signer'); - const rawConfig = config.get() as Record; - const normalized = normalizeConnection(rawConfig.connection as any); - const sanitizedConnection = omit(normalized, [ - 'type', - 'region', - ]) as Partial; - - 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'); + 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 = - ((normalized as any).region as string | undefined) ?? + config.getOptionalString('connection.region') ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; if (!region) { @@ -303,17 +300,33 @@ export async function buildRdsPgConfig(config: Config): Promise { ); } + const rawConfig = config.get() as Record; + const sanitizedConnection = omit( + config.get('connection') as Record, + ['type', 'region'], + ) as Partial; + 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(); - return { ...sanitizedConnection, password }; + const tokenExpiration = Date.now() + tokenTtlMs - renewalOffsetMs; + return { + ...sanitizedConnection, + password, + expirationChecker: () => tokenExpiration <= Date.now(), + }; } catch (err) { - console.error( - `AWS RDS IAM auth token acquisition failed for ${username}@${hostname}:${port}: ${err}`, + throw new ForwardedError( + `AWS RDS IAM auth token acquisition failed for ${username}@${hostname}:${port}`, + err, ); - throw err; } }