| 1 | import * as lf from "@distilled.cloud/aws/lakeformation"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import type { Providers } from "../Providers.ts"; |
| 8 | import { |
| 9 | observePrincipalPermissions, |
| 10 | retryWhileConcurrentModification, |
| 11 | retryWhileInvalidPrincipal, |
| 12 | } from "./internal.ts"; |
| 13 | import { |
| 14 | type LakeFormationResourceSpec, |
| 15 | toWireResource, |
| 16 | } from "./ResourceSpec.ts"; |
| 17 | |
| 18 | export 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 | |
| 45 | export 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 | */ |
| 105 | export const Permissions = Resource<Permissions>( |
| 106 | "AWS.LakeFormation.Permissions", |
| 107 | ); |
| 108 | |
| 109 | const dedupeSort = (values: lf.Permission[]): lf.Permission[] => |
| 110 | [...new Set(values)].sort(); |
| 111 | |
| 112 | export 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 | ); |