@speles7172/sql-bridge
v0.3.0
Published
Lambda handlers that reach databases a caller cannot, for @speles7172/sql-client.
Readme
@speles7172/sql-bridge
Lambda handlers that run queries against databases a caller cannot reach — another VPC, another region, or an engine whose driver has no business being in every consumer's bundle.
The other half of @speles7172/sql-client: register
a database with engine: 'remote-pg' or engine: 'neo4j' and a
bridgeFunctionName, and the client invokes one of these.
your Lambda ──invoke──▶ sql-bridge ──▶ database in another VPC / region
(sql-client) (this)Why a separate function at all
Three reasons, in order of how much they matter:
- Reachability. A database in another VPC or region is not addressable from your process. A small function that is addressable, placed where the database lives, is the cheapest thing that fixes that.
- Driver weight. Neo4j goes through the bridge specifically so that
sql-clientnever depends onneo4j-driver. A service that only queries PostgreSQL should not carry a graph driver. - Blast radius. The bridge holds credentials for databases nothing else can reach. Keeping it separate means one narrow IAM role reads those secrets, rather than every service that might want the data.
Two functions, not one
postgres and neo4j deploy separately. They need different drivers,
different ports and different secrets, and one function switching on engine
would put the graph driver into the artifact of every PostgreSQL query.
| Engine | Handler | Port default |
|---|---|---|
| postgres | dist/postgres-handler.handler | 5432 |
| neo4j | dist/neo4j-handler.handler | 7687 |
Deploying it from your own repository
This repository ships the handler and the Terraform module; it does not run bridges for you. A bridge belongs to the environment whose databases it reaches — the subnets, security groups and secret ARNs it needs all live in that account — so you instantiate it where those things are.
Three steps.
1. Bundle the handler
npm install @speles7172/sql-bridge
npx esbuild node_modules/@speles7172/sql-bridge/dist/postgres-handler.js \
--bundle --platform=node --target=node22 --format=esm \
--external:@aws-sdk/* \
--outfile=build/bridge/dist/postgres-handler.jsNotes on that command:
--external:@aws-sdk/*— the SDK is already in the Lambda runtime; bundling it roughly triples the artifact for no benefit.- The output path must keep
dist/postgres-handler.js, because the module sets the Lambda handler todist/postgres-handler.handler. - For Neo4j, swap both filenames and do not externalise
neo4j-driver— it is not in the runtime.
2. Provision it
data "archive_file" "bridge" {
type = "zip"
source_dir = "${path.module}/build/bridge"
output_path = "${path.module}/build/bridge.zip"
}
module "reporting_bridge" {
source = "git::https://github.com/speles7172/utils.git//infra/modules/sql-bridge?ref=v1.0.0"
name_prefix = "myapp-staging"
engine = "postgres"
package_path = data.archive_file.bridge.output_path
# Exactly the secrets this bridge serves — see below.
allowed_secret_arns = [aws_db_instance.reporting.master_user_secret[0].secret_arn]
# Where the database actually is.
subnet_ids = module.vpc.private_subnet_ids
security_group_ids = [aws_security_group.bridge.id]
}Pin ?ref= to a tag. Tracking main means an unrelated merge here can change
your infrastructure.
Your caller also needs lambda:InvokeFunction on module.reporting_bridge.function_arn.
3. Register it with the client
registerDatabase({
name: 'reporting',
engine: 'remote-pg',
version: '16',
region: 'us-west-2',
secretArn: 'arn:aws:secretsmanager:us-west-2:…:secret:reporting',
bridgeFunctionName: 'myapp-staging-sql-bridge-postgres',
});Verify the database's certificate
By default the bridge encrypts its connection but does not authenticate the server — the same default as the client, and for the same reason: RDS presents a certificate signed by a private Amazon CA that is in no default trust store, so verifying out of the box would break every connection on first use.
A bridge usually sits closer to untrusted network than the caller does, so this is worth changing. Give the module a CA and it verifies:
module "reporting_bridge" {
# …
ssl_ca_pem = file("${path.module}/rds-global-bundle.pem")
}from https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem.
| Setting | Behaviour |
|---|---|
| default | encrypted, server not authenticated |
| ssl_ca_pem set | encrypted and verified against that CA |
| ssl_reject_unauthorized = false | forces verification off even with a CA |
| BRIDGE_SSL=false in environment | no TLS — loopback testing only |
Neo4j uses the neo4j+s scheme, which verifies, and does work out of the box
against Aura or any publicly-signed host. Drop to neo4j_scheme = "neo4j+ssc"
only for a self-signed deployment.
Trust settings live on the function, not in the invocation payload: a CA bundle is far too large to send on every call, and a caller must not be able to talk a bridge into accepting a weaker certificate.
The bits that are easy to get wrong
allowed_secret_arns has no default, on purpose. A bridge that can read
every secret in the account is not a bridge, it is a skeleton key. List exactly
the secrets it serves; Terraform rejects an empty list.
One bridge can serve several identities safely. Pools and drivers are cached by credentials, not just by address — two secrets resolving to different users on the same host get separate connections, and a rotated password opens a fresh one rather than reusing a connection authenticated with the old value.
The bridge must be in the VPC that can reach the database. Without
subnet_ids it runs outside any VPC and cannot see a private database, which is
the only reason it exists. The failure is a connection timeout, not a clear
error.
Set the caller's invocation timeout above the bridge's. Otherwise the caller gives up while the query keeps running and holding a connection.
Reserve concurrency. Every execution holds database connections, so unbounded concurrency becomes unbounded connections on a database that is, by definition, hard to reach and probably not sized for it. Defaults to 10.
Behaviour worth knowing
A handler never throws. Query failures come back as { error }. An
unhandled exception would reach the caller as a Lambda FunctionError, which
is indistinguishable from the bridge itself being broken — so a typo in a
WHERE clause would look like an outage.
Pools survive between invocations. Connections are cached at module scope for the life of the warm container. That is the opposite of the client's Lambda wrapper, which tears pools down on the way out — and deliberately so: this function exists to hold connections open, and it is one low-concurrency function rather than one instance per request path.
Explicit request fields beat the secret's. host, port and dbname from
the caller win, so one cluster secret can serve several databases on that
cluster. Identical to the rule the client applies when registering, so a
database behaves the same whichever side resolves it.
Neo4j paging happens after the query. Cypher records are fetched, then
sliced, so totalCount is a true total — but the whole result set crossed the
wire to get it. A LIMIT in the Cypher is still the better tool.
Payload
The wire format is BridgePayload / BridgeResponse, imported from
@speles7172/sql-client. Both sides therefore compile against one definition:
a change to what the client sends fails to build here until the handler agrees.
