Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dbhub.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"

# ============================================================================
Expand All @@ -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"

# ============================================================================
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/adr/0001-strict-per-source-aws-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Resolve configured AWS profiles without credential fallback
Comment thread
tianzhou marked this conversation as resolved.

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.
35 changes: 35 additions & 0 deletions docs/config/toml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,38 @@ Sources define database connections. Each source represents a database that DBHu
</Note>
</ParamField>

### AWS RDS IAM authentication

<ParamField path="aws_iam_auth" type="boolean" default="false">
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"
```

<Note>
`aws_profile` is optional, but may only be set when `aws_iam_auth = true`.
</Note>
</ParamField>

### connection_timeout

<ParamField path="connection_timeout" type="number">
Expand Down Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion scripts/build-mcpb.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down
54 changes: 54 additions & 0 deletions src/config/__tests__/toml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand All @@ -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);

Expand All @@ -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();
});
Expand Down
18 changes: 18 additions & 0 deletions src/config/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions src/connectors/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand All @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/connectors/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ export class ConnectorManager {
port,
username,
region: source.aws_region,
profile: source.aws_profile,
});

const queryParams = new Map(parsed.searchParams);
Expand Down
1 change: 1 addition & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions src/utils/__tests__/aws-rds-signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');

Expand Down
16 changes: 12 additions & 4 deletions src/utils/aws-rds-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
let Signer: typeof import("@aws-sdk/rds-signer")["Signer"];
Expand All @@ -25,12 +26,19 @@ export async function generateRdsAuthToken(params: RdsAuthTokenParams): Promise<
throw error;
}

const signer = new Signer({
const signerConfig: ConstructorParameters<typeof Signer>[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();
}
10 changes: 9 additions & 1 deletion tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading