1import * as organizations from "@distilled.cloud/aws/organizations";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7import { collectPages, retryOrganizations } from "./common.ts";
8
9export interface PolicyAttachmentProps {
10 /**
11 * Policy to attach.
12 */
13 policyId: string;
14 /**
15 * Target root, OU, or account ID.
16 */
17 targetId: string;
18}
19
20export interface PolicyAttachment extends Resource<
21 "AWS.Organizations.PolicyAttachment",
22 PolicyAttachmentProps,
23 {
24 /**
25 * ID of the attached policy.
26 */
27 policyId: string;
28 /**
29 * ID of the root, OU, or account the policy is attached to.
30 */
31 targetId: string;
32 /**
33 * ARN of the attachment target.
34 */
35 targetArn: string | undefined;
36 /**
37 * Friendly name of the attachment target.
38 */
39 targetName: string | undefined;
40 /**
41 * Kind of the attachment target (`ROOT`, `ORGANIZATIONAL_UNIT`, or
42 * `ACCOUNT`).
43 */
44 targetType: organizations.TargetType | undefined;
45 },
46 never,
47 Providers
48> {}
49
50/**
51 * Attaches an Organizations {@link Policy} to a root, OU, or account.
52 *
53 * Existence-only resource: changing either `policyId` or `targetId` replaces
54 * the attachment. The policy's type must already be enabled on the root (see
55 * {@link RootPolicyType}).
56 * ### Attaching Policies
57 * **Example:** Attach an SCP to an Organizational Unit
58 * ```typescript
59 * const workloads = yield* OrganizationalUnit("Workloads", {
60 * parentId: root.rootId,
61 * name: "workloads",
62 * });
63 *
64 * const denyRegions = yield* Policy("DenyOtherRegions", {
65 * type: "SERVICE_CONTROL_POLICY",
66 * document: {
67 * Version: "2012-10-17",
68 * Statement: [
69 * {
70 * Effect: "Deny",
71 * NotAction: ["iam:*", "organizations:*", "sts:*"],
72 * Resource: "*",
73 * Condition: {
74 * StringNotEquals: { "aws:RequestedRegion": ["us-east-1", "us-west-2"] },
75 * },
76 * },
77 * ],
78 * },
79 * });
80 *
81 * yield* PolicyAttachment("DenyRegionsOnWorkloads", {
82 * policyId: denyRegions.policyId,
83 * targetId: workloads.ouId,
84 * });
85 * ```
86 *
87 * **Example:** Attach a Policy to a Member Account
88 * ```typescript
89 * yield* PolicyAttachment("DenyRegionsOnDev", {
90 * policyId: denyRegions.policyId,
91 * targetId: devAccount.accountId,
92 * });
93 * ```
94 *
95 * @resource
96 */
97export const PolicyAttachment = Resource<PolicyAttachment>(
98 "AWS.Organizations.PolicyAttachment",
99);
100
101export const PolicyAttachmentProvider = () =>
102 Provider.effect(
103 PolicyAttachment,
104 Effect.gen(function* () {
105 return {
106 stables: ["policyId", "targetId"],
107 diff: Effect.fn(function* ({ olds, news }) {
108 if (!isResolved(news)) return;
109 if (
110 olds?.policyId !== news.policyId ||
111 olds?.targetId !== news.targetId
112 ) {
113 return { action: "replace" } as const;
114 }
115 }),
116 read: Effect.fn(function* ({ olds, output }) {
117 const policyId = output?.policyId ?? olds?.policyId;
118 const targetId = output?.targetId ?? olds?.targetId;
119 if (policyId === undefined || targetId === undefined) {
120 // A `creating` row persisted before upstream Outputs resolved
121 // can't round-trip Output-valued ids — they deserialize as
122 // `undefined`. Report "not found" so the engine re-drives the
123 // create (reconcile observes before attaching).
124 return undefined;
125 }
126 return yield* readAttachment({ policyId, targetId });
127 }),
128 // Enumerate every (policy, target) attachment. There is no direct
129 // "list attachments" API, so we fan out: for each policy type, list the
130 // policies of that type, then list each policy's targets, emitting one
131 // Attributes per (policy, target) pair — the exact shape `read` returns.
132 // listPolicies/listTargetsForPolicy may only be called from the org
133 // management account or a delegated administrator; a member account or a
134 // standalone account that isn't part of an org rejects with the typed
135 // AccessDeniedException / AWSOrganizationsNotInUseException, which we
136 // degrade to [].
137 list: () =>
138 Effect.gen(function* () {
139 const perType = yield* Effect.forEach(
140 POLICY_TYPES,
141 (Filter) =>
142 Effect.gen(function* () {
143 const policies = yield* retryOrganizations(
144 collectPages(
145 (NextToken) =>
146 organizations.listPolicies({ Filter, NextToken }),
147 (page) => page.Policies,
148 ),
149 );
150 const policyIds = policies
151 .map((policy) => policy.Id)
152 .filter((id): id is string => id != null);
153 const perPolicy = yield* Effect.forEach(
154 policyIds,
155 (policyId) => listAttachmentsForPolicy(policyId),
156 { concurrency: 10 },
157 );
158 return perPolicy.flat();
159 }),
160 { concurrency: 10 },
161 );
162 return perType.flat();
163 }).pipe(
164 Effect.catchTags({
165 AccessDeniedException: () =>
166 Effect.succeed([] as PolicyAttachment["Attributes"][]),
167 AWSOrganizationsNotInUseException: () =>
168 Effect.succeed([] as PolicyAttachment["Attributes"][]),
169 }),
170 ),
171 reconcile: Effect.fn(function* ({ news, session }) {
172 // Observe — list current attachments to see whether ours is present.
173 // The attachment is identity-only; both `policyId` and `targetId`
174 // are stable, so `diff` handles any change as a replacement.
175 let state = yield* readAttachment(news);
176
177 // Ensure — attach if missing. `DuplicatePolicyAttachmentException`
178 // is treated as success (e.g. a peer reconciler attached
179 // concurrently, or our observation lost a race).
180 if (!state) {
181 yield* retryOrganizations(
182 organizations
183 .attachPolicy({
184 PolicyId: news.policyId,
185 TargetId: news.targetId,
186 })
187 .pipe(
188 Effect.catchTag(
189 "DuplicatePolicyAttachmentException",
190 () => Effect.void,
191 ),
192 ),
193 );
194 state = yield* readAttachment(news);
195 if (!state) {
196 return yield* Effect.fail(
197 new Error(
198 `policy attachment '${news.policyId}' -> '${news.targetId}' not found after create`,
199 ),
200 );
201 }
202 }
203
204 yield* session.note(`${state.policyId}:${state.targetId}`);
205 return state;
206 }),
207 delete: Effect.fn(function* ({ output }) {
208 yield* retryOrganizations(
209 organizations
210 .detachPolicy({
211 PolicyId: output.policyId,
212 TargetId: output.targetId,
213 })
214 .pipe(
215 Effect.catchTags({
216 PolicyNotAttachedException: () => Effect.void,
217 PolicyNotFoundException: () => Effect.void,
218 TargetNotFoundException: () => Effect.void,
219 }),
220 ),
221 );
222 }),
223 };
224 }),
225 );
226
227// All policy types that can be attached to a root, OU, or account. `list()`
228// fans out over each of these because listPolicies requires an explicit
229// `Filter` and only returns policies of that single type.
230const POLICY_TYPES = [
231 "SERVICE_CONTROL_POLICY",
232 "RESOURCE_CONTROL_POLICY",
233 "TAG_POLICY",
234 "BACKUP_POLICY",
235 "AISERVICES_OPT_OUT_POLICY",
236 "CHATBOT_POLICY",
237 "DECLARATIVE_POLICY_EC2",
238 "SECURITYHUB_POLICY",
239 "INSPECTOR_POLICY",
240 "UPGRADE_ROLLOUT_POLICY",
241 "BEDROCK_POLICY",
242 "S3_POLICY",
243 "NETWORK_SECURITY_DIRECTOR_POLICY",
244] as const satisfies readonly organizations.PolicyType[];
245
246const listAttachmentsForPolicy = (policyId: string) =>
247 retryOrganizations(
248 collectPages(
249 (NextToken) =>
250 organizations.listTargetsForPolicy({ PolicyId: policyId, NextToken }),
251 (page) => page.Targets,
252 ),
253 ).pipe(
254 Effect.map((targets) =>
255 targets
256 .filter(
257 (
258 target,
259 ): target is organizations.PolicyTargetSummary & {
260 TargetId: string;
261 } => target.TargetId != null,
262 )
263 .map(
264 (target) =>
265 ({
266 policyId,
267 targetId: target.TargetId,
268 targetArn: target.Arn,
269 targetName: target.Name,
270 targetType: target.Type,
271 }) satisfies PolicyAttachment["Attributes"],
272 ),
273 ),
274 // The policy was deleted between enumeration and target read — skip it.
275 Effect.catchTag("PolicyNotFoundException", () =>
276 Effect.succeed([] as PolicyAttachment["Attributes"][]),
277 ),
278 );
279
280const readAttachment = Effect.fn(function* ({
281 policyId,
282 targetId,
283}: PolicyAttachmentProps) {
284 const targets = yield* retryOrganizations(
285 collectPages(
286 (NextToken) =>
287 organizations.listTargetsForPolicy({ PolicyId: policyId, NextToken }),
288 (page) => page.Targets,
289 ),
290 );
291
292 const target = targets.find((candidate) => candidate.TargetId === targetId);
293 return target
294 ? ({
295 policyId,
296 targetId,
297 targetArn: target.Arn,
298 targetName: target.Name,
299 targetType: target.Type,
300 } satisfies PolicyAttachment["Attributes"])
301 : undefined;
302});