| 1 | import * as organizations from "@distilled.cloud/aws/organizations"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Provider from "../../Provider.ts"; |
| 4 | import { Resource } from "../../Resource.ts"; |
| 5 | import type { Providers } from "../Providers.ts"; |
| 6 | import type { PolicyDocument } from "../IAM/Policy.ts"; |
| 7 | import { retryOrganizations } from "./common.ts"; |
| 8 | |
| 9 | export interface OrganizationResourcePolicyProps { |
| 10 | /** |
| 11 | * Typed resource policy document for the organization. |
| 12 | */ |
| 13 | document: PolicyDocument; |
| 14 | } |
| 15 | |
| 16 | export interface OrganizationResourcePolicy extends Resource< |
| 17 | "AWS.Organizations.OrganizationResourcePolicy", |
| 18 | OrganizationResourcePolicyProps, |
| 19 | { |
| 20 | /** |
| 21 | * ID of the resource policy. |
| 22 | */ |
| 23 | resourcePolicyId: string; |
| 24 | /** |
| 25 | * ARN of the resource policy. |
| 26 | */ |
| 27 | resourcePolicyArn: string; |
| 28 | /** |
| 29 | * Parsed resource policy document as stored by AWS Organizations. |
| 30 | */ |
| 31 | document: PolicyDocument; |
| 32 | }, |
| 33 | never, |
| 34 | Providers |
| 35 | > {} |
| 36 | |
| 37 | /** |
| 38 | * The singleton AWS Organizations resource policy — an org-level |
| 39 | * resource-based policy that grants other principals (typically delegated |
| 40 | * administrator accounts) permission to call Organizations APIs. |
| 41 | * |
| 42 | * There is at most one per organization; Alchemy adopts and reconciles the |
| 43 | * existing policy if one is already in place. |
| 44 | * ### Setting the Resource Policy |
| 45 | * **Example:** Allow a Member Account to Describe the Organization |
| 46 | * ```typescript |
| 47 | * const security = yield* Account("Security", { |
| 48 | * name: "security", |
| 49 | * email: "aws-security@example.com", |
| 50 | * parentId: root.rootId, |
| 51 | * }); |
| 52 | * |
| 53 | * yield* OrganizationResourcePolicy("OrgResourcePolicy", { |
| 54 | * document: { |
| 55 | * Version: "2012-10-17", |
| 56 | * Statement: [ |
| 57 | * { |
| 58 | * Effect: "Allow", |
| 59 | * Principal: { AWS: security.accountId }, |
| 60 | * Action: [ |
| 61 | * "organizations:DescribeOrganization", |
| 62 | * "organizations:ListAccounts", |
| 63 | * ], |
| 64 | * Resource: "*", |
| 65 | * }, |
| 66 | * ], |
| 67 | * }, |
| 68 | * }); |
| 69 | * ``` |
| 70 | * |
| 71 | * @resource |
| 72 | */ |
| 73 | export const OrganizationResourcePolicy = Resource<OrganizationResourcePolicy>( |
| 74 | "AWS.Organizations.OrganizationResourcePolicy", |
| 75 | ); |
| 76 | |
| 77 | const readResourcePolicy = () => |
| 78 | retryOrganizations( |
| 79 | organizations.describeResourcePolicy({}).pipe( |
| 80 | Effect.map((response) => response.ResourcePolicy), |
| 81 | Effect.catchTag("ResourcePolicyNotFoundException", () => |
| 82 | Effect.succeed(undefined), |
| 83 | ), |
| 84 | Effect.map((policy) => { |
| 85 | const summary = policy?.ResourcePolicySummary; |
| 86 | return summary?.Id && summary.Arn |
| 87 | ? ({ |
| 88 | resourcePolicyId: summary.Id, |
| 89 | resourcePolicyArn: summary.Arn, |
| 90 | document: JSON.parse(policy?.Content ?? "{}") as PolicyDocument, |
| 91 | } satisfies OrganizationResourcePolicy["Attributes"]) |
| 92 | : undefined; |
| 93 | }), |
| 94 | ), |
| 95 | ); |
| 96 | |
| 97 | export const OrganizationResourcePolicyProvider = () => |
| 98 | Provider.effect( |
| 99 | OrganizationResourcePolicy, |
| 100 | Effect.gen(function* () { |
| 101 | return { |
| 102 | stables: ["resourcePolicyId", "resourcePolicyArn"], |
| 103 | diff: Effect.fn(function* () {}), |
| 104 | read: Effect.fn(function* () { |
| 105 | return yield* readResourcePolicy(); |
| 106 | }), |
| 107 | // Org singleton: there is no list API. Describe the single resource |
| 108 | // policy and return a one-element array if it exists, else []. A |
| 109 | // missing policy or an account that isn't an org both yield []. |
| 110 | list: () => |
| 111 | readResourcePolicy().pipe( |
| 112 | Effect.map((policy) => (policy ? [policy] : [])), |
| 113 | Effect.catchTag("AWSOrganizationsNotInUseException", () => |
| 114 | Effect.succeed([] as OrganizationResourcePolicy["Attributes"][]), |
| 115 | ), |
| 116 | ), |
| 117 | reconcile: Effect.fn(function* ({ news, session }) { |
| 118 | const desiredContent = JSON.stringify(news.document); |
| 119 | |
| 120 | // Observe — fetch the live resource policy (or absence). |
| 121 | let state = yield* readResourcePolicy(); |
| 122 | |
| 123 | // Sync — `putResourcePolicy` is a single upsert that handles both |
| 124 | // first-create and update. We diff observed content against |
| 125 | // desired so the call only fires when there's drift. Reading by |
| 126 | // ID isn't possible (resource is a singleton with a server-issued |
| 127 | // ID), so we compare the JSON-stringified document. |
| 128 | const observedContent = state |
| 129 | ? JSON.stringify(state.document) |
| 130 | : undefined; |
| 131 | |
| 132 | if (observedContent !== desiredContent) { |
| 133 | yield* retryOrganizations( |
| 134 | organizations.putResourcePolicy({ |
| 135 | Content: desiredContent, |
| 136 | }), |
| 137 | ); |
| 138 | state = yield* readResourcePolicy(); |
| 139 | } |
| 140 | |
| 141 | if (!state) { |
| 142 | return yield* Effect.fail( |
| 143 | new Error( |
| 144 | "organization resource policy not found after reconcile", |
| 145 | ), |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | yield* session.note(state.resourcePolicyArn); |
| 150 | return state; |
| 151 | }), |
| 152 | delete: Effect.fn(function* () { |
| 153 | yield* retryOrganizations( |
| 154 | organizations |
| 155 | .deleteResourcePolicy({}) |
| 156 | .pipe( |
| 157 | Effect.catchTag( |
| 158 | "ResourcePolicyNotFoundException", |
| 159 | () => Effect.void, |
| 160 | ), |
| 161 | ), |
| 162 | ); |
| 163 | }), |
| 164 | }; |
| 165 | }), |
| 166 | ); |