1import type * as Credentials from "@distilled.cloud/aws/Credentials";
2import type * as Region from "@distilled.cloud/aws/Region";
3import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Binding from "../../Binding.ts";
8import * as Output from "../../Output.ts";
9import { generateDbAuthToken } from "../Connection/DbAuthToken.ts";
10import { formatSqlConnectionUrl } from "../Connection/internal.ts";
11import { isBindingHost } from "../Lambda/Function.ts";
12import {
13 Connect,
14 type ConnectOptions,
15 type ConnectResource,
16} from "./Connect.ts";
17
18/**
19 * `arn:{partition}:rds-db:{region}:{account}:dbuser:{resourceId}/{username}`
20 * derived from another RDS ARN in the same account/region.
21 */
22const rdsDbUserArn = (
23 sourceArn: string,
24 resourceId: string,
25 username: string,
26): string => {
27 const [, partition, , region, accountId] = sourceArn.split(":");
28 return `arn:${partition}:rds-db:${region}:${accountId}:dbuser:${resourceId}/${username}`;
29};
30
31/**
32 * The `rds-db:connect` resource for IAM database authentication.
33 *
34 * - DBCluster — the cluster's `DbClusterResourceId` (`cluster-XXXX`).
35 * - DBProxy — the proxy id (`prx-XXXX`), the last segment of the proxy ARN.
36 * - DBProxyEndpoint — the parent proxy's `prx-` id is not an endpoint
37 * attribute, so the resource id is wildcarded to the account/region
38 * (still scoped to the exact `username`).
39 */
40const dbUserArn = (resource: ConnectResource, username: string) => {
41 switch (resource.Type) {
42 case "AWS.RDS.DBCluster":
43 return Output.map(
44 Output.all(resource.dbClusterArn, resource.dbClusterResourceId),
45 ([arn, resourceId]) => rdsDbUserArn(arn, resourceId ?? "*", username),
46 );
47 case "AWS.RDS.DBProxy":
48 return Output.map(resource.dbProxyArn, (arn) =>
49 rdsDbUserArn(arn, arn.split(":")[6] ?? "*", username),
50 );
51 default:
52 return Output.map(resource.dbProxyEndpointArn, (arn) =>
53 rdsDbUserArn(arn, "*", username),
54 );
55 }
56};
57
58export const ConnectHttp = Layer.effect(
59 Connect,
60 Effect.gen(function* () {
61 const getSecretValue = yield* secretsmanager.getSecretValue;
62 // Ambient AWS identity for the IAM-auth token presign — captured at
63 // layer build so the runtime client stays `RuntimeContext`-colored.
64 const services = yield* Effect.context<
65 Credentials.Credentials | Region.Region
66 >();
67
68 return Effect.fn(function* (
69 resource: ConnectResource,
70 options: ConnectOptions,
71 ) {
72 const SecretId =
73 options.auth === "iam" ? undefined : yield* options.secret.secretArn;
74 const Host = yield* resource.endpoint;
75 const Port =
76 resource.Type === "AWS.RDS.DBCluster"
77 ? yield* resource.port
78 : undefined;
79 // Engine flavor drives the connection-URL scheme. Proxy endpoints
80 // carry no engine attribute — they default to postgres below.
81 const Engine =
82 resource.Type === "AWS.RDS.DBCluster"
83 ? yield* resource.engine
84 : resource.Type === "AWS.RDS.DBProxy"
85 ? yield* resource.engineFamily
86 : undefined;
87
88 if (!globalThis.__ALCHEMY_RUNTIME__) {
89 const host = yield* Binding.Host;
90 if (isBindingHost(host)) {
91 // DECISION #5: VPC attachment is requested declaratively through
92 // the host's `vpc` binding channel (and remains available as a
93 // plain Function prop for the manual case).
94 const vpc =
95 options.subnetIds !== undefined ||
96 options.securityGroupIds !== undefined
97 ? {
98 vpc: {
99 subnetIds: options.subnetIds ?? [],
100 securityGroupIds: options.securityGroupIds ?? [],
101 },
102 }
103 : {};
104 if (options.auth === "iam") {
105 yield* host.bind`Allow(${host}, AWS.RDS.Connect(${resource}))`({
106 policyStatements: [
107 {
108 Effect: "Allow",
109 Action: ["rds-db:connect"],
110 Resource: [dbUserArn(resource, options.username)],
111 },
112 ],
113 ...vpc,
114 });
115 } else {
116 yield* host.bind`Allow(${host}, AWS.RDS.Connect(${options.secret}))`(
117 {
118 policyStatements: [
119 {
120 Effect: "Allow",
121 Action: [
122 "secretsmanager:GetSecretValue",
123 "secretsmanager:DescribeSecret",
124 ],
125 Resource: [options.secret.secretArn],
126 },
127 ],
128 ...vpc,
129 },
130 );
131 }
132 }
133 }
134
135 return Effect.gen(function* () {
136 const host = yield* Host;
137 const port = Port ? yield* Port : undefined;
138 const engine = Engine ? yield* Engine : undefined;
139
140 if (!host) {
141 return yield* Effect.die(`RDS endpoint is not available yet`);
142 }
143
144 const resolvedPort = options.port ?? port ?? 5432;
145 const scheme = engine?.toLowerCase().includes("mysql")
146 ? "mysql"
147 : "postgresql";
148
149 if (options.auth === "iam") {
150 // IAM database authentication mandates TLS.
151 const ssl = true;
152 const mintToken = generateDbAuthToken({
153 service: "rds-db",
154 hostname: host,
155 port: resolvedPort,
156 username: options.username,
157 }).pipe(Effect.provideContext(services));
158 const token = yield* mintToken;
159 return {
160 host,
161 port: resolvedPort,
162 database: options.database,
163 username: options.username,
164 password: Redacted.value(token),
165 ssl,
166 url: formatSqlConnectionUrl({
167 scheme,
168 host,
169 port: resolvedPort,
170 database: options.database,
171 username: options.username,
172 password: token,
173 ssl,
174 // RDS certificates chain to a private AWS CA that Node's
175 // trust store doesn't carry — `no-verify` keeps TLS on
176 // (mandatory for IAM auth) with libpq `require` semantics.
177 sslMode: "no-verify",
178 }),
179 refreshPassword: mintToken,
180 };
181 }
182
183 const secretId = yield* SecretId!;
184 const value = yield* getSecretValue({
185 SecretId: secretId,
186 });
187 const secretString = value.SecretString
188 ? typeof value.SecretString === "string"
189 ? value.SecretString
190 : Redacted.value(value.SecretString)
191 : "{}";
192 const secret = JSON.parse(secretString) as {
193 username?: string;
194 password?: string;
195 };
196
197 const ssl = options.ssl ?? true;
198 return {
199 host,
200 port: resolvedPort,
201 database: options.database,
202 username: secret.username,
203 password: secret.password,
204 ssl,
205 url: formatSqlConnectionUrl({
206 scheme,
207 host,
208 port: resolvedPort,
209 database: options.database,
210 username: secret.username,
211 password: secret.password,
212 ssl,
213 // See the IAM branch — RDS certs chain to a private CA.
214 sslMode: "no-verify",
215 }),
216 };
217 });
218 });
219 }),
220);