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 { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import { retryThroughEnablement } from "./common.ts";
17
18export interface CustomDataIdentifierProps {
19 /**
20 * Custom name for the identifier (1-128 characters). Must be unique per
21 * account. If omitted, a unique name is generated from the app/stage/logical
22 * ID. Changing this replaces the identifier — custom data identifiers are
23 * immutable once created.
24 */
25 name?: string;
26
27 /**
28 * Custom description of the identifier (up to 512 characters). Changing this
29 * replaces the identifier.
30 */
31 description?: string;
32
33 /**
34 * The regular expression that defines the text pattern to match (up to 512
35 * characters). Changing this replaces the identifier.
36 */
37 regex: string;
38
39 /**
40 * Words that must be in proximity of a pattern match for Macie to report it
41 * (up to 50 keywords, each 3-90 UTF-8 characters). Changing this replaces
42 * the identifier.
43 */
44 keywords?: string[];
45
46 /**
47 * Words to exclude from the results — a match containing one of these is
48 * ignored (up to 10 entries, each 4-90 UTF-8 characters). Changing this
49 * replaces the identifier.
50 */
51 ignoreWords?: string[];
52
53 /**
54 * The maximum number of characters between the end of a keyword and the end
55 * of the matched text (1-300). Changing this replaces the identifier.
56 * @default 50
57 */
58 maximumMatchDistance?: number;
59
60 /**
61 * Severity to assign findings based on the number of occurrences of matched
62 * text. Changing this replaces the identifier.
63 */
64 severityLevels?: macie2.SeverityLevel[];
65
66 /**
67 * Tags applied to the identifier. Alchemy ownership tags are merged in
68 * automatically.
69 */
70 tags?: Record<string, string>;
71}
72
73/** @resource */
74export interface CustomDataIdentifier extends Resource<
75 "AWS.Macie2.CustomDataIdentifier",
76 CustomDataIdentifierProps,
77 {
78 /** Generated custom data identifier ID. */
79 id: string;
80 /** ARN of the custom data identifier. */
81 arn: string;
82 /** The resolved identifier name. */
83 name: string;
84 },
85 never,
86 Providers
87> {}
88
89/**
90 * An Amazon Macie custom data identifier — a regex-based detection criterion
91 * (with optional keywords, ignore words, and severity thresholds) that
92 * classification jobs and automated discovery use to detect organization-
93 * specific sensitive data. Requires Macie to be enabled for the account (see
94 * `Macie2.Session`). Definitions are immutable: any change other than tags
95 * replaces the identifier. Destroy soft-deletes it.
96 *
97 * ### Creating a custom data identifier
98 * **Example:** Employee-id detector
99 * ```typescript
100 * const identifier = yield* Macie2.CustomDataIdentifier("EmployeeId", {
101 * regex: "EMP-[0-9]{8}",
102 * description: "Internal employee id format",
103 * });
104 * ```
105 *
106 * **Example:** Keyword-scoped detector with severity thresholds
107 * ```typescript
108 * const identifier = yield* Macie2.CustomDataIdentifier("AccountNumber", {
109 * regex: "[0-9]{12}",
110 * keywords: ["account number", "acct no"],
111 * maximumMatchDistance: 30,
112 * severityLevels: [
113 * { occurrencesThreshold: 1, severity: "LOW" },
114 * { occurrencesThreshold: 25, severity: "HIGH" },
115 * ],
116 * });
117 * ```
118 */
119const CustomDataIdentifierResource = Resource<CustomDataIdentifier>(
120 "AWS.Macie2.CustomDataIdentifier",
121);
122
123export { CustomDataIdentifierResource as CustomDataIdentifier };
124
125const createName = (id: string, props: Partial<CustomDataIdentifierProps>) =>
126 props.name
127 ? Effect.succeed(props.name)
128 : createPhysicalName({ id, maxLength: 128 });
129
130// The whole definition is immutable — any change replaces.
131const fingerprint = (props: Partial<CustomDataIdentifierProps>) =>
132 JSON.stringify({
133 description: props.description,
134 regex: props.regex,
135 keywords: props.keywords ?? [],
136 ignoreWords: props.ignoreWords ?? [],
137 maximumMatchDistance: props.maximumMatchDistance,
138 severityLevels: props.severityLevels ?? [],
139 });
140
141const buildIdentifierAttrs = (
142 id: string,
143 live: macie2.GetCustomDataIdentifierResponse,
144) => ({
145 id,
146 arn: live.arn!,
147 name: live.name!,
148});
149
150export const CustomDataIdentifierProvider = () =>
151 Provider.effect(
152 CustomDataIdentifierResource,
153 Effect.gen(function* () {
154 const getIdentifier = (id: string) =>
155 macie2.getCustomDataIdentifier({ id }).pipe(
156 // Deleting is a soft delete — a deleted identifier still answers,
157 // flagged `deleted: true`. Treat it (and a disabled Macie session)
158 // as gone.
159 Effect.map((d) => (d.deleted ? undefined : d)),
160 Effect.catchTag("ResourceNotFoundException", () =>
161 Effect.succeed(undefined),
162 ),
163 Effect.catchTag("AccessDeniedException", () =>
164 Effect.succeed(undefined),
165 ),
166 );
167
168 return {
169 stables: ["id", "arn", "name"],
170
171 read: Effect.fn(function* ({ id, output }) {
172 if (!output?.id) return undefined;
173 const live = yield* getIdentifier(output.id);
174 if (!live) return undefined;
175 const attrs = buildIdentifierAttrs(output.id, live);
176 return (yield* hasAlchemyTags(id, live.tags))
177 ? attrs
178 : Unowned(attrs);
179 }),
180
181 list: () =>
182 Effect.gen(function* () {
183 const pages = yield* macie2.listCustomDataIdentifiers
184 .pages({})
185 .pipe(
186 Stream.runCollect,
187 Effect.catchTag("AccessDeniedException", () =>
188 Effect.succeed([]),
189 ),
190 );
191 const out: CustomDataIdentifier["Attributes"][] = [];
192 for (const page of pages) {
193 for (const summary of page.items ?? []) {
194 const live = yield* getIdentifier(summary.id!);
195 if (live) out.push(buildIdentifierAttrs(summary.id!, live));
196 }
197 }
198 return out;
199 }),
200
201 diff: Effect.fn(function* ({ id, news, olds = {} }) {
202 if (!isResolved(news)) return undefined;
203 const oldName = yield* createName(id, olds);
204 const newName = yield* createName(id, news);
205 if (oldName !== newName) return { action: "replace" } as const;
206 if (fingerprint(olds) !== fingerprint(news)) {
207 return { action: "replace" } as const;
208 }
209 // The definition is immutable; only tags mutate in place.
210 return undefined;
211 }),
212
213 reconcile: Effect.fn(function* ({ id, news, output, session }) {
214 const internalTags = yield* createInternalTags(id);
215 const desiredTags = { ...news.tags, ...internalTags };
216 const name = output?.name ?? (yield* createName(id, news));
217
218 // 1. OBSERVE — cloud state is authoritative; output caches the id.
219 let identifierId = output?.id;
220 let live = identifierId
221 ? yield* getIdentifier(identifierId)
222 : undefined;
223
224 if (!live || identifierId === undefined) {
225 // 2. ENSURE — create the identifier (retry through enablement lag).
226 const created = yield* retryThroughEnablement(
227 macie2.createCustomDataIdentifier({
228 name,
229 description: news.description,
230 regex: news.regex,
231 keywords: news.keywords,
232 ignoreWords: news.ignoreWords,
233 maximumMatchDistance: news.maximumMatchDistance,
234 severityLevels: news.severityLevels,
235 tags: desiredTags,
236 }),
237 );
238 identifierId = created.customDataIdentifierId!;
239 } else {
240 // 3. SYNC tags — the definition is immutable; only tags mutate.
241 const { upsert, removed } = diffTags(
242 tagRecord(live.tags),
243 desiredTags,
244 );
245 if (upsert.length > 0) {
246 yield* macie2.tagResource({
247 resourceArn: live.arn!,
248 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
249 });
250 }
251 if (removed.length > 0) {
252 yield* macie2.untagResource({
253 resourceArn: live.arn!,
254 tagKeys: removed,
255 });
256 }
257 }
258
259 // 4. RETURN fresh attributes.
260 const final = yield* macie2.getCustomDataIdentifier({
261 id: identifierId,
262 });
263 yield* session.note(identifierId);
264 return buildIdentifierAttrs(identifierId, final);
265 }),
266
267 delete: Effect.fn(function* ({ output }) {
268 // Idempotent soft delete — the identifier may already be gone, or
269 // Macie may already be disabled for the account.
270 yield* macie2.deleteCustomDataIdentifier({ id: output.id }).pipe(
271 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
272 Effect.catchTag("AccessDeniedException", () => Effect.void),
273 );
274 }),
275 };
276 }),
277 );