1import * as organizations from "@distilled.cloud/aws/organizations";
2import * as Effect from "effect/Effect";
3import * as Provider from "../../Provider.ts";
4import { Resource } from "../../Resource.ts";
5import type { Providers } from "../Providers.ts";
6import type { PolicyDocument } from "../IAM/Policy.ts";
7import { retryOrganizations } from "./common.ts";
8
9export interface OrganizationResourcePolicyProps {
10 /**
11 * Typed resource policy document for the organization.
12 */
13 document: PolicyDocument;
14}
15
16export 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 */
73export const OrganizationResourcePolicy = Resource<OrganizationResourcePolicy>(
74 "AWS.Organizations.OrganizationResourcePolicy",
75);
76
77const 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
97export 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 );