diff --git a/dbhub.toml.example b/dbhub.toml.example index 86771702..d8659dec 100644 --- a/dbhub.toml.example +++ b/dbhub.toml.example @@ -54,6 +54,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # user = "dbuser@example.com" # aws_iam_auth = true # aws_region = "eu-west-1" +# aws_profile = "my-profile" # Optional: named AWS profile; requires aws_iam_auth = true # sslmode = "require" # PostgreSQL with certificate verification (e.g., AWS RDS) @@ -129,6 +130,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # user = "dbuser@example.com" # aws_iam_auth = true # aws_region = "eu-west-1" +# aws_profile = "my-profile" # Optional: named AWS profile; requires aws_iam_auth = true # sslmode = "require" # ============================================================================ @@ -149,6 +151,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # user = "dbuser@example.com" # aws_iam_auth = true # aws_region = "eu-west-1" +# aws_profile = "my-profile" # Optional: named AWS profile; requires aws_iam_auth = true # sslmode = "require" # ============================================================================ @@ -355,6 +358,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # collation = "utf8mb4_0900_ai_ci" # MySQL/MariaDB only: connection collation # aws_iam_auth = true # Optional: enable AWS RDS IAM auth (postgres/mysql/mariadb) # aws_region = "eu-west-1" # Required when aws_iam_auth = true +# aws_profile = "my-profile" # Optional: named AWS profile; requires aws_iam_auth = true # # DSN Formats: # PostgreSQL: postgres://user:pass@host:5432/database?sslmode=require diff --git a/docs/adr/0001-strict-per-source-aws-profiles.md b/docs/adr/0001-strict-per-source-aws-profiles.md new file mode 100644 index 00000000..cfd8d28d --- /dev/null +++ b/docs/adr/0001-strict-per-source-aws-profiles.md @@ -0,0 +1,3 @@ +# Resolve configured AWS profiles without credential fallback + +DBHub will support an optional per-source `aws_profile` only when `aws_iam_auth = true`. When configured, DBHub resolves credentials strictly from that named shared-config profile and fails if it is unavailable rather than falling through to environment, container, or instance credentials; when omitted, the existing default credential chain remains unchanged, and `aws_region` remains required. This trades an additional direct optional AWS credential-provider dependency for deterministic account and role selection and prevents a source from connecting under an unintended identity. diff --git a/docs/config/toml.mdx b/docs/config/toml.mdx index ce6ab012..f8e35263 100644 --- a/docs/config/toml.mdx +++ b/docs/config/toml.mdx @@ -232,6 +232,38 @@ Sources define database connections. Each source represents a database that DBHu +### AWS RDS IAM authentication + + + Generate short-lived AWS RDS IAM authentication tokens instead of using a + configured password. This is supported for PostgreSQL, MySQL, and MariaDB + sources and requires `aws_region`. + + Set `aws_profile` to use a named AWS shared-config profile for a source. An + explicitly configured profile is resolved strictly: if it is missing or + cannot provide credentials, the connection fails instead of falling back to + another AWS identity. When `aws_profile` is omitted, the AWS SDK default + credential chain is used. + + ```toml + [[sources]] + id = "production" + type = "postgres" + host = "mydb.example.us-east-1.rds.amazonaws.com" + port = 5432 + database = "myapp" + user = "iam_db_user" + aws_iam_auth = true + aws_region = "us-east-1" + aws_profile = "production-readonly" + sslmode = "require" + ``` + + + `aws_profile` is optional, but may only be set when `aws_iam_auth = true`. + + + ### connection_timeout @@ -785,6 +817,9 @@ default = 10 | `id` | string | ✅ | Unique source identifier | | `description` | string | ❌ | Human-readable description of the data source | | `dsn` | string | ✅ | Database connection string | +| `aws_iam_auth` | boolean | ❌ | Generate AWS RDS IAM authentication tokens (default: `false`) | +| `aws_region` | string | ❌ | AWS region (required when `aws_iam_auth` is enabled) | +| `aws_profile` | string | ❌ | Named AWS shared-config profile (requires `aws_iam_auth`) | | `lazy` | boolean | ❌ | Defer connection until first query (default: `false`) | | `connection_timeout` | number | ❌ | Connection timeout (seconds) | | `query_timeout` | number | ❌ | Query timeout (seconds) | diff --git a/package.json b/package.json index 5251d2a0..ff7314b7 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "zod": "^4.2.0" }, "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.1001.0", "@aws-sdk/rds-signer": "^3.1001.0", "@azure/identity": "^4.8.0", "mariadb": "^3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74ac5b5f..cc5ade8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: specifier: ^4.0.6 version: 4.0.6(@types/node@22.15.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.3)(yaml@2.8.0) optionalDependencies: + '@aws-sdk/credential-providers': + specifier: ^3.1001.0 + version: 3.1001.0 '@aws-sdk/rds-signer': specifier: ^3.1001.0 version: 3.1001.0 diff --git a/scripts/build-mcpb.mjs b/scripts/build-mcpb.mjs index 5b8d3d8d..6722bc1d 100644 --- a/scripts/build-mcpb.mjs +++ b/scripts/build-mcpb.mjs @@ -29,7 +29,11 @@ const bundleDir = join(root, "dist-mcpb", "bundle"); // auth packages: AWS IAM / Azure AD auth requires local cloud credential // setup that does not fit the bundle's zero-setup, read-only use case, and // they would triple the bundle size. Repackage with them added if needed. -const CLOUD_AUTH_PACKAGES = new Set(["@aws-sdk/rds-signer", "@azure/identity"]); +const CLOUD_AUTH_PACKAGES = new Set([ + "@aws-sdk/credential-providers", + "@aws-sdk/rds-signer", + "@azure/identity", +]); const drivers = Object.keys(rootPkg.optionalDependencies ?? {}).filter( (pkg) => !CLOUD_AUTH_PACKAGES.has(pkg) ); diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts index 8b733377..179345c2 100644 --- a/src/config/__tests__/toml-loader.test.ts +++ b/src/config/__tests__/toml-loader.test.ts @@ -925,6 +925,58 @@ domain = "MYDOMAIN" }); describe('AWS IAM auth validation', () => { + it('should reject aws_profile when AWS IAM auth is not enabled', () => { + const tomlContent = ` +[[sources]] +id = "postgres_profile_without_iam" +type = "postgres" +host = "localhost" +database = "mydb" +user = "dbuser" +password = "secret" +aws_profile = "development" +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + expect(() => loadTomlConfig()).toThrow( + 'aws_profile requires aws_iam_auth = true' + ); + }); + + it('should reject a non-string aws_profile', () => { + const tomlContent = ` +[[sources]] +id = "postgres_invalid_profile" +type = "postgres" +host = "mydb.example.com" +database = "mydb" +user = "dbuser" +aws_iam_auth = true +aws_region = "us-east-1" +aws_profile = 42 +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + expect(() => loadTomlConfig()).toThrow('invalid aws_profile'); + }); + + it('should reject a blank aws_profile', () => { + const tomlContent = ` +[[sources]] +id = "postgres_blank_profile" +type = "postgres" +host = "mydb.example.com" +database = "mydb" +user = "dbuser" +aws_iam_auth = true +aws_region = "us-east-1" +aws_profile = " " +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + expect(() => loadTomlConfig()).toThrow('invalid aws_profile'); + }); + it('should accept aws_iam_auth for MySQL without password', () => { const tomlContent = ` [[sources]] @@ -935,6 +987,7 @@ database = "mydb" user = "dbuser@example.com" aws_iam_auth = true aws_region = "eu-west-1" +aws_profile = "development" `; fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); @@ -949,6 +1002,7 @@ aws_region = "eu-west-1" user: 'dbuser@example.com', aws_iam_auth: true, aws_region: 'eu-west-1', + aws_profile: 'development', }); expect(result?.sources[0].password).toBeUndefined(); }); diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index 62b700dd..28f3315a 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -428,6 +428,24 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void { } } + if (source.aws_profile !== undefined) { + if ( + typeof source.aws_profile !== "string" || + source.aws_profile.trim().length === 0 + ) { + throw new Error( + `Configuration file ${configPath}: source '${source.id}' has invalid aws_profile. ` + + `Must be a non-empty string.` + ); + } + if (source.aws_iam_auth !== true) { + throw new Error( + `Configuration file ${configPath}: source '${source.id}' aws_profile requires ` + + `aws_iam_auth = true.` + ); + } + } + if (source.aws_iam_auth === true) { const validIamTypes = ["postgres", "mysql", "mariadb"]; if (!source.type || !validIamTypes.includes(source.type)) { diff --git a/src/connectors/__tests__/manager.test.ts b/src/connectors/__tests__/manager.test.ts index d1334bd9..042a6290 100644 --- a/src/connectors/__tests__/manager.test.ts +++ b/src/connectors/__tests__/manager.test.ts @@ -175,6 +175,7 @@ describe("ConnectorManager IAM DSN rewrite", () => { user: "dbuser@example.com", aws_iam_auth: true, aws_region: "eu-west-1", + aws_profile: "ngqa", dsn: "mysql://dbuser%40example.com:ignored@mydb.abc123.eu-west-1.rds.amazonaws.com:3306/mydb?connectTimeout=5000&sslmode=disable", }; @@ -185,6 +186,7 @@ describe("ConnectorManager IAM DSN rewrite", () => { port: 3306, username: "dbuser@example.com", region: "eu-west-1", + profile: "ngqa", }); expect(dsn).toContain("mysql://dbuser%40example.com:token%20with%20spaces%2F%2B%3F%3D@"); expect(dsn).toContain("connectTimeout=5000"); diff --git a/src/connectors/manager.ts b/src/connectors/manager.ts index 9020ba2b..6cf0f917 100644 --- a/src/connectors/manager.ts +++ b/src/connectors/manager.ts @@ -558,6 +558,7 @@ export class ConnectorManager { port, username, region: source.aws_region, + profile: source.aws_profile, }); const queryParams = new Map(parsed.searchParams); diff --git a/src/types/config.ts b/src/types/config.ts index b10d65de..1ed6f064 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -35,6 +35,7 @@ export interface ConnectionParams { password?: string; aws_iam_auth?: boolean; // Enable AWS IAM auth token generation for RDS aws_region?: string; // AWS region required when aws_iam_auth is enabled + aws_profile?: string; // Named AWS shared-config profile for RDS IAM auth instanceName?: string; // SQL Server named instance support sslmode?: "disable" | "require" | "verify-ca" | "verify-full"; // SSL mode for network databases (not applicable to SQLite, verify-* only applicable for PostgreSQL) sslrootcert?: string; // CA certificate path (requires verify-ca or verify-full) diff --git a/src/utils/__tests__/aws-rds-signer.test.ts b/src/utils/__tests__/aws-rds-signer.test.ts index 67a79ff1..ed664a51 100644 --- a/src/utils/__tests__/aws-rds-signer.test.ts +++ b/src/utils/__tests__/aws-rds-signer.test.ts @@ -5,6 +5,9 @@ const signerMocks = vi.hoisted(() => ({ constructor: vi.fn(), getAuthToken: vi.fn(), })); +const credentialProviderMocks = vi.hoisted(() => ({ + fromIni: vi.fn(), +})); vi.mock('@aws-sdk/rds-signer', () => { class MockSigner { @@ -20,11 +23,40 @@ vi.mock('@aws-sdk/rds-signer', () => { return { Signer: MockSigner }; }); +vi.mock('@aws-sdk/credential-providers', () => ({ + fromIni: credentialProviderMocks.fromIni, +})); + describe('generateRdsAuthToken', () => { beforeEach(() => { vi.clearAllMocks(); }); + it('should resolve an explicit profile without credential fallback', async () => { + const profileCredentials = vi.fn(); + credentialProviderMocks.fromIni.mockReturnValue(profileCredentials); + signerMocks.getAuthToken.mockResolvedValue('iam-token'); + + await generateRdsAuthToken({ + hostname: 'mydb.abc123.us-east-1.rds.amazonaws.com', + port: 5432, + username: 'db_user', + region: 'us-east-1', + profile: 'ngqa', + }); + + expect(credentialProviderMocks.fromIni).toHaveBeenCalledWith({ + profile: 'ngqa', + }); + expect(signerMocks.constructor).toHaveBeenCalledWith({ + hostname: 'mydb.abc123.us-east-1.rds.amazonaws.com', + port: 5432, + username: 'db_user', + region: 'us-east-1', + credentials: profileCredentials, + }); + }); + it('should create signer with expected params and return token', async () => { signerMocks.getAuthToken.mockResolvedValue('iam-token'); diff --git a/src/utils/aws-rds-signer.ts b/src/utils/aws-rds-signer.ts index 6e114785..02b755d9 100644 --- a/src/utils/aws-rds-signer.ts +++ b/src/utils/aws-rds-signer.ts @@ -5,12 +5,13 @@ export interface RdsAuthTokenParams { port: number; username: string; region: string; + profile?: string; } /** * Generate an AWS RDS IAM auth token for database authentication. - * The AWS SDK uses the default credential provider chain - * (AWS CLI profile, env vars, instance role, etc.). + * Uses the named shared-config profile when provided; otherwise the AWS SDK + * uses its default credential provider chain. */ export async function generateRdsAuthToken(params: RdsAuthTokenParams): Promise { let Signer: typeof import("@aws-sdk/rds-signer")["Signer"]; @@ -25,12 +26,19 @@ export async function generateRdsAuthToken(params: RdsAuthTokenParams): Promise< throw error; } - const signer = new Signer({ + const signerConfig: ConstructorParameters[0] = { hostname: params.hostname, port: params.port, username: params.username, region: params.region, - }); + }; + + if (params.profile) { + const { fromIni } = await import("@aws-sdk/credential-providers"); + signerConfig.credentials = fromIni({ profile: params.profile }); + } + + const signer = new Signer(signerConfig); return signer.getAuthToken(); } diff --git a/tsup.config.ts b/tsup.config.ts index 982b14c8..1b03f9e9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -19,7 +19,15 @@ export default defineConfig({ // CJS code into ESM chunks (which causes "Dynamic require of X is not // supported"). Cloud auth packages are externalized to keep their large // dependency trees out of the bundle. - external: ['pg', 'mysql2', 'mariadb', 'mssql', '@aws-sdk/rds-signer', '@azure/identity'], + external: [ + 'pg', + 'mysql2', + 'mariadb', + 'mssql', + '@aws-sdk/credential-providers', + '@aws-sdk/rds-signer', + '@azure/identity', + ], // Copy the employee-sqlite demo data to dist async onSuccess() { // Create target directory