1import * as organizations from "@distilled.cloud/aws/organizations";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import type { ServiceControlPolicyDocument } from "../IAM/Policy.ts";
11import {
12 normalizePolicyDocument,
13 stringifyPolicyDocument,
14} from "../IAM/Policy.ts";
15import {
16 collectPages,
17 createName,
18 readResourceTags,
19 retryOrganizations,
20 updateResourceTags,
21} from "./common.ts";
22
23export type PolicyId = string;
24export type PolicyArn = string;
25
26export interface PolicyProps {
27 /**
28 * Policy name. If omitted, Alchemy generates one.
29 */
30 name?: string;
31 /**
32 * Policy description.
33 * @default ""
34 */
35 description?: string;
36 /**
37 * Organizations policy type.
38 */
39 type: organizations.PolicyType;
40 /**
41 * Policy content. For `SERVICE_CONTROL_POLICY` / `RESOURCE_CONTROL_POLICY`
42 * pass a typed {@link ServiceControlPolicyDocument} (the SCP-legal IAM
43 * dialect — no `Principal`/`NotPrincipal`). Other policy types (tag,
44 * backup, AI-services opt-out, ...) use their own JSON grammars — pass
45 * them as a raw JSON `string`. The string form also serves as the
46 * escape hatch for adopted or hand-authored documents.
47 */
48 document: ServiceControlPolicyDocument | string;
49 /**
50 * Optional tags applied to the policy.
51 */
52 tags?: Record<string, string>;
53}
54
55export interface Policy extends Resource<
56 "AWS.Organizations.Policy",
57 PolicyProps,
58 {
59 /**
60 * ID of the policy (e.g. `p-examplepolicyid`).
61 */
62 policyId: PolicyId;
63 /**
64 * ARN of the policy.
65 */
66 policyArn: PolicyArn;
67 /**
68 * Policy name.
69 */
70 name: string;
71 /**
72 * Policy description.
73 */
74 description: string | undefined;
75 /**
76 * Organizations policy type.
77 */
78 type: organizations.PolicyType | undefined;
79 /**
80 * Whether the policy is an AWS-managed policy (e.g. `FullAWSAccess`).
81 */
82 awsManaged: boolean | undefined;
83 /**
84 * Parsed policy document as currently stored by AWS Organizations.
85 */
86 document: ServiceControlPolicyDocument;
87 /**
88 * Tags on the policy.
89 */
90 tags: Record<string, string>;
91 },
92 never,
93 Providers
94> {}
95
96/**
97 * An AWS Organizations policy such as an SCP or tag policy.
98 *
99 * Attach it to a root, OU, or account with {@link PolicyAttachment}. Changing
100 * `type` or `name` replaces the policy; document and description changes
101 * update in place.
102 * ### Creating Policies
103 * **Example:** Service Control Policy (Typed Document)
104 * ```typescript
105 * const denyLeaveOrg = yield* Policy("DenyLeaveOrg", {
106 * type: "SERVICE_CONTROL_POLICY",
107 * description: "Prevent member accounts from leaving the organization",
108 * document: {
109 * Version: "2012-10-17",
110 * Statement: [
111 * {
112 * Effect: "Deny",
113 * Action: ["organizations:LeaveOrganization"],
114 * Resource: "*",
115 * },
116 * ],
117 * },
118 * });
119 * ```
120 *
121 * **Example:** Tag Policy (Raw JSON)
122 * ```typescript
123 * const tagPolicy = yield* Policy("RequireEnvTag", {
124 * type: "TAG_POLICY",
125 * document: JSON.stringify({
126 * tags: {
127 * environment: {
128 * tag_key: { "@@assign": "environment" },
129 * tag_value: { "@@assign": ["dev", "staging", "prod"] },
130 * },
131 * },
132 * }),
133 * });
134 * ```
135 *
136 * ### Attaching Policies
137 * **Example:** Attach an SCP to the Organization Root
138 * ```typescript
139 * const root = yield* Root("Root", {});
140 *
141 * const scpEnabled = yield* RootPolicyType("ScpEnabled", {
142 * rootId: root.rootId,
143 * policyType: "SERVICE_CONTROL_POLICY",
144 * });
145 *
146 * yield* PolicyAttachment("DenyLeaveOrgOnRoot", {
147 * policyId: denyLeaveOrg.policyId,
148 * targetId: scpEnabled.rootId,
149 * });
150 * ```
151 *
152 * @resource
153 */
154export const Policy = Resource<Policy>("AWS.Organizations.Policy");
155
156export const PolicyProvider = () =>
157 Provider.effect(
158 Policy,
159 Effect.gen(function* () {
160 return {
161 stables: ["policyId", "policyArn"],
162 diff: Effect.fn(function* ({ id, olds, news }) {
163 if (!isResolved(news)) return;
164 if (olds?.type !== news.type) {
165 return { action: "replace" } as const;
166 }
167
168 const oldName = yield* toName(id, olds ?? {});
169 const newName = yield* toName(id, news);
170 if (oldName !== newName) {
171 return { action: "replace" } as const;
172 }
173 }),
174 read: Effect.fn(function* ({ id, olds, output }) {
175 const state = output?.policyId
176 ? yield* readPolicyById(output.policyId)
177 : olds
178 ? yield* readPolicyByName({
179 type: olds.type,
180 name: yield* toName(id, olds),
181 })
182 : undefined;
183 if (!state) return undefined;
184 return (yield* hasAlchemyTags(id, state.tags))
185 ? state
186 : Unowned(state);
187 }),
188 // `listPolicies` REQUIRES a `Filter` (one policy type per call), so we
189 // fan out across every policy-type filter and hydrate each summary via
190 // `describePolicy` into the exact `read` shape. Degrades to `[]` when
191 // the account isn't an org management/delegated-admin account
192 // (`AWSOrganizationsNotInUseException`/`AccessDeniedException`) and skips
193 // disabled policy types per-filter.
194 list: () =>
195 Effect.gen(function* () {
196 const summaries = yield* Effect.forEach(
197 POLICY_TYPE_FILTERS,
198 (type) =>
199 retryOrganizations(
200 collectPages(
201 (NextToken) =>
202 organizations.listPolicies({ Filter: type, NextToken }),
203 (page) => page.Policies,
204 ),
205 ).pipe(
206 // Not an org management/delegated account → nothing to list.
207 Effect.catchTag(
208 [
209 "AWSOrganizationsNotInUseException",
210 "AccessDeniedException",
211 ],
212 () => Effect.succeed([] as organizations.PolicySummary[]),
213 ),
214 ),
215 { concurrency: 10 },
216 );
217
218 const ids = summaries
219 .flat()
220 .map((summary) => summary.Id)
221 .filter((policyId): policyId is string => policyId != null);
222
223 const hydrated = yield* Effect.forEach(
224 ids,
225 (policyId) => readPolicyById(policyId),
226 { concurrency: 10 },
227 );
228
229 return hydrated.filter(
230 (policy): policy is Policy["Attributes"] => policy !== undefined,
231 );
232 }),
233 reconcile: Effect.fn(function* ({ id, news, output, session }) {
234 const name = yield* toName(id, news);
235 const desiredDescription = news.description ?? "";
236 const desiredContent =
237 typeof news.document === "string"
238 ? news.document
239 : stringifyPolicyDocument(news.document);
240
241 // Observe — locate the policy by ID if known, else by type+name.
242 // Both `name` (after generation) and `type` are stable, so `diff`
243 // handles renames as a replacement; here we just look up.
244 let state = output?.policyId
245 ? yield* readPolicyById(output.policyId)
246 : yield* readPolicyByName({
247 type: news.type,
248 name,
249 });
250
251 // Ensure — create the policy if missing. Tolerate
252 // `DuplicatePolicyException` as adoption (a peer reconciler beat
253 // us, or our observation lost a race).
254 if (!state) {
255 yield* retryOrganizations(
256 organizations
257 .createPolicy({
258 Name: name,
259 Description: desiredDescription,
260 Type: news.type,
261 Content: desiredContent,
262 })
263 .pipe(
264 Effect.catchTag(
265 "DuplicatePolicyException",
266 () => Effect.void,
267 ),
268 ),
269 );
270 state = yield* readPolicyByName({
271 type: news.type,
272 name,
273 });
274 if (!state) {
275 return yield* Effect.fail(
276 new Error(`policy '${name}' not found after create`),
277 );
278 }
279 }
280
281 // Sync description + content — diff observed cloud state against
282 // desired. Documents are compared in canonical form
283 // (`normalizePolicyDocument`: sorted keys, no whitespace) so an
284 // unchanged re-deploy — regardless of key order or string-vs-typed
285 // authoring — skips the `updatePolicy` call entirely.
286 // `updatePolicy` requires `Name`; we keep the existing policy name
287 // (rename triggers replacement at the diff level).
288 const observedDescription = state.description ?? "";
289 if (
290 observedDescription !== desiredDescription ||
291 normalizePolicyDocument(state.document) !==
292 normalizePolicyDocument(desiredContent)
293 ) {
294 yield* retryOrganizations(
295 organizations.updatePolicy({
296 PolicyId: state.policyId,
297 Name: state.name,
298 Description: desiredDescription,
299 Content: desiredContent,
300 }),
301 );
302 }
303
304 // Sync tags — diff observed cloud tags against desired. Using
305 // `state.tags` (fetched fresh) keeps the reconciler convergent on
306 // adoption and drift.
307 const tags = yield* updateResourceTags({
308 id,
309 resourceId: state.policyId,
310 olds: state.tags,
311 news: news.tags,
312 });
313
314 const updated = yield* readPolicyById(state.policyId);
315 if (!updated) {
316 return yield* Effect.fail(
317 new Error(`policy '${state.policyId}' not found after reconcile`),
318 );
319 }
320
321 yield* session.note(updated.policyArn);
322 return {
323 ...updated,
324 tags,
325 };
326 }),
327 delete: Effect.fn(function* ({ output }) {
328 yield* retryOrganizations(
329 organizations.deletePolicy({ PolicyId: output.policyId }).pipe(
330 // Detaching a policy (the attachment's delete) propagates
331 // asynchronously — deletePolicy issued right after the detach
332 // can still observe the policy as attached. Retry through the
333 // typed dependency-violation tag, bounded.
334 Effect.retry({
335 while: (error) => error._tag === "PolicyInUseException",
336 schedule: Schedule.spaced("3 seconds"),
337 times: 8,
338 }),
339 Effect.catchTag("PolicyNotFoundException", () => Effect.void),
340 ),
341 );
342 }),
343 };
344 }),
345 );
346
347// The documented `ListPolicies` `Filter` values. `listPolicies` requires a
348// single policy type per call, so `list()` fans out across all of them.
349const POLICY_TYPE_FILTERS = [
350 "SERVICE_CONTROL_POLICY",
351 "RESOURCE_CONTROL_POLICY",
352 "DECLARATIVE_POLICY_EC2",
353 "BACKUP_POLICY",
354 "TAG_POLICY",
355 "CHATBOT_POLICY",
356 "AISERVICES_OPT_OUT_POLICY",
357 "SECURITYHUB_POLICY",
358] as const satisfies readonly organizations.PolicyType[];
359
360const toName = (id: string, props: { name?: string } = {}) =>
361 createName(id, props.name, 128);
362
363const readPolicyById = Effect.fn(function* (policyId: string) {
364 const described = yield* retryOrganizations(
365 organizations.describePolicy({ PolicyId: policyId }).pipe(
366 Effect.map((response) => response.Policy),
367 Effect.catchTag("PolicyNotFoundException", () =>
368 Effect.succeed(undefined),
369 ),
370 ),
371 );
372
373 const summary = described?.PolicySummary;
374 if (!summary?.Id || !summary.Arn || !summary.Name) {
375 return undefined;
376 }
377
378 const tags = yield* readResourceTags(summary.Id).pipe(
379 Effect.catchTag("TargetNotFoundException", () => Effect.succeed({})),
380 );
381
382 return {
383 policyId: summary.Id,
384 policyArn: summary.Arn,
385 name: summary.Name,
386 description: summary.Description,
387 type: summary.Type,
388 awsManaged: summary.AwsManaged,
389 document: JSON.parse(
390 described?.Content ?? "{}",
391 ) as ServiceControlPolicyDocument,
392 tags,
393 } satisfies Policy["Attributes"];
394});
395
396const readPolicyByName = Effect.fn(function* ({
397 type,
398 name,
399}: {
400 type: organizations.PolicyType;
401 name: string;
402}) {
403 const policies = yield* retryOrganizations(
404 collectPages(
405 (NextToken) => organizations.listPolicies({ Filter: type, NextToken }),
406 (page) => page.Policies,
407 ),
408 );
409
410 const match = policies.find((policy) => policy.Name === name);
411 return match?.Id ? yield* readPolicyById(match.Id) : undefined;
412});