1import * as aa from "@distilled.cloud/aws/accessanalyzer";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import {
13 createInternalTags,
14 diffTags,
15 hasAlchemyTags,
16 tagRecord,
17} from "../../Tags.ts";
18import { toWireDays } from "../../Util/Duration.ts";
19import type { Providers } from "../Providers.ts";
20
21/**
22 * The zone of trust and access type an analyzer monitors.
23 *
24 * - `ACCOUNT` / `ORGANIZATION` — external-access analysis (the free tier):
25 * surfaces resource policies that grant access to principals outside the
26 * account or organization.
27 * - `ACCOUNT_UNUSED_ACCESS` / `ORGANIZATION_UNUSED_ACCESS` — unused-access
28 * analysis (paid): surfaces unused IAM roles, users, and permissions.
29 */
30export type AnalyzerType =
31 | "ACCOUNT"
32 | "ORGANIZATION"
33 | "ACCOUNT_UNUSED_ACCESS"
34 | "ORGANIZATION_UNUSED_ACCESS";
35
36export interface AnalyzerProps {
37 /**
38 * Name of the analyzer. Must be unique within the account/Region and start
39 * with a letter (`[A-Za-z][A-Za-z0-9_.-]*`, up to 255 characters). Changing
40 * the name replaces the analyzer.
41 * @default a generated physical name derived from the logical ID
42 */
43 analyzerName?: string;
44 /**
45 * The analyzer's zone of trust and access type. Create-only — changing the
46 * type replaces the analyzer.
47 * @default "ACCOUNT"
48 */
49 type?: AnalyzerType;
50 /**
51 * How long access must go unused before the analyzer reports it as an
52 * unused-access finding. Accepts any duration input (e.g. `"180 days"`);
53 * converted to whole days on the wire (valid range 1–365 days). Only valid
54 * for `ACCOUNT_UNUSED_ACCESS` / `ORGANIZATION_UNUSED_ACCESS` analyzers.
55 * Create-only — the API rejects in-place tracking-period updates, so
56 * changing this replaces the analyzer (delete-first, since accounts are
57 * limited to one unused-access analyzer per Region). When omitted, the
58 * analyzer's existing tracking period is left unmanaged.
59 * @default "90 days" (the AWS default)
60 */
61 unusedAccessAge?: Duration.Input;
62 /**
63 * Tags to apply to the analyzer. Merged with internal Alchemy tags.
64 */
65 tags?: Record<string, string>;
66}
67
68export interface Analyzer extends Resource<
69 "AWS.AccessAnalyzer.Analyzer",
70 AnalyzerProps,
71 {
72 analyzerName: string;
73 analyzerArn: string;
74 type: string;
75 status: string;
76 },
77 {},
78 Providers
79> {}
80
81class AnalyzerStillExists extends Data.TaggedError("AnalyzerStillExists")<{
82 readonly analyzerName: string;
83}> {}
84
85/**
86 * An AWS IAM Access Analyzer — continuously monitors resource policies to
87 * identify resources shared with an external entity (external-access
88 * analyzers) or unused IAM access (unused-access analyzers).
89 *
90 * The `ACCOUNT` external-access analyzer is free and is the common case:
91 * create one per account per Region to have Access Analyzer surface public
92 * and cross-account grants as findings.
93 * ### Creating an Analyzer
94 * **Example:** Account External-Access Analyzer
95 * ```typescript
96 * import * as AWS from "alchemy/AWS";
97 *
98 * const analyzer = yield* AWS.AccessAnalyzer.Analyzer("AccountAnalyzer", {
99 * type: "ACCOUNT",
100 * });
101 * ```
102 *
103 * **Example:** Analyzer with Tags
104 * ```typescript
105 * const analyzer = yield* AWS.AccessAnalyzer.Analyzer("AccountAnalyzer", {
106 * analyzerName: "prod-external-access",
107 * type: "ACCOUNT",
108 * tags: { Environment: "prod" },
109 * });
110 * ```
111 *
112 * **Example:** Unused-Access Analyzer with a Custom Tracking Period
113 * ```typescript
114 * const analyzer = yield* AWS.AccessAnalyzer.Analyzer("UnusedAccess", {
115 * type: "ACCOUNT_UNUSED_ACCESS",
116 * unusedAccessAge: "180 days",
117 * });
118 * ```
119 *
120 * ### Archiving Findings
121 * **Example:** Auto-archive Findings from a Trusted Account
122 * ```typescript
123 * const analyzer = yield* AWS.AccessAnalyzer.Analyzer("AccountAnalyzer", {});
124 *
125 * yield* AWS.AccessAnalyzer.ArchiveRule("TrustedAccount", {
126 * analyzerName: analyzer.analyzerName,
127 * ruleName: "trusted-account",
128 * filter: {
129 * "principal.AWS": { eq: ["123456789012"] },
130 * },
131 * });
132 * ```
133 *
134 * @resource
135 */
136export const Analyzer = Resource<Analyzer>("AWS.AccessAnalyzer.Analyzer");
137
138export const AnalyzerProvider = () =>
139 Provider.effect(
140 Analyzer,
141 Effect.gen(function* () {
142 const createName = Effect.fn(function* (
143 id: string,
144 props: { analyzerName?: string | undefined },
145 ) {
146 return (
147 props.analyzerName ??
148 (yield* createPhysicalName({ id, maxLength: 255 }))
149 );
150 });
151
152 const observe = Effect.fn(function* (name: string) {
153 return yield* aa.getAnalyzer({ analyzerName: name }).pipe(
154 Effect.map((r) => r.analyzer),
155 Effect.catchTag("ResourceNotFoundException", () =>
156 Effect.succeed(undefined),
157 ),
158 );
159 });
160
161 return Analyzer.Provider.of({
162 stables: ["analyzerName", "analyzerArn", "type"],
163
164 list: () =>
165 Effect.gen(function* () {
166 const pages = yield* aa.listAnalyzers
167 .pages({})
168 .pipe(Stream.runCollect);
169 const summaries = Array.from(pages).flatMap(
170 (page) => page.analyzers ?? [],
171 );
172 return summaries.map((summary) => ({
173 analyzerName: summary.name,
174 analyzerArn: summary.arn,
175 type: summary.type,
176 status: summary.status,
177 }));
178 }),
179
180 read: Effect.fn(function* ({ id, olds, output }) {
181 const name =
182 output?.analyzerName ?? (yield* createName(id, olds ?? {}));
183 const analyzer = yield* observe(name);
184 if (analyzer === undefined) return undefined;
185 const attrs = {
186 analyzerName: analyzer.name,
187 analyzerArn: analyzer.arn,
188 type: analyzer.type,
189 status: analyzer.status,
190 };
191 const tags = analyzer.tags ?? {};
192 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
193 }),
194
195 diff: Effect.fn(function* ({ id, news, olds }) {
196 if (!isResolved(news)) return undefined;
197 const oldName = yield* createName(id, olds);
198 const newName = yield* createName(id, news);
199 if (oldName !== newName) {
200 return { action: "replace" } as const;
201 }
202 // type is create-only for external-access analyzers
203 if ((olds.type ?? "ACCOUNT") !== (news.type ?? "ACCOUNT")) {
204 return { action: "replace" } as const;
205 }
206 // unusedAccessAge is create-only (the API rejects in-place
207 // tracking-period updates with "Cannot update unused access age").
208 // Delete first: accounts are limited to one unused-access analyzer
209 // per Region, so create-before-delete would hit the quota (and the
210 // physical name is stable across the replacement).
211 if (
212 news.unusedAccessAge !== undefined &&
213 toWireDays(olds.unusedAccessAge) !==
214 toWireDays(news.unusedAccessAge)
215 ) {
216 return { action: "replace", deleteFirst: true } as const;
217 }
218 // only tags are mutable → default update path
219 }),
220
221 reconcile: Effect.fn(function* ({ id, news, output, session }) {
222 const name = output?.analyzerName ?? (yield* createName(id, news));
223 const type = news.type ?? "ACCOUNT";
224 const internalTags = yield* createInternalTags(id);
225 const desiredTags = { ...news.tags, ...internalTags };
226 const desiredUnusedAccessAge = toWireDays(news.unusedAccessAge);
227
228 // 1. OBSERVE — cloud state is authoritative
229 let analyzer = yield* observe(name);
230
231 // 2. ENSURE — createAnalyzer, tolerating a concurrent create race
232 if (analyzer === undefined) {
233 yield* aa
234 .createAnalyzer({
235 analyzerName: name,
236 type,
237 tags: desiredTags,
238 configuration:
239 desiredUnusedAccessAge !== undefined
240 ? {
241 unusedAccess: {
242 unusedAccessAge: desiredUnusedAccessAge,
243 },
244 }
245 : undefined,
246 })
247 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
248 analyzer = yield* observe(name);
249 }
250 if (analyzer === undefined) {
251 return yield* Effect.fail(
252 new aa.ResourceNotFoundException({
253 message: `analyzer ${name} not visible after create`,
254 resourceId: name,
255 resourceType: "AWS::AccessAnalyzer::Analyzer",
256 }),
257 );
258 }
259
260 // 3. SYNC TAGS — diff against OBSERVED cloud tags
261 // (unusedAccessAge is create-only — diff replaces on change, so
262 // there is no configuration sync step.)
263 const observedTags = tagRecord(analyzer.tags);
264 const { upsert, removed } = diffTags(observedTags, desiredTags);
265 if (upsert.length > 0) {
266 yield* aa.tagResource({
267 resourceArn: analyzer.arn,
268 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
269 });
270 }
271 if (removed.length > 0) {
272 yield* aa.untagResource({
273 resourceArn: analyzer.arn,
274 tagKeys: removed,
275 });
276 }
277
278 yield* session.note(analyzer.arn);
279 return {
280 analyzerName: analyzer.name,
281 analyzerArn: analyzer.arn,
282 type: analyzer.type,
283 status: analyzer.status,
284 };
285 }),
286
287 delete: Effect.fn(function* ({ output }) {
288 yield* aa
289 .deleteAnalyzer({ analyzerName: output.analyzerName })
290 .pipe(
291 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
292 );
293
294 // Unused-access analyzers are account/Region singletons. The delete
295 // API returns before the quota slot is necessarily reusable, so a
296 // delete-first replacement (or a subsequent clean test run) can hit
297 // ServiceQuotaExceededException unless we observe deletion here.
298 yield* observe(output.analyzerName).pipe(
299 Effect.flatMap((analyzer) =>
300 analyzer === undefined
301 ? Effect.void
302 : Effect.fail(
303 new AnalyzerStillExists({
304 analyzerName: output.analyzerName,
305 }),
306 ),
307 ),
308 Effect.retry({
309 while: (error) => error._tag === "AnalyzerStillExists",
310 schedule: Schedule.max([
311 Schedule.spaced("2 seconds"),
312 Schedule.recurs(15),
313 ]),
314 }),
315 );
316 }),
317 });
318 }),
319 );