1import * as ram from "@distilled.cloud/aws/ram";
2import * as Array from "effect/Array";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import 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 */
21export 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
35export 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
67export 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 */
128export const Permission = Resource<Permission>("AWS.RAM.Permission");
129
130const 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`. */
136const 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. */
149const isLive = (status: string | undefined): boolean =>
150 status !== "DELETING" && status !== "DELETED";
151
152/** Serialize the declared policy template to RAM's JSON wire format. */
153const 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. */
161const 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
183const 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. */
197const 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. */
208const 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
221export 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 );