1import * as config from "@distilled.cloud/aws/config-service";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 createTagsList,
11 diffTags,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15
16export interface AggregationAuthorizationProps {
17 /**
18 * The 12-digit account ID of the aggregator account that is authorized to
19 * collect AWS Config data from this account. Changing it replaces the
20 * authorization.
21 */
22 authorizedAccountId: string;
23 /**
24 * The region of the aggregator account that is authorized to collect AWS
25 * Config data from this account. Changing it replaces the authorization.
26 */
27 authorizedAwsRegion: string;
28 /**
29 * Tags to apply to the authorization. Merged with internal Alchemy tags.
30 */
31 tags?: Record<string, string>;
32}
33
34export interface AggregationAuthorization extends Resource<
35 "AWS.Config.AggregationAuthorization",
36 AggregationAuthorizationProps,
37 {
38 /** ARN of the aggregation authorization. */
39 aggregationAuthorizationArn: string;
40 /** The aggregator account authorized to collect data. */
41 authorizedAccountId: string;
42 /** The aggregator region authorized to collect data. */
43 authorizedAwsRegion: string;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * An AWS Config aggregation authorization that grants an aggregator account
51 * in a specific region permission to collect AWS Config configuration and
52 * compliance data from this account.
53 *
54 * The authorization's identity is the `(account, region)` pair — changing
55 * either replaces it.
56 * ### Authorizing an Aggregator
57 * **Example:** Authorize an aggregator account
58 * ```typescript
59 * import * as Config from "alchemy/AWS/Config";
60 *
61 * const authorization = yield* Config.AggregationAuthorization(
62 * "OrgAggregator",
63 * {
64 * authorizedAccountId: "123456789012",
65 * authorizedAwsRegion: "us-east-1",
66 * },
67 * );
68 * ```
69 *
70 * @resource
71 */
72export const AggregationAuthorization = Resource<AggregationAuthorization>(
73 "AWS.Config.AggregationAuthorization",
74);
75
76export const AggregationAuthorizationProvider = () =>
77 Provider.effect(
78 AggregationAuthorization,
79 Effect.gen(function* () {
80 // There is no Get API — enumerate and match on the (account, region)
81 // identity pair.
82 const observeAuthorization = Effect.fn(function* (
83 accountId: string,
84 region: string,
85 ) {
86 return yield* config.describeAggregationAuthorizations.items({}).pipe(
87 Stream.filter(
88 (auth) =>
89 auth.AuthorizedAccountId === accountId &&
90 auth.AuthorizedAwsRegion === region,
91 ),
92 Stream.runHead,
93 Effect.map((head) => (head._tag === "Some" ? head.value : undefined)),
94 );
95 });
96
97 const observedTags = (arn: string) =>
98 config.listTagsForResource({ ResourceArn: arn }).pipe(
99 Effect.map((r) =>
100 Object.fromEntries(
101 (r.Tags ?? []).flatMap((t) =>
102 t.Key !== undefined ? [[t.Key, t.Value ?? ""]] : [],
103 ),
104 ),
105 ),
106 Effect.catchTag("ResourceNotFoundException", () =>
107 Effect.succeed({} as Record<string, string>),
108 ),
109 );
110
111 const toAttrs = (auth: config.AggregationAuthorization) => ({
112 aggregationAuthorizationArn: auth.AggregationAuthorizationArn!,
113 authorizedAccountId: auth.AuthorizedAccountId!,
114 authorizedAwsRegion: auth.AuthorizedAwsRegion!,
115 });
116
117 return AggregationAuthorization.Provider.of({
118 stables: [
119 "aggregationAuthorizationArn",
120 "authorizedAccountId",
121 "authorizedAwsRegion",
122 ],
123 list: () =>
124 config.describeAggregationAuthorizations.items({}).pipe(
125 Stream.runCollect,
126 Effect.map((auths) =>
127 Array.from(auths).flatMap((auth) =>
128 auth.AggregationAuthorizationArn &&
129 auth.AuthorizedAccountId &&
130 auth.AuthorizedAwsRegion
131 ? [toAttrs(auth)]
132 : [],
133 ),
134 ),
135 ),
136 read: Effect.fn(function* ({ id, olds, output }) {
137 const accountId =
138 output?.authorizedAccountId ?? olds?.authorizedAccountId;
139 const region =
140 output?.authorizedAwsRegion ?? olds?.authorizedAwsRegion;
141 if (accountId === undefined || region === undefined) {
142 return undefined;
143 }
144 const auth = yield* observeAuthorization(accountId, region);
145 if (auth?.AggregationAuthorizationArn === undefined) {
146 return undefined;
147 }
148 const attrs = toAttrs(auth);
149 const tags = yield* observedTags(auth.AggregationAuthorizationArn);
150 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
151 }),
152 // The (account, region) pair IS the resource identity — any change
153 // replaces it. Tags remain updatable in place.
154 diff: Effect.fn(function* ({ news, olds }) {
155 if (!isResolved(news)) return undefined;
156 if (
157 olds.authorizedAccountId !== news.authorizedAccountId ||
158 olds.authorizedAwsRegion !== news.authorizedAwsRegion
159 ) {
160 return { action: "replace" } as const;
161 }
162 }),
163 reconcile: Effect.fn(function* ({ id, news, session }) {
164 const internalTags = yield* createInternalTags(id);
165 const desiredTags = { ...news.tags, ...internalTags };
166
167 // 1. OBSERVE — cloud state is authoritative.
168 let live = yield* observeAuthorization(
169 news.authorizedAccountId,
170 news.authorizedAwsRegion,
171 );
172
173 // 2. ENSURE — PutAggregationAuthorization is idempotent: it
174 // creates when missing and is a no-op when present (create-time
175 // tags on an existing authorization are ignored, so tags are
176 // synced separately below).
177 if (live === undefined) {
178 live = yield* config
179 .putAggregationAuthorization({
180 AuthorizedAccountId: news.authorizedAccountId,
181 AuthorizedAwsRegion: news.authorizedAwsRegion,
182 Tags: createTagsList(desiredTags),
183 })
184 .pipe(Effect.map((r) => r.AggregationAuthorization));
185 }
186
187 const arn = live?.AggregationAuthorizationArn;
188 if (arn === undefined) {
189 // Extremely unlikely (Put returns the authorization) — fall back
190 // to a fresh observation so the attrs are never fabricated.
191 live = yield* observeAuthorization(
192 news.authorizedAccountId,
193 news.authorizedAwsRegion,
194 );
195 }
196
197 // 3. SYNC TAGS — diff against OBSERVED cloud tags so adoption
198 // converges.
199 const finalArn = live?.AggregationAuthorizationArn;
200 if (finalArn !== undefined) {
201 const currentTags = yield* observedTags(finalArn);
202 const { upsert, removed } = diffTags(currentTags, desiredTags);
203 if (upsert.length > 0) {
204 yield* config.tagResource({
205 ResourceArn: finalArn,
206 Tags: upsert,
207 });
208 }
209 if (removed.length > 0) {
210 yield* config.untagResource({
211 ResourceArn: finalArn,
212 TagKeys: removed,
213 });
214 }
215 }
216
217 yield* session.note(
218 `${news.authorizedAccountId}/${news.authorizedAwsRegion}`,
219 );
220 return {
221 aggregationAuthorizationArn: finalArn!,
222 authorizedAccountId: news.authorizedAccountId,
223 authorizedAwsRegion: news.authorizedAwsRegion,
224 };
225 }),
226 // DeleteAggregationAuthorization succeeds whether or not the
227 // authorization still exists — idempotent by design.
228 delete: Effect.fn(function* ({ output }) {
229 yield* config.deleteAggregationAuthorization({
230 AuthorizedAccountId: output.authorizedAccountId,
231 AuthorizedAwsRegion: output.authorizedAwsRegion,
232 });
233 }),
234 });
235 }),
236 );