| 1 | import * as ram from "@distilled.cloud/aws/ram"; |
| 2 | import * as Array from "effect/Array"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | |
| 14 | /** |
| 15 | * The policy template of a customer managed permission — the actions a |
| 16 | * resource share grants to principals for the permission's resource type. |
| 17 | * RAM policy templates only support `Effect: "Allow"` with `Action` and an |
| 18 | * optional `Condition`; the `Principal` and `Resource` are supplied by the |
| 19 | * resource share at association time. |
| 20 | */ |
| 21 | export interface PermissionPolicyTemplate { |
| 22 | /** |
| 23 | * Actions granted to principals the resource share is shared with. Must be |
| 24 | * a subset of the actions RAM supports for the permission's `resourceType` |
| 25 | * (for example `appsync:SourceGraphQL` for `appsync:Apis`). |
| 26 | */ |
| 27 | actions: string[]; |
| 28 | |
| 29 | /** |
| 30 | * Optional IAM condition block constraining when the actions are granted. |
| 31 | */ |
| 32 | condition?: Record<string, unknown>; |
| 33 | } |
| 34 | |
| 35 | export interface PermissionProps { |
| 36 | /** |
| 37 | * Name of the customer managed permission. Must be unique within your |
| 38 | * account and Region. If omitted, Alchemy generates a deterministic name |
| 39 | * from the stack, stage, and logical ID. Max 36 characters. |
| 40 | * Changing the name replaces the permission. |
| 41 | */ |
| 42 | permissionName?: string; |
| 43 | |
| 44 | /** |
| 45 | * The resource type this permission applies to, in `service:Type` format — |
| 46 | * for example `appsync:Apis`. Only some resource types support customer |
| 47 | * managed permissions (others, like `ec2:Subnet`, only allow the AWS |
| 48 | * managed default permission). |
| 49 | * Changing the resource type replaces the permission. |
| 50 | */ |
| 51 | resourceType: string; |
| 52 | |
| 53 | /** |
| 54 | * The policy template granted by resource shares using this permission. |
| 55 | * Changing the template creates a new permission version and promotes it |
| 56 | * to the default; the previous default version is deleted when it is no |
| 57 | * longer attached to any resource share. |
| 58 | */ |
| 59 | policyTemplate: PermissionPolicyTemplate; |
| 60 | |
| 61 | /** |
| 62 | * Tags applied to the permission. |
| 63 | */ |
| 64 | tags?: Record<string, string>; |
| 65 | } |
| 66 | |
| 67 | export interface Permission extends Resource< |
| 68 | "AWS.RAM.Permission", |
| 69 | PermissionProps, |
| 70 | { |
| 71 | /** ARN of the customer managed permission. */ |
| 72 | permissionArn: string; |
| 73 | /** Name of the permission. */ |
| 74 | name: string; |
| 75 | /** The resource type the permission applies to. */ |
| 76 | resourceType: string; |
| 77 | /** The default version number of the permission. */ |
| 78 | version: string | undefined; |
| 79 | /** Current status (`ATTACHABLE`, `UNATTACHABLE`, `DELETING`, ...). */ |
| 80 | status: string | undefined; |
| 81 | /** Tags applied to the permission. */ |
| 82 | tags: Record<string, string>; |
| 83 | }, |
| 84 | never, |
| 85 | Providers |
| 86 | > {} |
| 87 | |
| 88 | /** |
| 89 | * An AWS Resource Access Manager (RAM) customer managed permission. |
| 90 | * |
| 91 | * A customer managed permission precisely controls which actions principals |
| 92 | * receive on resources of a given type when you attach the permission to a |
| 93 | * {@link ResourceShare} via `permissionArns`. |
| 94 | * |
| 95 | * ### Creating a Permission |
| 96 | * **Example:** Least-privilege AppSync API sharing |
| 97 | * ```typescript |
| 98 | * const permission = yield* Permission("SourceGraphQLOnly", { |
| 99 | * resourceType: "appsync:Apis", |
| 100 | * policyTemplate: { |
| 101 | * actions: ["appsync:SourceGraphQL"], |
| 102 | * }, |
| 103 | * }); |
| 104 | * ``` |
| 105 | * |
| 106 | * **Example:** Attach a permission to a resource share |
| 107 | * ```typescript |
| 108 | * const share = yield* ResourceShare("ApiShare", { |
| 109 | * resourceArns: [api.apiArn], |
| 110 | * principals: ["123456789012"], |
| 111 | * permissionArns: [permission.permissionArn], |
| 112 | * }); |
| 113 | * ``` |
| 114 | * |
| 115 | * ### Updating the Policy |
| 116 | * **Example:** Add an action (creates a new default version) |
| 117 | * ```typescript |
| 118 | * const permission = yield* Permission("SourceGraphQLOnly", { |
| 119 | * resourceType: "appsync:Apis", |
| 120 | * policyTemplate: { |
| 121 | * actions: ["appsync:SourceGraphQL", "appsync:GraphQL"], |
| 122 | * }, |
| 123 | * }); |
| 124 | * ``` |
| 125 | * |
| 126 | * @resource |
| 127 | */ |
| 128 | export const Permission = Resource<Permission>("AWS.RAM.Permission"); |
| 129 | |
| 130 | const toName = (id: string, props: { permissionName?: string } = {}) => |
| 131 | props.permissionName |
| 132 | ? Effect.succeed(props.permissionName) |
| 133 | : createPhysicalName({ id, maxLength: 36 }); |
| 134 | |
| 135 | /** RAM's inline `{ key, value }` tag list → a plain `Record`. */ |
| 136 | const tagsToRecord = ( |
| 137 | tags: readonly ram.Tag[] | undefined, |
| 138 | ): Record<string, string> => { |
| 139 | const record: Record<string, string> = {}; |
| 140 | for (const tag of tags ?? []) { |
| 141 | if (tag.key !== undefined && tag.value !== undefined) { |
| 142 | record[tag.key] = tag.value; |
| 143 | } |
| 144 | } |
| 145 | return record; |
| 146 | }; |
| 147 | |
| 148 | /** A permission is "live" only while it is not deleting/deleted. */ |
| 149 | const isLive = (status: string | undefined): boolean => |
| 150 | status !== "DELETING" && status !== "DELETED"; |
| 151 | |
| 152 | /** Serialize the declared policy template to RAM's JSON wire format. */ |
| 153 | const renderPolicyTemplate = (template: PermissionPolicyTemplate): string => |
| 154 | JSON.stringify({ |
| 155 | Effect: "Allow", |
| 156 | Action: [...template.actions].sort(), |
| 157 | ...(template.condition ? { Condition: template.condition } : {}), |
| 158 | }); |
| 159 | |
| 160 | /** Parse an observed policy document into a comparable canonical string. */ |
| 161 | const canonicalizePolicy = (policy: string | undefined): string => { |
| 162 | if (policy === undefined) return ""; |
| 163 | try { |
| 164 | const parsed = JSON.parse(policy) as { |
| 165 | Effect?: string; |
| 166 | Action?: string | string[]; |
| 167 | Condition?: Record<string, unknown>; |
| 168 | }; |
| 169 | const actions = |
| 170 | typeof parsed.Action === "string" |
| 171 | ? [parsed.Action] |
| 172 | : (parsed.Action ?? []); |
| 173 | return JSON.stringify({ |
| 174 | Effect: parsed.Effect ?? "Allow", |
| 175 | Action: [...actions].sort(), |
| 176 | ...(parsed.Condition ? { Condition: parsed.Condition } : {}), |
| 177 | }); |
| 178 | } catch { |
| 179 | return policy; |
| 180 | } |
| 181 | }; |
| 182 | |
| 183 | const toAttrs = ( |
| 184 | permission: |
| 185 | | ram.ResourceSharePermissionSummary |
| 186 | | ram.ResourceSharePermissionDetail, |
| 187 | ): Permission["Attributes"] => ({ |
| 188 | permissionArn: permission.arn!, |
| 189 | name: permission.name!, |
| 190 | resourceType: permission.resourceType!, |
| 191 | version: permission.version, |
| 192 | status: permission.status, |
| 193 | tags: tagsToRecord(permission.tags), |
| 194 | }); |
| 195 | |
| 196 | /** Read the default version of a permission by ARN; undefined if missing. */ |
| 197 | const readDetail = Effect.fn(function* (arn: string) { |
| 198 | const detail = yield* ram.getPermission({ permissionArn: arn }).pipe( |
| 199 | Effect.map((r) => r.permission), |
| 200 | Effect.catchTag("UnknownResourceException", () => |
| 201 | Effect.succeed(undefined), |
| 202 | ), |
| 203 | ); |
| 204 | return detail && isLive(detail.status) ? detail : undefined; |
| 205 | }); |
| 206 | |
| 207 | /** Find a live customer managed permission owned by us with the given name. */ |
| 208 | const readByName = Effect.fn(function* (name: string) { |
| 209 | const permissions = yield* ram.listPermissions |
| 210 | .pages({ permissionType: "CUSTOMER_MANAGED" }) |
| 211 | .pipe( |
| 212 | Stream.runCollect, |
| 213 | Effect.map((chunk) => |
| 214 | Array.fromIterable(chunk).flatMap((page) => page.permissions ?? []), |
| 215 | ), |
| 216 | ); |
| 217 | const summary = permissions.find((p) => p.name === name && isLive(p.status)); |
| 218 | return summary?.arn ? yield* readDetail(summary.arn) : undefined; |
| 219 | }); |
| 220 | |
| 221 | export const PermissionProvider = () => |
| 222 | Provider.effect( |
| 223 | Permission, |
| 224 | Effect.gen(function* () { |
| 225 | return { |
| 226 | stables: ["permissionArn"], |
| 227 | diff: Effect.fn(function* ({ news, olds }) { |
| 228 | if (!isResolved(news)) return; |
| 229 | // Name and resource type are fixed at creation (RAM has no |
| 230 | // rename/retype API for permissions). |
| 231 | if (olds !== undefined) { |
| 232 | if (news.permissionName !== olds.permissionName) { |
| 233 | return { action: "replace" } as const; |
| 234 | } |
| 235 | if (news.resourceType !== olds.resourceType) { |
| 236 | return { action: "replace" } as const; |
| 237 | } |
| 238 | } |
| 239 | }), |
| 240 | list: () => |
| 241 | ram.listPermissions |
| 242 | .pages({ permissionType: "CUSTOMER_MANAGED" }) |
| 243 | .pipe( |
| 244 | Stream.runCollect, |
| 245 | Effect.map((chunk) => |
| 246 | Array.fromIterable(chunk) |
| 247 | .flatMap((page) => page.permissions ?? []) |
| 248 | .filter( |
| 249 | (p) => |
| 250 | isLive(p.status) && |
| 251 | p.arn !== undefined && |
| 252 | p.name !== undefined, |
| 253 | ) |
| 254 | .map(toAttrs), |
| 255 | ), |
| 256 | ), |
| 257 | read: Effect.fn(function* ({ id, olds, output }) { |
| 258 | const detail = output?.permissionArn |
| 259 | ? yield* readDetail(output.permissionArn) |
| 260 | : yield* readByName(yield* toName(id, olds ?? {})); |
| 261 | if (!detail) return undefined; |
| 262 | const state = toAttrs(detail); |
| 263 | return (yield* hasAlchemyTags(id, state.tags)) |
| 264 | ? state |
| 265 | : Unowned(state); |
| 266 | }), |
| 267 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 268 | const name = yield* toName(id, news); |
| 269 | const internalTags = yield* createInternalTags(id); |
| 270 | const desiredTags = { ...news.tags, ...internalTags }; |
| 271 | const desiredPolicy = renderPolicyTemplate(news.policyTemplate); |
| 272 | |
| 273 | // 1. OBSERVE — output ARN is only a cache; re-read live state. |
| 274 | let detail = output?.permissionArn |
| 275 | ? yield* readDetail(output.permissionArn) |
| 276 | : yield* readByName(name); |
| 277 | |
| 278 | // 2. ENSURE — create if missing. PermissionAlreadyExists is a |
| 279 | // race with a concurrent create; fall through to observation. |
| 280 | if (!detail) { |
| 281 | const created = yield* ram |
| 282 | .createPermission({ |
| 283 | name, |
| 284 | resourceType: news.resourceType, |
| 285 | policyTemplate: desiredPolicy, |
| 286 | tags: Object.entries(desiredTags).map(([key, value]) => ({ |
| 287 | key, |
| 288 | value, |
| 289 | })), |
| 290 | }) |
| 291 | .pipe( |
| 292 | Effect.map((r) => r.permission), |
| 293 | Effect.catchTag("PermissionAlreadyExistsException", () => |
| 294 | Effect.succeed(undefined), |
| 295 | ), |
| 296 | ); |
| 297 | // Prefer the ARN from the create response; on the AlreadyExists |
| 298 | // race fall back to the (briefly eventually consistent) list. |
| 299 | detail = created?.arn |
| 300 | ? yield* readDetail(created.arn) |
| 301 | : yield* readByName(name); |
| 302 | if (!detail?.arn) { |
| 303 | if (created?.arn) { |
| 304 | yield* session.note(created.arn); |
| 305 | return { ...toAttrs(created), tags: desiredTags }; |
| 306 | } |
| 307 | return yield* Effect.fail( |
| 308 | new Error(`permission '${name}' not found after create`), |
| 309 | ); |
| 310 | } |
| 311 | yield* session.note(detail.arn); |
| 312 | return toAttrs(detail); |
| 313 | } |
| 314 | |
| 315 | const arn = detail.arn!; |
| 316 | const previousVersion = detail.version; |
| 317 | |
| 318 | // 3a. SYNC policy — diff the observed default version's policy |
| 319 | // against the desired template; publish a new default version |
| 320 | // on drift, then drop the superseded version (RAM caps a |
| 321 | // permission at 5 versions). |
| 322 | if (canonicalizePolicy(detail.permission) !== desiredPolicy) { |
| 323 | // createPermissionVersion automatically becomes the default. |
| 324 | yield* ram.createPermissionVersion({ |
| 325 | permissionArn: arn, |
| 326 | policyTemplate: desiredPolicy, |
| 327 | }); |
| 328 | if (previousVersion !== undefined) { |
| 329 | // The old version may still be attached to an existing share; |
| 330 | // that's fine — leave it and converge on a later reconcile. |
| 331 | yield* ram |
| 332 | .deletePermissionVersion({ |
| 333 | permissionArn: arn, |
| 334 | permissionVersion: Number(previousVersion), |
| 335 | }) |
| 336 | .pipe( |
| 337 | Effect.catchTag( |
| 338 | [ |
| 339 | "OperationNotPermittedException", |
| 340 | "InvalidParameterException", |
| 341 | ], |
| 342 | () => Effect.void, |
| 343 | ), |
| 344 | ); |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // 3b. SYNC tags — diff against observed cloud tags. |
| 349 | const { upsert, removed } = diffTags( |
| 350 | tagsToRecord(detail.tags), |
| 351 | desiredTags, |
| 352 | ); |
| 353 | if (upsert.length > 0) { |
| 354 | yield* ram.tagResource({ |
| 355 | resourceArn: arn, |
| 356 | tags: upsert.map((t) => ({ key: t.Key, value: t.Value })), |
| 357 | }); |
| 358 | } |
| 359 | if (removed.length > 0) { |
| 360 | yield* ram.untagResource({ resourceArn: arn, tagKeys: removed }); |
| 361 | } |
| 362 | |
| 363 | // 4. RETURN fresh state. |
| 364 | const updated = yield* readDetail(arn); |
| 365 | yield* session.note(arn); |
| 366 | return updated |
| 367 | ? toAttrs(updated) |
| 368 | : { ...toAttrs(detail), tags: desiredTags }; |
| 369 | }), |
| 370 | delete: Effect.fn(function* ({ output }) { |
| 371 | // A permission cannot be deleted while attached to a resource |
| 372 | // share; shares delete asynchronously, so retry briefly on |
| 373 | // OperationNotPermitted before giving up. |
| 374 | yield* ram |
| 375 | .deletePermission({ permissionArn: output.permissionArn }) |
| 376 | .pipe( |
| 377 | Effect.retry({ |
| 378 | while: (e): boolean => |
| 379 | e._tag === "OperationNotPermittedException", |
| 380 | schedule: Schedule.exponential("2 seconds"), |
| 381 | times: 8, |
| 382 | }), |
| 383 | Effect.catchTag("UnknownResourceException", () => Effect.void), |
| 384 | ); |
| 385 | }), |
| 386 | }; |
| 387 | }), |
| 388 | ); |