1import * as Glue from "@/AWS/Glue";
2import * as LakeFormation from "@/AWS/LakeFormation";
3import * as Lambda from "@/AWS/Lambda";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13/**
14 * Deterministic fixture names — the handler needs the database name at
15 * runtime, so it is a constant shared by deploy time and runtime.
16 */
17export const FIXTURE_DATABASE = "alchemy_lf_bindings_fixture";
18
19/** A tag key that never exists — drives the typed EntityNotFound paths. */
20const MISSING_TAG_KEY = "alchemy-lf-bindings-missing";
21
22export class LakeFormationTestFunction extends Lambda.Function<Lambda.Function>()(
23 "LakeFormationTestFunction",
24) {}
25
26export default LakeFormationTestFunction.make(
27 {
28 main,
29 functionUrl: true,
30 // Lake Formation calls routinely take a few seconds; the default 3s
31 // Lambda timeout is too tight.
32 timeout: Duration.seconds(30),
33 },
34 Effect.gen(function* () {
35 // A database for GetResourceLFTags to observe (deterministic name so
36 // the runtime routes can reference it).
37 yield* Glue.Database("LfBindingsDb", {
38 databaseName: FIXTURE_DATABASE,
39 });
40
41 // --- account-level bindings ---
42 const getDataLakePrincipal = yield* LakeFormation.GetDataLakePrincipal();
43 const getTemporaryGlueTableCredentials =
44 yield* LakeFormation.GetTemporaryGlueTableCredentials();
45 const getTemporaryGluePartitionCredentials =
46 yield* LakeFormation.GetTemporaryGluePartitionCredentials();
47 const getTemporaryDataLocationCredentials =
48 yield* LakeFormation.GetTemporaryDataLocationCredentials();
49 const searchDatabasesByLFTags =
50 yield* LakeFormation.SearchDatabasesByLFTags();
51 const searchTablesByLFTags = yield* LakeFormation.SearchTablesByLFTags();
52 const getResourceLFTags = yield* LakeFormation.GetResourceLFTags();
53 const getEffectivePermissionsForPath =
54 yield* LakeFormation.GetEffectivePermissionsForPath();
55 const getLFTag = yield* LakeFormation.GetLFTag();
56 const listLFTags = yield* LakeFormation.ListLFTags();
57 const listPermissions = yield* LakeFormation.ListPermissions();
58
59 const bound = {
60 getDataLakePrincipal,
61 getTemporaryGlueTableCredentials,
62 getTemporaryGluePartitionCredentials,
63 getTemporaryDataLocationCredentials,
64 searchDatabasesByLFTags,
65 searchTablesByLFTags,
66 getResourceLFTags,
67 getEffectivePermissionsForPath,
68 getLFTag,
69 listLFTags,
70 listPermissions,
71 };
72
73 // A syntactically valid, foreign-account Glue table ARN — the credential
74 // vending probes prove the IAM grant reaches the API by observing a
75 // *typed* Lake Formation rejection rather than an IAM signature failure.
76 const foreignTableArn = Effect.sync(
77 () =>
78 `arn:aws:glue:${process.env.AWS_REGION}:123456789012:table/missing_db/missing_table`,
79 );
80
81 return {
82 fetch: Effect.gen(function* () {
83 const request = yield* HttpServerRequest;
84 const url = new URL(request.originalUrl);
85 const pathname = url.pathname;
86
87 if (request.method === "GET" && pathname === "/bindings") {
88 return yield* HttpServerResponse.json({
89 bound: Object.keys(bound),
90 });
91 }
92
93 if (request.method === "GET" && pathname === "/principal") {
94 const result = yield* getDataLakePrincipal().pipe(
95 Effect.map((r) => ({ tag: "Ok", identity: r.Identity ?? null })),
96 );
97 return yield* HttpServerResponse.json(result);
98 }
99
100 if (request.method === "GET" && pathname === "/tags") {
101 const result = yield* listLFTags().pipe(
102 Effect.map((r) => ({ tag: "Ok", count: (r.LFTags ?? []).length })),
103 );
104 return yield* HttpServerResponse.json(result);
105 }
106
107 if (request.method === "GET" && pathname === "/tag-missing") {
108 const result = yield* getLFTag({ TagKey: MISSING_TAG_KEY }).pipe(
109 Effect.map(() => ({ tag: "Ok" })),
110 Effect.catchTag(
111 ["EntityNotFoundException", "AccessDeniedException"],
112 (e) => Effect.succeed({ tag: e._tag }),
113 ),
114 );
115 return yield* HttpServerResponse.json(result);
116 }
117
118 if (request.method === "GET" && pathname === "/permissions") {
119 const result = yield* listPermissions().pipe(
120 Effect.map((r) => ({
121 tag: "Ok",
122 count: (r.PrincipalResourcePermissions ?? []).length,
123 })),
124 );
125 return yield* HttpServerResponse.json(result);
126 }
127
128 if (request.method === "GET" && pathname === "/search-databases") {
129 const result = yield* searchDatabasesByLFTags({
130 Expression: [{ TagKey: MISSING_TAG_KEY, TagValues: ["x"] }],
131 }).pipe(
132 Effect.map((r) => ({
133 tag: "Ok",
134 count: (r.DatabaseList ?? []).length,
135 })),
136 Effect.catchTag(
137 ["EntityNotFoundException", "AccessDeniedException"],
138 (e) => Effect.succeed({ tag: e._tag, count: 0 }),
139 ),
140 );
141 return yield* HttpServerResponse.json(result);
142 }
143
144 if (request.method === "GET" && pathname === "/search-tables") {
145 const result = yield* searchTablesByLFTags({
146 Expression: [{ TagKey: MISSING_TAG_KEY, TagValues: ["x"] }],
147 }).pipe(
148 Effect.map((r) => ({
149 tag: "Ok",
150 count: (r.TableList ?? []).length,
151 })),
152 Effect.catchTag(
153 ["EntityNotFoundException", "AccessDeniedException"],
154 (e) => Effect.succeed({ tag: e._tag, count: 0 }),
155 ),
156 );
157 return yield* HttpServerResponse.json(result);
158 }
159
160 if (request.method === "GET" && pathname === "/resource-tags") {
161 const result = yield* getResourceLFTags({
162 Resource: { Database: { Name: FIXTURE_DATABASE } },
163 }).pipe(
164 Effect.map((r) => ({
165 tag: "Ok",
166 count: (r.LFTagOnDatabase ?? []).length,
167 })),
168 Effect.catchTag(
169 [
170 "EntityNotFoundException",
171 "AccessDeniedException",
172 "GlueEncryptionException",
173 ],
174 (e) => Effect.succeed({ tag: e._tag, count: 0 }),
175 ),
176 );
177 return yield* HttpServerResponse.json(result);
178 }
179
180 if (request.method === "GET" && pathname === "/effective-permissions") {
181 const result = yield* getEffectivePermissionsForPath({
182 ResourceArn: "arn:aws:s3:::alchemy-lf-bindings-nonexistent",
183 }).pipe(
184 Effect.map((r) => ({
185 tag: "Ok",
186 count: (r.Permissions ?? []).length,
187 })),
188 Effect.catchTag(
189 ["EntityNotFoundException", "InvalidInputException"],
190 (e) => Effect.succeed({ tag: e._tag, count: 0 }),
191 ),
192 );
193 return yield* HttpServerResponse.json(result);
194 }
195
196 if (request.method === "GET" && pathname === "/table-credentials") {
197 const TableArn = yield* foreignTableArn;
198 const result = yield* getTemporaryGlueTableCredentials({
199 TableArn,
200 Permissions: ["SELECT"],
201 SupportedPermissionTypes: ["COLUMN_PERMISSION"],
202 }).pipe(
203 Effect.map(() => ({ tag: "Ok" })),
204 Effect.catchTag(
205 [
206 "AccessDeniedException",
207 "EntityNotFoundException",
208 "InvalidInputException",
209 "PermissionTypeMismatchException",
210 ],
211 (e) => Effect.succeed({ tag: e._tag }),
212 ),
213 );
214 return yield* HttpServerResponse.json(result);
215 }
216
217 if (request.method === "GET" && pathname === "/partition-credentials") {
218 const TableArn = yield* foreignTableArn;
219 const result = yield* getTemporaryGluePartitionCredentials({
220 TableArn,
221 Partition: { Values: ["x"] },
222 Permissions: ["SELECT"],
223 }).pipe(
224 Effect.map(() => ({ tag: "Ok" })),
225 Effect.catchTag(
226 [
227 "AccessDeniedException",
228 "EntityNotFoundException",
229 "InvalidInputException",
230 "PermissionTypeMismatchException",
231 ],
232 (e) => Effect.succeed({ tag: e._tag }),
233 ),
234 );
235 return yield* HttpServerResponse.json(result);
236 }
237
238 if (request.method === "GET" && pathname === "/location-credentials") {
239 const result = yield* getTemporaryDataLocationCredentials({
240 DataLocations: ["arn:aws:s3:::alchemy-lf-bindings-nonexistent"],
241 CredentialsScope: "READ",
242 }).pipe(
243 Effect.map(() => ({ tag: "Ok" })),
244 Effect.catchTag(
245 [
246 "AccessDeniedException",
247 "EntityNotFoundException",
248 "InvalidInputException",
249 "ConflictException",
250 ],
251 (e) => Effect.succeed({ tag: e._tag }),
252 ),
253 );
254 return yield* HttpServerResponse.json(result);
255 }
256
257 return yield* HttpServerResponse.json(
258 { error: "not found" },
259 { status: 404 },
260 );
261 }).pipe(Effect.orDie),
262 };
263 }).pipe(
264 Effect.provide(
265 Layer.mergeAll(
266 LakeFormation.GetDataLakePrincipalHttp,
267 LakeFormation.GetTemporaryGlueTableCredentialsHttp,
268 LakeFormation.GetTemporaryGluePartitionCredentialsHttp,
269 LakeFormation.GetTemporaryDataLocationCredentialsHttp,
270 LakeFormation.SearchDatabasesByLFTagsHttp,
271 LakeFormation.SearchTablesByLFTagsHttp,
272 LakeFormation.GetResourceLFTagsHttp,
273 LakeFormation.GetEffectivePermissionsForPathHttp,
274 LakeFormation.GetLFTagHttp,
275 LakeFormation.ListLFTagsHttp,
276 LakeFormation.ListPermissionsHttp,
277 ),
278 ),
279 ),
280);