Skip to content
Source
Sign inSign up

ConnectHttp.ts

252 lines9.5 KB

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 Output from "alchemy/Output";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as AWSBindings from "../AWSBindings.ts";
10import { generateDbAuthToken } from "../Connection/DbAuthToken.ts";
11import { formatSqlConnectionUrl } from "../Connection/internal.ts";
12import { Connect, type ConnectOptions, type ConnectResource } from "./Connect.ts";
13​
14/**
15 * `Connect` was bound without a `secret` to a proxy, or to a database that
16 * has no RDS-managed master user secret (`manageMasterUserPassword` is off).
17 */
18export class ManagedMasterSecretNotFound extends Data.TaggedError("ManagedMasterSecretNotFound")<{
19 message: string;
20}> {}
21​
22/**
23 * `arn:{partition}:rds-db:{region}:{account}:dbuser:{resourceId}/{username}`
24 * derived from another RDS ARN in the same account/region.
25 */
26const rdsDbUserArn = (sourceArn: string, resourceId: string, username: string): 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 * - DBInstance — the instance's `DbiResourceId` (`db-XXXX`).
36 * - DBProxy — the proxy id (`prx-XXXX`), the last segment of the proxy ARN.
37 * - DBProxyEndpoint — the parent proxy's `prx-` id is not an endpoint
38 * attribute, so the resource id is wildcarded to the account/region
39 * (still scoped to the exact `username`).
40 */
41const dbUserArn = (resource: ConnectResource, username: string) => {
42 switch (resource.Type) {
43 case "AWS.RDS.DBCluster":
44 return Output.map(
45 Output.all(resource.dbClusterArn, resource.dbClusterResourceId),
46 ([arn, resourceId]) => rdsDbUserArn(arn, resourceId ?? "*", username),
47 );
48 case "AWS.RDS.DBInstance":
49 return Output.map(
50 Output.all(resource.dbInstanceArn, resource.dbiResourceId),
51 ([arn, resourceId]) => rdsDbUserArn(arn, resourceId ?? "*", username),
52 );
53 case "AWS.RDS.DBProxy":
54 return Output.map(resource.dbProxyArn, (arn) =>
55 rdsDbUserArn(arn, arn.split(":")[6] ?? "*", username),
56 );
57 default:
58 return Output.map(resource.dbProxyEndpointArn, (arn) => rdsDbUserArn(arn, "*", username));
59 }
60};
61​
62const managedSecretArn = (resource: ConnectResource): Output.Output<string, never> =>
63 (resource.Type === "AWS.RDS.DBCluster" || resource.Type === "AWS.RDS.DBInstance"
64 ? resource.masterUserSecretArn
65 : Output.literal<string | undefined>(undefined)
66 ).pipe(
67 Output.mapEffect((arn) =>
68 arn
69 ? Effect.succeed(arn)
70 : Effect.die(
71 new ManagedMasterSecretNotFound({
72 message: `AWS.RDS.Connect(${resource.LogicalId}) has no \`secret\` and ${resource.LogicalId} has no RDS-managed master user secret. Pass \`secret\`, set \`manageMasterUserPassword: true\`, or use \`auth: "iam"\`.`,
73 }),
74 ),
75 ),
76 );
77​
78export const ConnectHttp = Layer.effect(
79 Connect,
80 Effect.gen(function* () {
81 const bind = yield* AWSBindings.Bind;
82 const getSecretValue = yield* AWSBindings.withCredentials(secretsmanager.getSecretValue);
83 // Ambient AWS identity for the IAM-auth token presign — captured at
84 // layer build so the runtime client stays `RuntimeContext`-colored.
85 const services = yield* AWSBindings.withCredentials(
86 Effect.context<Credentials.Credentials | Region.Region>(),
87 );
88​
89 return Effect.fn(function* (resource: ConnectResource, options: ConnectOptions) {
90 // Without an explicit `secret`, read the database's RDS-managed master
91 // user secret. The IAM grant checks it exists, so its absence fails the
92 // deploy, not the first request; the runtime reads the plain attribute.
93 const secretArn: Output.Output<string, never> | undefined =
94 options.auth === "iam"
95 ? undefined
96 : options.secret
97 ? options.secret.secretArn
98 : managedSecretArn(resource);
99 const SecretId =
100 options.auth === "iam"
101 ? undefined
102 : options.secret
103 ? yield* options.secret.secretArn
104 : resource.Type === "AWS.RDS.DBCluster" || resource.Type === "AWS.RDS.DBInstance"
105 ? yield* resource.masterUserSecretArn
106 : undefined;
107 const Host =
108 resource.Type === "AWS.RDS.DBInstance"
109 ? yield* resource.endpointAddress
110 : yield* resource.endpoint;
111 const Port =
112 resource.Type === "AWS.RDS.DBCluster"
113 ? yield* resource.port
114 : resource.Type === "AWS.RDS.DBInstance"
115 ? yield* resource.endpointPort
116 : undefined;
117 // Engine flavor drives the connection-URL scheme. Proxy endpoints
118 // carry no engine attribute — they default to postgres below.
119 const Engine =
120 resource.Type === "AWS.RDS.DBCluster" || resource.Type === "AWS.RDS.DBInstance"
121 ? yield* resource.engine
122 : resource.Type === "AWS.RDS.DBProxy"
123 ? yield* resource.engineFamily
124 : undefined;
125​
126 if (!globalThis.__ALCHEMY_RUNTIME__) {
127 // Attach the host to the database's VPC (also available as a plain
128 // Function prop for the manual case).
129 const vpc =
130 options.subnetIds !== undefined || options.securityGroupIds !== undefined
131 ? {
132 subnetIds: options.subnetIds ?? [],
133 securityGroupIds: options.securityGroupIds ?? [],
134 }
135 : undefined;
136 if (options.auth === "iam") {
137 yield* bind`AWS.RDS.Connect(${resource}, ${options.username})`({
138 policyStatements: [
139 {
140 Effect: "Allow",
141 Action: ["rds-db:connect"],
142 Resource: [dbUserArn(resource, options.username)],
143 },
144 ],
145 });
146 } else {
147 yield* bind`AWS.RDS.Connect(${options.secret ?? resource})`({
148 policyStatements: [
149 {
150 Effect: "Allow",
151 Action: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
152 Resource: [secretArn!],
153 },
154 ],
155 });
156 }
157 if (vpc !== undefined) {
158 yield* AWSBindings.bindVpc`AWS.RDS.Connect(${resource})`(vpc);
159 }
160 }
161​
162 return Effect.gen(function* () {
163 const host = yield* Host;
164 const port = Port ? yield* Port : undefined;
165 const engine = Engine ? yield* Engine : undefined;
166​
167 if (!host) {
168 return yield* Effect.die(`RDS endpoint is not available yet`);
169 }
170​
171 const resolvedPort = options.port ?? port ?? 5432;
172 const scheme = engine?.toLowerCase().includes("mysql") ? "mysql" : "postgresql";
173​
174 if (options.auth === "iam") {
175 // IAM database authentication mandates TLS.
176 const ssl = true;
177 const mintToken = generateDbAuthToken({
178 service: "rds-db",
179 hostname: host,
180 port: resolvedPort,
181 username: options.username,
182 }).pipe(Effect.provideContext(services));
183 const token = yield* mintToken;
184 return {
185 host,
186 port: resolvedPort,
187 database: options.database,
188 username: options.username,
189 password: Redacted.value(token),
190 ssl,
191 url: formatSqlConnectionUrl({
192 scheme,
193 host,
194 port: resolvedPort,
195 database: options.database,
196 username: options.username,
197 password: token,
198 ssl,
199 // RDS certificates chain to a private AWS CA that Node's
200 // trust store doesn't carry — `no-verify` keeps TLS on
201 // (mandatory for IAM auth) with libpq `require` semantics.
202 sslMode: "no-verify",
203 }),
204 refreshPassword: mintToken,
205 };
206 }
207​
208 const secretId = SecretId ? yield* SecretId : undefined;
209 if (!secretId) {
210 return yield* Effect.die(
211 new ManagedMasterSecretNotFound({
212 message: `AWS.RDS.Connect(${resource.LogicalId}) has no \`secret\` and no RDS-managed master user secret`,
213 }),
214 );
215 }
216 const value = yield* getSecretValue({
217 SecretId: secretId,
218 });
219 const secretString = value.SecretString
220 ? typeof value.SecretString === "string"
221 ? value.SecretString
222 : Redacted.value(value.SecretString)
223 : "{}";
224 const secret = JSON.parse(secretString) as {
225 username?: string;
226 password?: string;
227 };
228​
229 const ssl = options.ssl ?? true;
230 return {
231 host,
232 port: resolvedPort,
233 database: options.database,
234 username: secret.username,
235 password: secret.password,
236 ssl,
237 url: formatSqlConnectionUrl({
238 scheme,
239 host,
240 port: resolvedPort,
241 database: options.database,
242 username: secret.username,
243 password: secret.password,
244 ssl,
245 // See the IAM branch — RDS certs chain to a private CA.
246 sslMode: "no-verify",
247 }),
248 };
249 });
250 });
251 }),
252);