1import * as auditmanager from "@distilled.cloud/aws/auditmanager";
2import * as Effect from "effect/Effect";
3import * as EffectStream 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 type Tags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import { toTagRecord, unredact } from "./internal.ts";
16
17/**
18 * The keyword used to search evidence for a control mapping source.
19 */
20export interface ControlSourceKeyword {
21 /**
22 * How the keyword is entered (`SELECT_FROM_LIST`, `UPLOAD_FILE`, or
23 * `INPUT_TEXT`).
24 */
25 keywordInputType?: auditmanager.KeywordInputType;
26 /**
27 * The value of the keyword — e.g. a CloudTrail event name, a Config rule
28 * name, or a Security Hub control name.
29 */
30 keywordValue?: string;
31}
32
33/**
34 * A data source Audit Manager collects evidence from for a custom control.
35 */
36export interface ControlMappingSourceProps {
37 /**
38 * Name of the evidence source.
39 */
40 sourceName: string;
41 /**
42 * A description of the source.
43 */
44 sourceDescription?: string;
45 /**
46 * How the source collects evidence: `System_Controls_Mapping` (automated)
47 * or `Procedural_Controls_Mapping` (manual).
48 */
49 sourceSetUpOption?: auditmanager.SourceSetUpOption;
50 /**
51 * The evidence collection method (`AWS_Cloudtrail`, `AWS_Config`,
52 * `AWS_Security_Hub`, `AWS_API_Call`, or `MANUAL`).
53 */
54 sourceType?: auditmanager.SourceType;
55 /**
56 * The keyword that scopes what evidence the source collects.
57 */
58 sourceKeyword?: ControlSourceKeyword;
59 /**
60 * How often automated evidence is collected (`DAILY`, `WEEKLY`, or
61 * `MONTHLY`).
62 */
63 sourceFrequency?: auditmanager.SourceFrequency;
64 /**
65 * Instructions shown to users when evidence collection fails.
66 */
67 troubleshootingText?: string;
68}
69
70export interface ControlProps {
71 /**
72 * Name of the custom control.
73 * @default ${app}-${stage}-${id}
74 */
75 name?: string;
76 /**
77 * A description of the control's purpose.
78 */
79 description?: string;
80 /**
81 * Steps for testing the control.
82 */
83 testingInformation?: string;
84 /**
85 * Title of the recommended action plan when the control isn't met.
86 */
87 actionPlanTitle?: string;
88 /**
89 * Recommended actions when the control isn't met.
90 */
91 actionPlanInstructions?: string;
92 /**
93 * The data sources Audit Manager collects evidence from for this control.
94 */
95 controlMappingSources: ControlMappingSourceProps[];
96 /**
97 * Tags to associate with the control.
98 */
99 tags?: Record<string, string>;
100}
101
102export interface Control extends Resource<
103 "AWS.AuditManager.Control",
104 ControlProps,
105 {
106 /**
107 * Service-assigned unique identifier of the control.
108 */
109 controlId: string;
110 /**
111 * ARN of the control.
112 */
113 arn: string;
114 /**
115 * The control's name.
116 */
117 name: string;
118 /**
119 * The control's type — always `Custom` for controls Alchemy creates.
120 */
121 type: auditmanager.ControlType | undefined;
122 /**
123 * Lifecycle state of the control (`ACTIVE` or `END_OF_SUPPORT`).
124 */
125 state: auditmanager.ControlState | undefined;
126 /**
127 * Current tags reported for the control.
128 */
129 tags: Record<string, string>;
130 },
131 never,
132 Providers
133> {}
134
135/**
136 * A custom control in AWS Audit Manager — a compliance requirement paired
137 * with the data sources (CloudTrail, Config, Security Hub, API calls, or
138 * manual evidence) Audit Manager collects evidence from to demonstrate it.
139 *
140 * :::note
141 * Audit Manager must be registered in the account (`RegisterAccount`)
142 * before controls can be created.
143 * :::
144 * ### Creating Controls
145 * **Example:** Manual-Evidence Control
146 * ```typescript
147 * import * as AWS from "alchemy/AWS";
148 *
149 * const control = yield* AWS.AuditManager.Control("AccessReview", {
150 * description: "Quarterly review of privileged access",
151 * controlMappingSources: [{
152 * sourceName: "access-review-records",
153 * sourceSetUpOption: "Procedural_Controls_Mapping",
154 * sourceType: "MANUAL",
155 * }],
156 * });
157 * ```
158 *
159 * **Example:** CloudTrail-Backed Control
160 * ```typescript
161 * const control = yield* AWS.AuditManager.Control("RootLoginMonitor", {
162 * description: "Detects console logins by the root user",
163 * controlMappingSources: [{
164 * sourceName: "root-console-logins",
165 * sourceSetUpOption: "System_Controls_Mapping",
166 * sourceType: "AWS_Cloudtrail",
167 * sourceKeyword: {
168 * keywordInputType: "SELECT_FROM_LIST",
169 * keywordValue: "ConsoleLogin",
170 * },
171 * sourceFrequency: "DAILY",
172 * }],
173 * });
174 * ```
175 *
176 * @resource
177 */
178export const Control = Resource<Control>("AWS.AuditManager.Control");
179
180const createControlName = (id: string, props: { name?: string | undefined }) =>
181 props.name
182 ? Effect.succeed(props.name)
183 : createPhysicalName({ id, maxLength: 100 });
184
185const toAttributes = (
186 control: auditmanager.Control,
187): Control["Attributes"] => ({
188 controlId: control.id ?? "",
189 arn: control.arn ?? "",
190 name: control.name ?? "",
191 type: control.type,
192 state: control.state,
193 tags: toTagRecord(control.tags),
194});
195
196const readControlById = Effect.fn(function* (controlId: string) {
197 const response = yield* auditmanager
198 .getControl({ controlId })
199 .pipe(
200 Effect.catchTag("ResourceNotFoundException", () =>
201 Effect.succeed(undefined),
202 ),
203 );
204 return response?.control;
205});
206
207const findControlByName = Effect.fn(function* (name: string) {
208 const pages = yield* auditmanager.listControls
209 .pages({ controlType: "Custom" })
210 .pipe(EffectStream.runCollect);
211 const match = Array.from(pages)
212 .flatMap((page) => page.controlMetadataList ?? [])
213 .find((control) => control.name === name);
214 if (!match?.id) return undefined;
215 return yield* readControlById(match.id);
216});
217
218/**
219 * Projection used for drift detection between desired and observed mapping
220 * sources — server-assigned fields (sourceId) are excluded.
221 */
222const projectSource = (source: {
223 sourceName?: string | undefined;
224 sourceDescription?: string | undefined;
225 sourceSetUpOption?: auditmanager.SourceSetUpOption | undefined;
226 sourceType?: auditmanager.SourceType | undefined;
227 sourceKeyword?: ControlSourceKeyword | undefined;
228 sourceFrequency?: auditmanager.SourceFrequency | undefined;
229 troubleshootingText?: string | undefined;
230}) => ({
231 sourceName: source.sourceName ?? "",
232 sourceDescription: source.sourceDescription ?? "",
233 sourceSetUpOption: source.sourceSetUpOption ?? "",
234 sourceType: source.sourceType ?? "",
235 keywordInputType: source.sourceKeyword?.keywordInputType ?? "",
236 keywordValue: source.sourceKeyword?.keywordValue ?? "",
237 sourceFrequency: source.sourceFrequency ?? "",
238 troubleshootingText: source.troubleshootingText ?? "",
239});
240
241export const ControlProvider = () =>
242 Provider.effect(
243 Control,
244 Effect.gen(function* () {
245 return {
246 stables: ["controlId", "arn"],
247 list: () =>
248 Effect.gen(function* () {
249 const pages = yield* auditmanager.listControls
250 .pages({ controlType: "Custom" })
251 .pipe(EffectStream.runCollect);
252 const ids = Array.from(pages)
253 .flatMap((page) => page.controlMetadataList ?? [])
254 .flatMap((control) => (control.id ? [control.id] : []));
255 const hydrated = yield* Effect.forEach(
256 ids,
257 (controlId) => readControlById(controlId),
258 { concurrency: 5 },
259 );
260 return hydrated.flatMap((control) =>
261 control === undefined ? [] : [toAttributes(control)],
262 );
263 }),
264 read: Effect.fn(function* ({ id, olds, output }) {
265 const control = output?.controlId
266 ? yield* readControlById(output.controlId)
267 : yield* findControlByName(
268 yield* createControlName(id, olds ?? {}),
269 );
270 if (!control) return undefined;
271 const attrs = toAttributes(control);
272 return (yield* hasAlchemyTags(id, attrs.tags as Tags))
273 ? attrs
274 : Unowned(attrs);
275 }),
276 reconcile: Effect.fn(function* ({ id, news, output, session }) {
277 const name = yield* createControlName(id, news);
278 const internalTags = yield* createInternalTags(id);
279 const desiredTags = { ...internalTags, ...news.tags };
280
281 // Observe — prefer the cached id; fall back to a name lookup so a
282 // create whose state failed to persist is adopted, not duplicated.
283 let control = output?.controlId
284 ? yield* readControlById(output.controlId)
285 : yield* findControlByName(name);
286
287 // Ensure — create if missing.
288 if (control === undefined) {
289 const created = yield* auditmanager.createControl({
290 name,
291 description: news.description,
292 testingInformation: news.testingInformation,
293 actionPlanTitle: news.actionPlanTitle,
294 actionPlanInstructions: news.actionPlanInstructions,
295 controlMappingSources: news.controlMappingSources,
296 tags: desiredTags,
297 });
298 control = created.control;
299 if (!control?.id) {
300 return yield* Effect.fail(
301 new Error(`CreateControl for '${name}' returned no control`),
302 );
303 }
304 yield* session.note(`Created control ${name} (${control.id})`);
305 }
306
307 // Sync — diff observed against desired; UpdateControl on drift.
308 const observedSources = (control.controlMappingSources ?? []).map(
309 (source) =>
310 projectSource({
311 ...source,
312 troubleshootingText: unredact(source.troubleshootingText),
313 }),
314 );
315 const desiredSources = news.controlMappingSources.map(projectSource);
316 const drifted =
317 (control.name ?? "") !== name ||
318 (unredact(control.description) ?? "") !==
319 (news.description ?? "") ||
320 (unredact(control.testingInformation) ?? "") !==
321 (news.testingInformation ?? "") ||
322 (unredact(control.actionPlanTitle) ?? "") !==
323 (news.actionPlanTitle ?? "") ||
324 (unredact(control.actionPlanInstructions) ?? "") !==
325 (news.actionPlanInstructions ?? "") ||
326 JSON.stringify(observedSources) !== JSON.stringify(desiredSources);
327 if (drifted) {
328 // Reuse the server-assigned sourceId for sources whose name is
329 // unchanged so AWS updates them in place instead of replacing.
330 const observedByName = new Map(
331 (control.controlMappingSources ?? []).map((source) => [
332 source.sourceName,
333 source.sourceId,
334 ]),
335 );
336 const updated = yield* auditmanager.updateControl({
337 controlId: control.id ?? "",
338 name,
339 description: news.description,
340 testingInformation: news.testingInformation,
341 actionPlanTitle: news.actionPlanTitle,
342 actionPlanInstructions: news.actionPlanInstructions,
343 controlMappingSources: news.controlMappingSources.map(
344 (source) => ({
345 ...source,
346 sourceId: observedByName.get(source.sourceName),
347 }),
348 ),
349 });
350 control = updated.control ?? control;
351 yield* session.note(`Updated control ${name}`);
352 }
353
354 // Sync tags — diff against observed cloud tags.
355 const attrs = toAttributes(control);
356 const { removed, upsert } = diffTags(attrs.tags, desiredTags);
357 if (removed.length > 0) {
358 yield* auditmanager.untagResource({
359 resourceArn: attrs.arn,
360 tagKeys: removed,
361 });
362 }
363 if (upsert.length > 0) {
364 yield* auditmanager.tagResource({
365 resourceArn: attrs.arn,
366 tags: Object.fromEntries(
367 upsert.map(({ Key, Value }) => [Key, Value]),
368 ),
369 });
370 }
371
372 yield* session.note(attrs.arn);
373 return { ...attrs, tags: desiredTags };
374 }),
375 delete: Effect.fn(function* ({ output }) {
376 yield* auditmanager
377 .deleteControl({ controlId: output.controlId })
378 .pipe(
379 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
380 );
381 }),
382 };
383 }),
384 );