1import * as macie2 from "@distilled.cloud/aws/macie2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 diffTags,
11 hasAlchemyTags,
12 tagRecord,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import { retryThroughEnablement } from "./common.ts";
16
17export interface AllowListProps {
18 /**
19 * Custom name for the allow list (1-128 characters). Must be unique per
20 * account. If omitted, a unique name is generated from the app/stage/logical
21 * ID. Updatable in place.
22 */
23 name?: string;
24
25 /**
26 * Custom description of the allow list (up to 512 characters). Updatable in
27 * place.
28 */
29 description?: string;
30
31 /**
32 * The criteria that specify the text to ignore — either a `regex` (a regular
33 * expression that defines the text pattern) or an `s3WordsList` (an S3
34 * object listing predefined words, one per line). Updatable in place.
35 */
36 criteria: macie2.AllowListCriteria;
37
38 /**
39 * Tags applied to the allow list. Alchemy ownership tags are merged in
40 * automatically.
41 */
42 tags?: Record<string, string>;
43}
44
45/** @resource */
46export interface AllowList extends Resource<
47 "AWS.Macie2.AllowList",
48 AllowListProps,
49 {
50 /** Generated allow list ID. */
51 id: string;
52 /** ARN of the allow list. */
53 arn: string;
54 /** The resolved allow list name. */
55 name: string;
56 /** Current status code (`OK` / `S3_OBJECT_NOT_FOUND` / ...). */
57 status: string | undefined;
58 },
59 never,
60 Providers
61> {}
62
63/**
64 * An Amazon Macie allow list — text patterns or predefined words that Macie
65 * ignores when it inspects S3 objects for sensitive data. Requires Macie to be
66 * enabled for the account (see `Macie2.Session`). Name, description, and
67 * criteria are all updatable in place; destroy deletes the list even if
68 * classification jobs reference it.
69 *
70 * ### Creating an allow list
71 * **Example:** Regex allow list
72 * ```typescript
73 * const allowList = yield* Macie2.AllowList("InternalIds", {
74 * description: "Internal ticket ids are not sensitive",
75 * criteria: { regex: "TICKET-[0-9]{6}" },
76 * });
77 * ```
78 *
79 * **Example:** Predefined words from S3
80 * ```typescript
81 * const allowList = yield* Macie2.AllowList("KnownTestData", {
82 * criteria: {
83 * s3WordsList: { bucketName: bucket.bucketName, objectKey: "words.txt" },
84 * },
85 * });
86 * ```
87 */
88const AllowListResource = Resource<AllowList>("AWS.Macie2.AllowList");
89
90export { AllowListResource as AllowList };
91
92const createName = (id: string, props: Partial<AllowListProps>) =>
93 props.name
94 ? Effect.succeed(props.name)
95 : createPhysicalName({ id, maxLength: 128 });
96
97const buildAllowListAttrs = (
98 id: string,
99 live: macie2.GetAllowListResponse,
100) => ({
101 id,
102 arn: live.arn!,
103 name: live.name!,
104 status: live.status?.code,
105});
106
107export const AllowListProvider = () =>
108 Provider.effect(
109 AllowListResource,
110 Effect.gen(function* () {
111 const getAllowList = (id: string) =>
112 macie2.getAllowList({ id }).pipe(
113 Effect.catchTag("ResourceNotFoundException", () =>
114 Effect.succeed(undefined),
115 ),
116 // Macie disabled ⇒ the list is unreachable (and disabling deletes
117 // all Macie configuration), so report it as gone.
118 Effect.catchTag("AccessDeniedException", () =>
119 Effect.succeed(undefined),
120 ),
121 );
122
123 return {
124 stables: ["id", "arn"],
125
126 read: Effect.fn(function* ({ id, output }) {
127 if (!output?.id) return undefined;
128 const live = yield* getAllowList(output.id);
129 if (!live) return undefined;
130 const attrs = buildAllowListAttrs(output.id, live);
131 return (yield* hasAlchemyTags(id, live.tags))
132 ? attrs
133 : Unowned(attrs);
134 }),
135
136 list: () =>
137 Effect.gen(function* () {
138 const pages = yield* macie2.listAllowLists.pages({}).pipe(
139 Stream.runCollect,
140 Effect.catchTag("AccessDeniedException", () =>
141 Effect.succeed([]),
142 ),
143 );
144 const out: AllowList["Attributes"][] = [];
145 for (const page of pages) {
146 for (const summary of page.allowLists ?? []) {
147 const live = yield* getAllowList(summary.id!);
148 if (live) out.push(buildAllowListAttrs(summary.id!, live));
149 }
150 }
151 return out;
152 }),
153
154 reconcile: Effect.fn(function* ({ id, news, output, session }) {
155 const internalTags = yield* createInternalTags(id);
156 const desiredTags = { ...news.tags, ...internalTags };
157 const name = news.name ?? (yield* createName(id, news));
158
159 // 1. OBSERVE — cloud state is authoritative; output caches the id.
160 let allowListId = output?.id;
161 let live = allowListId ? yield* getAllowList(allowListId) : undefined;
162
163 if (!live || allowListId === undefined) {
164 // 2. ENSURE — create the allow list (retry through enablement lag).
165 const created = yield* retryThroughEnablement(
166 macie2.createAllowList({
167 name,
168 description: news.description,
169 criteria: news.criteria,
170 tags: desiredTags,
171 }),
172 );
173 allowListId = created.id!;
174 } else {
175 // 3. SYNC settings — observed ↔ desired (name is required by the
176 // update API, so it is always sent when anything drifts).
177 const drift =
178 live.name !== name ||
179 (news.description !== undefined &&
180 live.description !== news.description) ||
181 JSON.stringify(live.criteria) !== JSON.stringify(news.criteria);
182 if (drift) {
183 yield* macie2.updateAllowList({
184 id: allowListId,
185 name,
186 description: news.description,
187 criteria: news.criteria,
188 });
189 }
190
191 // 3b. SYNC tags — diff against OBSERVED cloud tags.
192 const { upsert, removed } = diffTags(
193 tagRecord(live.tags),
194 desiredTags,
195 );
196 if (upsert.length > 0) {
197 yield* macie2.tagResource({
198 resourceArn: live.arn!,
199 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
200 });
201 }
202 if (removed.length > 0) {
203 yield* macie2.untagResource({
204 resourceArn: live.arn!,
205 tagKeys: removed,
206 });
207 }
208 }
209
210 // 4. RETURN fresh attributes.
211 const final = yield* macie2.getAllowList({ id: allowListId });
212 yield* session.note(allowListId);
213 return buildAllowListAttrs(allowListId, final);
214 }),
215
216 delete: Effect.fn(function* ({ output }) {
217 // Idempotent — the list may already be gone, or Macie may already be
218 // disabled for the account (which removes all Macie configuration).
219 yield* macie2
220 .deleteAllowList({ id: output.id, ignoreJobChecks: "true" })
221 .pipe(
222 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
223 Effect.catchTag("AccessDeniedException", () => Effect.void),
224 );
225 }),
226 };
227 }),
228 );