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