1import * as lf from "@distilled.cloud/aws/lakeformation";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import {
9 observePrincipalPermissions,
10 retryWhileConcurrentModification,
11 retryWhileInvalidPrincipal,
12} from "./internal.ts";
13import {
14 type LakeFormationResourceSpec,
15 toWireResource,
16} from "./ResourceSpec.ts";
17
18export interface PermissionsProps {
19 /**
20 * The principal receiving the permissions — an IAM user/role ARN, an
21 * external account id, or the `IAM_ALLOWED_PRINCIPALS` group. Changing it
22 * replaces the grant.
23 */
24 principal: string;
25 /**
26 * The Lake Formation resource the permissions apply to (exactly one
27 * variant set). Changing it replaces the grant.
28 */
29 resource: LakeFormationResourceSpec;
30 /**
31 * The permissions to grant (e.g. `["DESCRIBE"]`, `["SELECT"]`, `["ALL"]`).
32 */
33 permissions: lf.Permission[];
34 /**
35 * The subset of permissions the principal may also grant to others.
36 */
37 permissionsWithGrantOption?: lf.Permission[];
38 /**
39 * The catalog id (AWS account id). Changing it replaces the grant.
40 * @default the caller's account
41 */
42 catalogId?: string;
43}
44
45export interface Permissions extends Resource<
46 "AWS.LakeFormation.Permissions",
47 PermissionsProps,
48 {
49 principal: string;
50 resource: lf.Resource;
51 permissions: lf.Permission[];
52 permissionsWithGrantOption: lf.Permission[];
53 catalogId: string | undefined;
54 },
55 {},
56 Providers
57> {}
58
59/**
60 * A Lake Formation permission grant — gives a principal permissions on a
61 * Data Catalog resource (database, table, data location, LF-tag, or LF-tag
62 * policy expression). The resource owns the full permission set for its
63 * principal/resource pair: permissions removed from the props are revoked.
64 *
65 * The caller must be a Lake Formation data lake administrator (or hold the
66 * grant option on the resource) — see
67 * {@link DataLakeSettings | AWS.LakeFormation.DataLakeSettings}.
68 *
69 * ### Granting Permissions
70 * **Example:** Grant Database Permissions to a Role
71 * ```typescript
72 * import * as AWS from "alchemy/AWS";
73 *
74 * const database = yield* AWS.Glue.Database("Analytics", {});
75 * const grant = yield* AWS.LakeFormation.Permissions("AnalystDbAccess", {
76 * principal: analystRole.roleArn,
77 * resource: { database: { name: database.databaseName } },
78 * permissions: ["DESCRIBE", "CREATE_TABLE"],
79 * });
80 * ```
81 *
82 * **Example:** Grant Table Select with Grant Option
83 * ```typescript
84 * const grant = yield* AWS.LakeFormation.Permissions("AnalystTableAccess", {
85 * principal: analystRole.roleArn,
86 * resource: {
87 * table: { databaseName: database.databaseName, tableWildcard: true },
88 * },
89 * permissions: ["SELECT", "DESCRIBE"],
90 * permissionsWithGrantOption: ["SELECT"],
91 * });
92 * ```
93 *
94 * **Example:** Grant Data Location Access
95 * ```typescript
96 * const grant = yield* AWS.LakeFormation.Permissions("EtlLocationAccess", {
97 * principal: etlRole.roleArn,
98 * resource: { dataLocation: { resourceArn: location.resourceArn } },
99 * permissions: ["DATA_LOCATION_ACCESS"],
100 * });
101 * ```
102 *
103 * @resource
104 */
105export const Permissions = Resource<Permissions>(
106 "AWS.LakeFormation.Permissions",
107);
108
109const dedupeSort = (values: lf.Permission[]): lf.Permission[] =>
110 [...new Set(values)].sort();
111
112export const PermissionsProvider = () =>
113 Provider.effect(
114 Permissions,
115 Effect.gen(function* () {
116 return Permissions.Provider.of({
117 stables: ["principal", "resource", "catalogId"],
118
119 list: () =>
120 Effect.gen(function* () {
121 const pages = yield* lf.listPermissions
122 .pages({})
123 .pipe(Stream.runCollect);
124 return Array.from(pages)
125 .flatMap((page) => page.PrincipalResourcePermissions ?? [])
126 .filter(
127 (e) =>
128 e.Principal?.DataLakePrincipalIdentifier !== undefined &&
129 e.Resource !== undefined,
130 )
131 .map((e) => ({
132 principal: e.Principal!.DataLakePrincipalIdentifier!,
133 resource: e.Resource!,
134 permissions: dedupeSort([...(e.Permissions ?? [])]),
135 permissionsWithGrantOption: dedupeSort([
136 ...(e.PermissionsWithGrantOption ?? []),
137 ]),
138 catalogId: undefined,
139 }));
140 }),
141
142 read: Effect.fn(function* ({ olds, output }) {
143 const principal = output?.principal ?? olds?.principal;
144 const resource =
145 output?.resource ??
146 (olds !== undefined ? toWireResource(olds.resource) : undefined);
147 if (principal === undefined || resource === undefined) {
148 return undefined;
149 }
150 const catalogId = output?.catalogId ?? olds?.catalogId;
151 const current = yield* observePrincipalPermissions(
152 principal,
153 resource,
154 catalogId,
155 ).pipe(
156 // a deleted IAM principal surfaces as "Invalid principal" — the
157 // grant is effectively gone.
158 Effect.catchTag("InvalidLakeFormationPrincipal", () =>
159 Effect.succeed({
160 permissions: [],
161 permissionsWithGrantOption: [],
162 }),
163 ),
164 );
165 if (current.permissions.length === 0) return undefined;
166 return {
167 principal,
168 resource,
169 permissions: current.permissions,
170 permissionsWithGrantOption: current.permissionsWithGrantOption,
171 catalogId,
172 };
173 }),
174
175 diff: Effect.fn(function* ({ news, olds }) {
176 if (!isResolved(news)) return undefined;
177 if (news.principal !== olds.principal) {
178 return { action: "replace" } as const;
179 }
180 if (
181 JSON.stringify(toWireResource(news.resource)) !==
182 JSON.stringify(toWireResource(olds.resource))
183 ) {
184 return { action: "replace" } as const;
185 }
186 if ((news.catalogId ?? undefined) !== (olds.catalogId ?? undefined)) {
187 return { action: "replace" } as const;
188 }
189 // permissions / permissionsWithGrantOption → update
190 }),
191
192 reconcile: Effect.fn(function* ({ news, session }) {
193 const resource = toWireResource(news.resource);
194 const principal: lf.DataLakePrincipal = {
195 DataLakePrincipalIdentifier: news.principal,
196 };
197 const desired = dedupeSort(news.permissions);
198 const desiredGrant = dedupeSort(
199 news.permissionsWithGrantOption ?? [],
200 );
201
202 // 1. OBSERVE — retry through IAM propagation of a fresh principal.
203 let current = yield* observePrincipalPermissions(
204 news.principal,
205 resource,
206 news.catalogId,
207 ).pipe(retryWhileInvalidPrincipal);
208
209 // 2. SYNC — revoke stale grants, then grant the desired set
210 // (GrantPermissions is an idempotent upsert).
211 const stale = current.permissions.filter((p) => !desired.includes(p));
212 if (stale.length > 0) {
213 yield* lf
214 .revokePermissions({
215 CatalogId: news.catalogId,
216 Principal: principal,
217 Resource: resource,
218 Permissions: stale,
219 PermissionsWithGrantOption: stale.some((p) =>
220 current.permissionsWithGrantOption.includes(p),
221 )
222 ? stale.filter((p) =>
223 current.permissionsWithGrantOption.includes(p),
224 )
225 : undefined,
226 })
227 .pipe(retryWhileConcurrentModification);
228 }
229
230 // grant-option-only removals (permission kept, option dropped):
231 // revoke the pair, the grant below restores the plain permission.
232 const staleOptionOnly = current.permissionsWithGrantOption.filter(
233 (p) => !desiredGrant.includes(p) && desired.includes(p),
234 );
235 if (staleOptionOnly.length > 0) {
236 yield* lf
237 .revokePermissions({
238 CatalogId: news.catalogId,
239 Principal: principal,
240 Resource: resource,
241 Permissions: staleOptionOnly,
242 PermissionsWithGrantOption: staleOptionOnly,
243 })
244 .pipe(retryWhileConcurrentModification);
245 }
246
247 const missing = desired.filter(
248 (p) => !current.permissions.includes(p),
249 );
250 const missingGrant = desiredGrant.filter(
251 (p) => !current.permissionsWithGrantOption.includes(p),
252 );
253 if (
254 missing.length > 0 ||
255 missingGrant.length > 0 ||
256 staleOptionOnly.length > 0
257 ) {
258 yield* lf
259 .grantPermissions({
260 CatalogId: news.catalogId,
261 Principal: principal,
262 Resource: resource,
263 Permissions: desired,
264 PermissionsWithGrantOption:
265 desiredGrant.length > 0 ? desiredGrant : undefined,
266 })
267 .pipe(
268 retryWhileInvalidPrincipal,
269 retryWhileConcurrentModification,
270 );
271 }
272
273 // 3. RETURN fresh state
274 current = yield* observePrincipalPermissions(
275 news.principal,
276 resource,
277 news.catalogId,
278 );
279 yield* session.note(
280 `${news.principal} ← [${current.permissions.join(", ")}]`,
281 );
282 return {
283 principal: news.principal,
284 resource,
285 permissions: current.permissions,
286 permissionsWithGrantOption: current.permissionsWithGrantOption,
287 catalogId: news.catalogId,
288 };
289 }),
290
291 delete: Effect.fn(function* ({ output }) {
292 // Observe first so we only revoke what actually exists (revoking a
293 // non-existent grant is an InvalidInputException).
294 const current = yield* observePrincipalPermissions(
295 output.principal,
296 output.resource,
297 output.catalogId,
298 ).pipe(
299 Effect.catchTag("InvalidLakeFormationPrincipal", () =>
300 Effect.succeed({
301 permissions: [] as lf.Permission[],
302 permissionsWithGrantOption: [] as lf.Permission[],
303 }),
304 ),
305 );
306 if (current.permissions.length === 0) return;
307 yield* lf
308 .revokePermissions({
309 CatalogId: output.catalogId,
310 Principal: { DataLakePrincipalIdentifier: output.principal },
311 Resource: output.resource,
312 Permissions: current.permissions,
313 PermissionsWithGrantOption:
314 current.permissionsWithGrantOption.length > 0
315 ? current.permissionsWithGrantOption
316 : undefined,
317 })
318 .pipe(
319 retryWhileConcurrentModification,
320 Effect.catchTag("EntityNotFoundException", () => Effect.void),
321 );
322 }),
323 });
324 }),
325 );