1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 createTagsList,
11 diffTags,
12 hasTags,
13} from "../../Tags.ts";
14import type { AccountID } from "../Environment.ts";
15import { AWSEnvironment } from "../Environment.ts";
16import type { Providers } from "../Providers.ts";
17import type { IamAction } from "./actions.generated.ts";
18import {
19 oldestNondefaultPolicyVersion,
20 parsePolicyDocument,
21 policyArnFromParts,
22 stringifyPolicyDocument,
23 toTagRecord,
24} from "./common.ts";
25
26export type { IamAction } from "./actions.generated.ts";
27export { stringifyPolicyDocument } from "./common.ts";
28
29export interface PolicyDocument {
30 Version: "2012-10-17";
31 Statement: PolicyStatement[];
32}
33
34export interface PolicyStatement {
35 Effect: "Allow" | "Deny";
36 Sid?: string;
37 Action?: IamAction[] | string[];
38 Resource?: string | string[];
39 Condition?: Record<string, Record<string, string | string[]>>;
40 Principal?: Record<string, string | string[]>;
41 NotPrincipal?: Record<string, string | string[]>;
42 NotAction?: string[];
43 NotResource?: string[];
44}
45
46/**
47 * A single statement in a Service Control Policy (SCP) or Resource Control
48 * Policy (RCP) — the restricted IAM dialect accepted by AWS Organizations.
49 *
50 * SCP statements never carry a `Principal`/`NotPrincipal` (they apply to the
51 * accounts the policy is attached to), and Allow statements are further
52 * restricted by Organizations at write time (`Resource` must be `"*"`, no
53 * `Condition`, no `NotAction`). This interface narrows {@link PolicyStatement}
54 * to the SCP-legal field set; the Allow-statement value restrictions are
55 * enforced by the Organizations API.
56 */
57export interface ServiceControlPolicyStatement {
58 Sid?: string;
59 Effect: "Allow" | "Deny";
60 Action?: IamAction[] | string[];
61 NotAction?: string[];
62 Resource?: string | string[];
63 NotResource?: string[];
64 Condition?: Record<string, Record<string, string | string[]>>;
65}
66
67/**
68 * A Service Control Policy document — the SCP-legal subset of
69 * {@link PolicyDocument} used by AWS Organizations policies
70 * (`SERVICE_CONTROL_POLICY` / `RESOURCE_CONTROL_POLICY`).
71 */
72export interface ServiceControlPolicyDocument {
73 Version: "2012-10-17";
74 Statement: ServiceControlPolicyStatement[];
75}
76
77/**
78 * Canonicalize an IAM policy document for drift comparison.
79 *
80 * Accepts either the raw JSON string a cloud API returned (IAM returns
81 * URL-encoded documents; both encoded and plain strings are handled) or an
82 * in-memory document object, and produces a deterministic string — object keys
83 * sorted recursively, arrays kept in order, no whitespace — so two equivalent
84 * documents compare equal regardless of key ordering or encoding.
85 *
86 * Single-element arrays are collapsed to their element: the IAM policy
87 * grammar treats `["x"]` and `"x"` as equivalent in every list-valued
88 * position (`Action`, `Resource`, principal values, condition values, even
89 * `Statement` itself), and several AWS services (e.g. Secrets Manager)
90 * store the scalar form — without collapsing, a typed document that uses
91 * arrays would spuriously diff against the stored policy on every deploy.
92 *
93 * Unparseable strings are returned unchanged so a diff still fires (and shows
94 * the offending value) instead of throwing inside a provider.
95 */
96export const normalizePolicyDocument = (json: string | object): string => {
97 const stable = (value: unknown): unknown =>
98 Array.isArray(value)
99 ? value.length === 1
100 ? stable(value[0])
101 : value.map(stable)
102 : value !== null && typeof value === "object"
103 ? Object.fromEntries(
104 Object.entries(value as Record<string, unknown>)
105 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
106 .map(([k, v]) => [k, stable(v)]),
107 )
108 : value;
109 if (typeof json === "string") {
110 try {
111 return JSON.stringify(stable(JSON.parse(json)));
112 } catch {
113 try {
114 return JSON.stringify(stable(JSON.parse(decodeURIComponent(json))));
115 } catch {
116 return json;
117 }
118 }
119 }
120 return JSON.stringify(stable(json));
121};
122
123export type PolicyName = string;
124export type PolicyArn = `arn:aws:iam::${AccountID}:policy/${string}`;
125
126export interface PolicyProps {
127 /**
128 * Name of the managed policy. If omitted, a deterministic name is generated.
129 */
130 policyName?: string;
131 /**
132 * Optional IAM path prefix for the policy.
133 * @default "/"
134 */
135 path?: string;
136 /**
137 * The JSON IAM policy document.
138 */
139 policyDocument: PolicyDocument;
140 /**
141 * Optional description for the policy.
142 */
143 description?: string;
144 /**
145 * User-defined tags to apply to the managed policy.
146 */
147 tags?: Record<string, string>;
148}
149
150export interface Policy extends Resource<
151 "AWS.IAM.Policy",
152 PolicyProps,
153 {
154 /** The ARN of the policy. */
155 policyArn: PolicyArn;
156 /** The name of the policy. */
157 policyName: PolicyName;
158 /** The stable unique ID of the policy. */
159 policyId: string | undefined;
160 /** The IAM path of the policy. */
161 path: string | undefined;
162 /** The version currently set as the default (e.g. `v2`). */
163 defaultVersionId: string | undefined;
164 /** How many IAM identities the policy is attached to. */
165 attachmentCount: number | undefined;
166 /** How many IAM identities use the policy as a permissions boundary. */
167 permissionsBoundaryUsageCount: number | undefined;
168 /** Whether the policy can be attached to IAM identities. */
169 isAttachable: boolean | undefined;
170 /** The description of the policy. */
171 description: string | undefined;
172 /** The policy document of the default version. */
173 policyDocument: PolicyDocument;
174 /** The tags applied to the policy. */
175 tags: Record<string, string>;
176 },
177 never,
178 Providers
179> {}
180
181/**
182 * A customer-managed IAM policy.
183 *
184 * `Policy` owns the lifecycle of the policy metadata and its default version,
185 * rotating versions on updates while keeping the current document attached to a
186 * stable policy ARN.
187 * ### Creating Policies
188 * **Example:** Managed Policy
189 * ```typescript
190 * const policy = yield* Policy("AppPolicy", {
191 * policyDocument: {
192 * Version: "2012-10-17",
193 * Statement: [{
194 * Effect: "Allow",
195 * Action: ["s3:GetObject"],
196 * Resource: ["arn:aws:s3:::my-bucket/*"],
197 * }],
198 * },
199 * });
200 * ```
201 *
202 * @resource
203 */
204export const Policy = Resource<Policy>("AWS.IAM.Policy");
205
206export const PolicyProvider = () =>
207 Provider.effect(
208 Policy,
209 Effect.gen(function* () {
210 const toPolicyName = (id: string, props: PolicyProps) =>
211 props.policyName
212 ? Effect.succeed(props.policyName)
213 : createPhysicalName({ id, maxLength: 128 });
214
215 const toPolicyArn = Effect.fn(function* (id: string, props: PolicyProps) {
216 const { accountId } = yield* AWSEnvironment.current;
217 const policyName = yield* toPolicyName(id, props);
218 return policyArnFromParts({
219 accountId,
220 path: props.path,
221 policyName,
222 }) as PolicyArn;
223 });
224
225 const readPolicy = Effect.fn(function* (policyArn: string) {
226 const response = yield* iam
227 .getPolicy({ PolicyArn: policyArn })
228 .pipe(
229 Effect.catchTag("NoSuchEntityException", () =>
230 Effect.succeed(undefined),
231 ),
232 );
233 return response?.Policy;
234 });
235
236 const readPolicyDocument = Effect.fn(function* ({
237 policyArn,
238 versionId,
239 }: {
240 policyArn: string;
241 versionId: string | undefined;
242 }) {
243 if (!versionId) {
244 return undefined;
245 }
246 const response = yield* iam
247 .getPolicyVersion({
248 PolicyArn: policyArn,
249 VersionId: versionId,
250 })
251 .pipe(
252 Effect.catchTag("NoSuchEntityException", () =>
253 Effect.succeed(undefined),
254 ),
255 );
256 return parsePolicyDocument(response?.PolicyVersion?.Document);
257 });
258
259 const prunePolicyVersions = Effect.fn(function* (policyArn: string) {
260 const versions = yield* iam.listPolicyVersions({
261 PolicyArn: policyArn,
262 });
263 if ((versions.Versions?.length ?? 0) < 5) {
264 return;
265 }
266 const removable = oldestNondefaultPolicyVersion(versions.Versions);
267 if (!removable?.VersionId) {
268 return;
269 }
270 yield* iam.deletePolicyVersion({
271 PolicyArn: policyArn,
272 VersionId: removable.VersionId,
273 });
274 });
275
276 return {
277 stables: ["policyArn", "policyName", "policyId"],
278 list: () =>
279 Effect.gen(function* () {
280 // IAM is global; enumerate only customer-managed ("Local")
281 // policies, paginating exhaustively.
282 const policies = yield* iam.listPolicies
283 .pages({ Scope: "Local" })
284 .pipe(
285 Stream.runCollect,
286 Effect.map((chunk) =>
287 Array.from(chunk).flatMap((page) => page.Policies ?? []),
288 ),
289 );
290 const rows = yield* Effect.forEach(
291 policies,
292 (policy) =>
293 Effect.gen(function* () {
294 if (!policy.Arn || !policy.PolicyName) {
295 return undefined;
296 }
297 const tags = yield* iam.listPolicyTags({
298 PolicyArn: policy.Arn,
299 });
300 const policyDocument = yield* readPolicyDocument({
301 policyArn: policy.Arn,
302 versionId: policy.DefaultVersionId,
303 });
304 if (!policyDocument) {
305 return undefined;
306 }
307 return {
308 policyArn: policy.Arn as PolicyArn,
309 policyName: policy.PolicyName,
310 policyId: policy.PolicyId,
311 path: policy.Path,
312 defaultVersionId: policy.DefaultVersionId,
313 attachmentCount: policy.AttachmentCount,
314 permissionsBoundaryUsageCount:
315 policy.PermissionsBoundaryUsageCount,
316 isAttachable: policy.IsAttachable,
317 description: policy.Description,
318 policyDocument,
319 tags: toTagRecord(tags.Tags),
320 };
321 }).pipe(
322 // A peer test may delete a policy between `listPolicies` and
323 // hydrating its tags/version — skip the vanished entry rather
324 // than failing the whole enumeration.
325 Effect.catchTag("NoSuchEntityException", () =>
326 Effect.succeed(undefined),
327 ),
328 ),
329 { concurrency: 10 },
330 );
331 return rows.filter(
332 (row): row is NonNullable<typeof row> => row !== undefined,
333 );
334 }),
335 diff: Effect.fn(function* ({ id, olds, news }) {
336 if (!isResolved(news)) return;
337 if (
338 (yield* toPolicyName(id, olds ?? ({} as PolicyProps))) !==
339 (yield* toPolicyName(id, news))
340 ) {
341 return { action: "replace" } as const;
342 }
343 if ((olds?.path ?? "/") !== (news.path ?? "/")) {
344 return { action: "replace" } as const;
345 }
346 if (
347 (olds?.description ?? undefined) !== (news.description ?? undefined)
348 ) {
349 return { action: "replace" } as const;
350 }
351 }),
352 read: Effect.fn(function* ({ id, olds, output }) {
353 const policyArn =
354 output?.policyArn ??
355 (yield* toPolicyArn(id, olds ?? ({} as PolicyProps)));
356 const policy = yield* readPolicy(policyArn);
357 if (!policy?.Arn || !policy.PolicyName) {
358 return undefined;
359 }
360
361 const tags = yield* iam.listPolicyTags({
362 PolicyArn: policy.Arn,
363 });
364 const policyDocument = yield* readPolicyDocument({
365 policyArn: policy.Arn,
366 versionId: policy.DefaultVersionId,
367 });
368
369 if (!policyDocument) {
370 return undefined;
371 }
372
373 return {
374 policyArn: policy.Arn as PolicyArn,
375 policyName: policy.PolicyName,
376 policyId: policy.PolicyId,
377 path: policy.Path,
378 defaultVersionId: policy.DefaultVersionId,
379 attachmentCount: policy.AttachmentCount,
380 permissionsBoundaryUsageCount: policy.PermissionsBoundaryUsageCount,
381 isAttachable: policy.IsAttachable,
382 description: policy.Description,
383 policyDocument,
384 tags: toTagRecord(tags.Tags),
385 };
386 }),
387 reconcile: Effect.fn(function* ({ id, news, output, session }) {
388 const policyName =
389 output?.policyName ?? (yield* toPolicyName(id, news));
390 const policyArn = output?.policyArn ?? (yield* toPolicyArn(id, news));
391 const desiredTags = {
392 ...(yield* createInternalTags(id)),
393 ...news.tags,
394 };
395
396 // Observe — fetch the policy metadata, current default-version
397 // document, and tags. Each piece feeds an independent sync step.
398 let observed = yield* readPolicy(policyArn);
399
400 // Ensure — create the managed policy when it is missing. We
401 // pass the desired document as the initial version so first
402 // create lands fully configured. On race, adopt by reading
403 // existing.
404 if (!observed?.Arn) {
405 const created = yield* iam
406 .createPolicy({
407 PolicyName: policyName,
408 Path: news.path,
409 PolicyDocument: stringifyPolicyDocument(news.policyDocument),
410 Description: news.description,
411 Tags: createTagsList(desiredTags),
412 })
413 .pipe(
414 Effect.catchTag("EntityAlreadyExistsException", () =>
415 Effect.gen(function* () {
416 const existing = yield* readPolicy(policyArn);
417 if (!existing?.Arn) {
418 return yield* Effect.fail(
419 new Error(
420 `Policy '${policyName}' already exists but could not be described`,
421 ),
422 );
423 }
424 const existingTags = yield* iam.listPolicyTags({
425 PolicyArn: existing.Arn,
426 });
427 if (!hasTags(desiredTags, existingTags.Tags)) {
428 return yield* Effect.fail(
429 new Error(
430 `Policy '${policyName}' already exists and is not managed by alchemy`,
431 ),
432 );
433 }
434 return { Policy: existing };
435 }),
436 ),
437 );
438 observed = created.Policy;
439 }
440
441 // Sync default-version document — IAM managed policies are
442 // immutable per version, so any document change requires
443 // creating a new default version (and pruning the oldest non-
444 // default if we're at the 5-version cap).
445 const observedDocument = yield* readPolicyDocument({
446 policyArn,
447 versionId: observed?.DefaultVersionId,
448 });
449 if (
450 JSON.stringify(observedDocument ?? null) !==
451 JSON.stringify(news.policyDocument)
452 ) {
453 yield* prunePolicyVersions(policyArn);
454 const createdVersion = yield* iam.createPolicyVersion({
455 PolicyArn: policyArn,
456 PolicyDocument: stringifyPolicyDocument(news.policyDocument),
457 SetAsDefault: true,
458 });
459 if (createdVersion.PolicyVersion?.VersionId) {
460 yield* iam.setDefaultPolicyVersion({
461 PolicyArn: policyArn,
462 VersionId: createdVersion.PolicyVersion.VersionId,
463 });
464 }
465 }
466
467 // Sync tags against the cloud's actual tags so adoption /
468 // out-of-band tag changes converge.
469 const observedTagsResp = yield* iam.listPolicyTags({
470 PolicyArn: policyArn,
471 });
472 const observedTags = toTagRecord(observedTagsResp.Tags);
473 const { removed, upsert } = diffTags(observedTags, desiredTags);
474 if (upsert.length > 0) {
475 yield* iam.tagPolicy({
476 PolicyArn: policyArn,
477 Tags: upsert,
478 });
479 }
480 if (removed.length > 0) {
481 yield* iam.untagPolicy({
482 PolicyArn: policyArn,
483 TagKeys: removed,
484 });
485 }
486
487 // Re-read for fresh metadata and the now-current document.
488 const fresh = yield* readPolicy(policyArn);
489 const freshDocument =
490 (yield* readPolicyDocument({
491 policyArn,
492 versionId: fresh?.DefaultVersionId,
493 })) ?? news.policyDocument;
494
495 yield* session.note(policyArn);
496 return {
497 policyArn,
498 policyName,
499 policyId: fresh?.PolicyId ?? observed?.PolicyId,
500 path: fresh?.Path ?? observed?.Path ?? news.path ?? "/",
501 defaultVersionId:
502 fresh?.DefaultVersionId ?? observed?.DefaultVersionId,
503 attachmentCount:
504 fresh?.AttachmentCount ?? observed?.AttachmentCount,
505 permissionsBoundaryUsageCount:
506 fresh?.PermissionsBoundaryUsageCount ??
507 observed?.PermissionsBoundaryUsageCount,
508 isAttachable: fresh?.IsAttachable ?? observed?.IsAttachable,
509 description:
510 fresh?.Description ?? observed?.Description ?? news.description,
511 policyDocument: freshDocument,
512 tags: desiredTags,
513 };
514 }),
515 delete: Effect.fn(function* ({ output }) {
516 const versions = yield* iam
517 .listPolicyVersions({
518 PolicyArn: output.policyArn,
519 })
520 .pipe(
521 Effect.catchTag("NoSuchEntityException", () =>
522 Effect.succeed(undefined),
523 ),
524 );
525 for (const version of versions?.Versions ?? []) {
526 if (!version.IsDefaultVersion && version.VersionId) {
527 yield* iam
528 .deletePolicyVersion({
529 PolicyArn: output.policyArn,
530 VersionId: version.VersionId,
531 })
532 .pipe(
533 Effect.catchTag("NoSuchEntityException", () => Effect.void),
534 );
535 }
536 }
537 yield* iam
538 .deletePolicy({
539 PolicyArn: output.policyArn,
540 })
541 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
542 }),
543 };
544 }),
545 );