1import * as lf from "@distilled.cloud/aws/lakeformation";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { AWSEnvironment } from "../Environment.ts";
7import type { Providers } from "../Providers.ts";
8import { retryWhileInvalidPrincipal } from "./internal.ts";
9
10/**
11 * A principal/permissions pair used for database and table default
12 * permissions.
13 */
14export interface DefaultPermissionSpec {
15 /**
16 * The principal (IAM ARN or the `IAM_ALLOWED_PRINCIPALS` group).
17 */
18 principal: string;
19 /**
20 * The permissions granted by default (e.g. `["ALL"]`).
21 */
22 permissions: lf.Permission[];
23}
24
25export interface DataLakeSettingsProps {
26 /**
27 * Principals to ensure are Lake Formation data lake administrators.
28 * Managed additively: admins that already existed (or were added out of
29 * band) are preserved; on destroy only the admins this resource added are
30 * removed.
31 */
32 dataLakeAdmins?: string[];
33 /**
34 * Principals to ensure are read-only administrators. Managed additively,
35 * like `dataLakeAdmins`.
36 */
37 readOnlyAdmins?: string[];
38 /**
39 * Default permissions applied to newly created databases. The prior value
40 * is captured on first management and restored on destroy.
41 */
42 createDatabaseDefaultPermissions?: DefaultPermissionSpec[];
43 /**
44 * Default permissions applied to newly created tables. The prior value is
45 * captured on first management and restored on destroy.
46 */
47 createTableDefaultPermissions?: DefaultPermissionSpec[];
48 /**
49 * Key/value parameters on the data lake settings (e.g.
50 * `CROSS_ACCOUNT_VERSION`). Captured and restored like the default
51 * permissions.
52 */
53 parameters?: Record<string, string>;
54 /**
55 * Account IDs whose resource shares are trusted.
56 */
57 trustedResourceOwners?: string[];
58 /**
59 * Allow external engines (EMR etc.) to filter data with Lake Formation
60 * permissions.
61 */
62 allowExternalDataFiltering?: boolean;
63 /**
64 * Allow external engines full table access without session tags.
65 */
66 allowFullTableExternalDataAccess?: boolean;
67 /**
68 * Principals allowed to use external data filtering.
69 */
70 externalDataFilteringAllowList?: string[];
71 /**
72 * Session tag values authorized for external data filtering.
73 */
74 authorizedSessionTagValueList?: string[];
75 /**
76 * The catalog id (AWS account id) the settings apply to. Changing it
77 * replaces the resource.
78 * @default the caller's account
79 */
80 catalogId?: string;
81}
82
83/** Prop names whose prior values are captured and restored on destroy. */
84export type ManagedField =
85 | "createDatabaseDefaultPermissions"
86 | "createTableDefaultPermissions"
87 | "parameters"
88 | "trustedResourceOwners"
89 | "allowExternalDataFiltering"
90 | "allowFullTableExternalDataAccess"
91 | "externalDataFilteringAllowList"
92 | "authorizedSessionTagValueList";
93
94export interface DataLakeSettings extends Resource<
95 "AWS.LakeFormation.DataLakeSettings",
96 DataLakeSettingsProps,
97 {
98 catalogId: string;
99 dataLakeAdmins: string[];
100 readOnlyAdmins: string[];
101 managedAdmins: string[];
102 managedReadOnlyAdmins: string[];
103 managedFields: ManagedField[];
104 captured: lf.DataLakeSettings;
105 },
106 {},
107 Providers
108> {}
109
110/**
111 * The Lake Formation data lake settings for the account — an account/region
112 * singleton controlling who the data lake administrators are, the default
113 * permissions for new databases/tables, and external data filtering.
114 *
115 * The resource is capture-and-restore: the pre-existing settings are
116 * snapshotted the first time it reconciles, admin lists are managed
117 * additively (existing admins are never removed), and destroy puts back what
118 * was there before for everything this resource managed.
119 *
120 * ### Managing Administrators
121 * **Example:** Add a Data Lake Administrator
122 * ```typescript
123 * import * as AWS from "alchemy/AWS";
124 *
125 * const settings = yield* AWS.LakeFormation.DataLakeSettings("Settings", {
126 * dataLakeAdmins: [adminRole.roleArn],
127 * });
128 * ```
129 *
130 * ### Default Permissions
131 * **Example:** Disable IAM-Allowed-Principals Defaults
132 * ```typescript
133 * const settings = yield* AWS.LakeFormation.DataLakeSettings("Settings", {
134 * dataLakeAdmins: [adminRole.roleArn],
135 * createDatabaseDefaultPermissions: [],
136 * createTableDefaultPermissions: [],
137 * });
138 * ```
139 *
140 * @resource
141 */
142export const DataLakeSettings = Resource<DataLakeSettings>(
143 "AWS.LakeFormation.DataLakeSettings",
144);
145
146const principalIds = (
147 principals: lf.DataLakePrincipal[] | undefined,
148): string[] =>
149 (principals ?? [])
150 .map((p) => p.DataLakePrincipalIdentifier)
151 .filter((id): id is string => id !== undefined);
152
153const toPrincipals = (ids: string[]): lf.DataLakePrincipal[] =>
154 ids.map((id) => ({ DataLakePrincipalIdentifier: id }));
155
156const toWireDefaults = (
157 specs: DefaultPermissionSpec[],
158): lf.PrincipalPermissions[] =>
159 specs.map((s) => ({
160 Principal: { DataLakePrincipalIdentifier: s.principal },
161 Permissions: s.permissions,
162 }));
163
164const dedupe = (values: string[]): string[] => [...new Set(values)];
165
166/**
167 * Stable fingerprint of the aspects of the settings this resource manages —
168 * used to skip the `PutDataLakeSettings` call when nothing changed.
169 */
170const fingerprint = (s: lf.DataLakeSettings): string =>
171 JSON.stringify({
172 admins: principalIds(s.DataLakeAdmins).sort(),
173 readOnlyAdmins: principalIds(s.ReadOnlyAdmins).sort(),
174 createDb: (s.CreateDatabaseDefaultPermissions ?? []).map((p) => ({
175 principal: p.Principal?.DataLakePrincipalIdentifier,
176 permissions: [...(p.Permissions ?? [])].sort(),
177 })),
178 createTable: (s.CreateTableDefaultPermissions ?? []).map((p) => ({
179 principal: p.Principal?.DataLakePrincipalIdentifier,
180 permissions: [...(p.Permissions ?? [])].sort(),
181 })),
182 parameters: Object.entries(s.Parameters ?? {}).sort(([a], [b]) =>
183 a.localeCompare(b),
184 ),
185 trusted: [...(s.TrustedResourceOwners ?? [])].sort(),
186 allowExternal: s.AllowExternalDataFiltering ?? false,
187 allowFullTable: s.AllowFullTableExternalDataAccess ?? false,
188 allowList: principalIds(s.ExternalDataFilteringAllowList).sort(),
189 sessionTags: [...(s.AuthorizedSessionTagValueList ?? [])].sort(),
190 });
191
192/** Plain JSON snapshot of the observed settings for capture-and-restore. */
193const snapshot = (s: lf.DataLakeSettings): lf.DataLakeSettings =>
194 JSON.parse(JSON.stringify(s)) as lf.DataLakeSettings;
195
196const MANAGED_FIELDS: ManagedField[] = [
197 "createDatabaseDefaultPermissions",
198 "createTableDefaultPermissions",
199 "parameters",
200 "trustedResourceOwners",
201 "allowExternalDataFiltering",
202 "allowFullTableExternalDataAccess",
203 "externalDataFilteringAllowList",
204 "authorizedSessionTagValueList",
205];
206
207/** Copy one managed field's wire value from `source` onto `target`. */
208const copyField = (
209 target: lf.DataLakeSettings,
210 source: lf.DataLakeSettings,
211 field: ManagedField,
212): void => {
213 switch (field) {
214 case "createDatabaseDefaultPermissions":
215 target.CreateDatabaseDefaultPermissions =
216 source.CreateDatabaseDefaultPermissions;
217 break;
218 case "createTableDefaultPermissions":
219 target.CreateTableDefaultPermissions =
220 source.CreateTableDefaultPermissions;
221 break;
222 case "parameters":
223 target.Parameters = source.Parameters;
224 break;
225 case "trustedResourceOwners":
226 target.TrustedResourceOwners = source.TrustedResourceOwners;
227 break;
228 case "allowExternalDataFiltering":
229 target.AllowExternalDataFiltering = source.AllowExternalDataFiltering;
230 break;
231 case "allowFullTableExternalDataAccess":
232 target.AllowFullTableExternalDataAccess =
233 source.AllowFullTableExternalDataAccess;
234 break;
235 case "externalDataFilteringAllowList":
236 target.ExternalDataFilteringAllowList =
237 source.ExternalDataFilteringAllowList;
238 break;
239 case "authorizedSessionTagValueList":
240 target.AuthorizedSessionTagValueList =
241 source.AuthorizedSessionTagValueList;
242 break;
243 }
244};
245
246/** Apply a desired prop value for one managed field onto `target`. */
247const applyField = (
248 target: lf.DataLakeSettings,
249 news: DataLakeSettingsProps,
250 field: ManagedField,
251): void => {
252 switch (field) {
253 case "createDatabaseDefaultPermissions":
254 target.CreateDatabaseDefaultPermissions = toWireDefaults(
255 news.createDatabaseDefaultPermissions!,
256 );
257 break;
258 case "createTableDefaultPermissions":
259 target.CreateTableDefaultPermissions = toWireDefaults(
260 news.createTableDefaultPermissions!,
261 );
262 break;
263 case "parameters":
264 target.Parameters = news.parameters;
265 break;
266 case "trustedResourceOwners":
267 target.TrustedResourceOwners = news.trustedResourceOwners;
268 break;
269 case "allowExternalDataFiltering":
270 target.AllowExternalDataFiltering = news.allowExternalDataFiltering;
271 break;
272 case "allowFullTableExternalDataAccess":
273 target.AllowFullTableExternalDataAccess =
274 news.allowFullTableExternalDataAccess;
275 break;
276 case "externalDataFilteringAllowList":
277 target.ExternalDataFilteringAllowList = toPrincipals(
278 news.externalDataFilteringAllowList!,
279 );
280 break;
281 case "authorizedSessionTagValueList":
282 target.AuthorizedSessionTagValueList = news.authorizedSessionTagValueList;
283 break;
284 }
285};
286
287export const DataLakeSettingsProvider = () =>
288 Provider.effect(
289 DataLakeSettings,
290 Effect.gen(function* () {
291 const observe = Effect.fn(function* (catalogId: string | undefined) {
292 const response = yield* lf.getDataLakeSettings({
293 CatalogId: catalogId,
294 });
295 return response.DataLakeSettings ?? {};
296 });
297
298 /**
299 * Merge managed admins into the observed list: keep everything
300 * observed except principals we previously managed and no longer
301 * manage (unless they were admins before we ever touched the
302 * settings), then union with the currently-managed set.
303 */
304 const mergeAdmins = (input: {
305 observed: string[];
306 managed: string[];
307 previouslyManaged: string[];
308 captured: string[];
309 }): string[] => {
310 const removedByUs = input.previouslyManaged.filter(
311 (p) => !input.managed.includes(p),
312 );
313 return dedupe([
314 ...input.observed.filter(
315 (p) => !removedByUs.includes(p) || input.captured.includes(p),
316 ),
317 ...input.managed,
318 ]);
319 };
320
321 return DataLakeSettings.Provider.of({
322 stables: ["catalogId"],
323
324 // Account/region singleton — return the single instance.
325 list: () =>
326 Effect.gen(function* () {
327 const observed = yield* observe(undefined);
328 const { accountId } = yield* AWSEnvironment.current;
329 return [
330 {
331 catalogId: accountId,
332 dataLakeAdmins: principalIds(observed.DataLakeAdmins),
333 readOnlyAdmins: principalIds(observed.ReadOnlyAdmins),
334 managedAdmins: [],
335 managedReadOnlyAdmins: [],
336 managedFields: [] as ManagedField[],
337 captured: snapshot(observed),
338 },
339 ];
340 }),
341
342 read: Effect.fn(function* ({ olds, output }) {
343 const { accountId } = yield* AWSEnvironment.current;
344 const catalogId = output?.catalogId ?? olds?.catalogId ?? accountId;
345 const observed = yield* observe(olds?.catalogId);
346 return {
347 catalogId,
348 dataLakeAdmins: principalIds(observed.DataLakeAdmins),
349 readOnlyAdmins: principalIds(observed.ReadOnlyAdmins),
350 managedAdmins: output?.managedAdmins ?? [],
351 managedReadOnlyAdmins: output?.managedReadOnlyAdmins ?? [],
352 managedFields: output?.managedFields ?? [],
353 captured: output?.captured ?? snapshot(observed),
354 };
355 }),
356
357 diff: Effect.fn(function* ({ news, olds }) {
358 if (!isResolved(news)) return undefined;
359 if ((news.catalogId ?? undefined) !== (olds.catalogId ?? undefined)) {
360 return { action: "replace" } as const;
361 }
362 // everything else → update (singleton sync)
363 }),
364
365 reconcile: Effect.fn(function* ({ news, olds, output, session }) {
366 const { accountId } = yield* AWSEnvironment.current;
367 const catalogId = news.catalogId ?? accountId;
368
369 // 1. OBSERVE — cloud settings are authoritative; capture the
370 // pre-existing settings the first time we manage them.
371 const observed = yield* observe(news.catalogId);
372 const captured = output?.captured ?? snapshot(observed);
373
374 // 2. SYNC — start from observed, overlay the aspects we manage.
375 const desired = snapshot(observed);
376
377 const managedAdmins = news.dataLakeAdmins ?? [];
378 const managedReadOnlyAdmins = news.readOnlyAdmins ?? [];
379 desired.DataLakeAdmins = toPrincipals(
380 mergeAdmins({
381 observed: principalIds(observed.DataLakeAdmins),
382 managed: managedAdmins,
383 previouslyManaged: olds?.dataLakeAdmins ?? [],
384 captured: principalIds(captured.DataLakeAdmins),
385 }),
386 );
387 desired.ReadOnlyAdmins = toPrincipals(
388 mergeAdmins({
389 observed: principalIds(observed.ReadOnlyAdmins),
390 managed: managedReadOnlyAdmins,
391 previouslyManaged: olds?.readOnlyAdmins ?? [],
392 captured: principalIds(captured.ReadOnlyAdmins),
393 }),
394 );
395
396 const managedFields = MANAGED_FIELDS.filter(
397 (field) => news[field] !== undefined,
398 );
399 for (const field of MANAGED_FIELDS) {
400 if (news[field] !== undefined) {
401 applyField(desired, news, field);
402 } else if (olds?.[field] !== undefined) {
403 // field was managed before and is no longer — restore the
404 // captured pre-management value.
405 copyField(desired, captured, field);
406 }
407 }
408
409 if (fingerprint(desired) !== fingerprint(observed)) {
410 yield* lf
411 .putDataLakeSettings({
412 CatalogId: news.catalogId,
413 DataLakeSettings: desired,
414 })
415 .pipe(retryWhileInvalidPrincipal);
416 yield* session.note("Updated data lake settings");
417 }
418
419 return {
420 catalogId,
421 dataLakeAdmins: principalIds(desired.DataLakeAdmins),
422 readOnlyAdmins: principalIds(desired.ReadOnlyAdmins),
423 managedAdmins,
424 managedReadOnlyAdmins,
425 managedFields,
426 captured,
427 };
428 }),
429
430 delete: Effect.fn(function* ({ output, session }) {
431 // Restore: remove the admins we added (unless they were already
432 // admins before we managed the settings) and put back the captured
433 // values for every field we managed.
434 const catalogIdInput =
435 output.catalogId === undefined ? undefined : output.catalogId;
436 const observed = yield* observe(catalogIdInput);
437 const captured = output.captured;
438 const desired = snapshot(observed);
439
440 const capturedAdmins = principalIds(captured.DataLakeAdmins);
441 desired.DataLakeAdmins = toPrincipals(
442 principalIds(observed.DataLakeAdmins).filter(
443 (p) =>
444 !output.managedAdmins.includes(p) || capturedAdmins.includes(p),
445 ),
446 );
447 const capturedReadOnly = principalIds(captured.ReadOnlyAdmins);
448 desired.ReadOnlyAdmins = toPrincipals(
449 principalIds(observed.ReadOnlyAdmins).filter(
450 (p) =>
451 !output.managedReadOnlyAdmins.includes(p) ||
452 capturedReadOnly.includes(p),
453 ),
454 );
455
456 for (const field of output.managedFields) {
457 copyField(desired, captured, field);
458 }
459
460 if (fingerprint(desired) !== fingerprint(observed)) {
461 yield* lf
462 .putDataLakeSettings({
463 CatalogId: catalogIdInput,
464 DataLakeSettings: desired,
465 })
466 .pipe(retryWhileInvalidPrincipal);
467 yield* session.note("Restored data lake settings");
468 }
469 }),
470 });
471 }),
472 );