| 1 | import * as organizations from "@distilled.cloud/aws/organizations"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { isResolved } from "../../Diff.ts"; |
| 4 | import * as Provider from "../../Provider.ts"; |
| 5 | import { Resource } from "../../Resource.ts"; |
| 6 | import type { Providers } from "../Providers.ts"; |
| 7 | import { collectPages, retryOrganizations } from "./common.ts"; |
| 8 | |
| 9 | export 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 | |
| 20 | export 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 | */ |
| 97 | export const PolicyAttachment = Resource<PolicyAttachment>( |
| 98 | "AWS.Organizations.PolicyAttachment", |
| 99 | ); |
| 100 | |
| 101 | export 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. |
| 230 | const 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 | |
| 246 | const 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 | |
| 280 | const 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 | }); |