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 * A control referenced by a framework control set.
19 */
20export interface FrameworkControl {
21 /**
22 * The unique identifier of the control (standard or custom).
23 */
24 id: string;
25}
26
27/**
28 * A grouping of related controls within a framework.
29 */
30export interface FrameworkControlSet {
31 /**
32 * Name of the control set.
33 */
34 name: string;
35 /**
36 * The controls in the set, referenced by control id.
37 */
38 controls?: FrameworkControl[];
39}
40
41export interface FrameworkProps {
42 /**
43 * Name of the custom framework.
44 * @default ${app}-${stage}-${id}
45 */
46 name?: string;
47 /**
48 * A description of the framework.
49 */
50 description?: string;
51 /**
52 * The compliance standard the framework relates to (e.g. `CIS`, `HIPAA`).
53 */
54 complianceType?: string;
55 /**
56 * The control sets that make up the framework. At least one set with at
57 * least one control is required.
58 */
59 controlSets: FrameworkControlSet[];
60 /**
61 * Tags to associate with the framework.
62 */
63 tags?: Record<string, string>;
64}
65
66export interface Framework extends Resource<
67 "AWS.AuditManager.Framework",
68 FrameworkProps,
69 {
70 /**
71 * Service-assigned unique identifier of the framework.
72 */
73 frameworkId: string;
74 /**
75 * ARN of the framework.
76 */
77 arn: string;
78 /**
79 * The framework's name.
80 */
81 name: string;
82 /**
83 * The framework's type — always `Custom` for frameworks Alchemy creates.
84 */
85 type: auditmanager.FrameworkType | undefined;
86 /**
87 * Current tags reported for the framework.
88 */
89 tags: Record<string, string>;
90 },
91 never,
92 Providers
93> {}
94
95/**
96 * A custom assessment framework in AWS Audit Manager — a named collection
97 * of control sets that assessments are created from.
98 *
99 * :::note
100 * Audit Manager must be registered in the account (`RegisterAccount`)
101 * before frameworks can be created.
102 * :::
103 * ### Creating Frameworks
104 * **Example:** Framework from a Custom Control
105 * ```typescript
106 * import * as AWS from "alchemy/AWS";
107 *
108 * const control = yield* AWS.AuditManager.Control("AccessReview", {
109 * controlMappingSources: [{
110 * sourceName: "access-review-records",
111 * sourceSetUpOption: "Procedural_Controls_Mapping",
112 * sourceType: "MANUAL",
113 * }],
114 * });
115 *
116 * const framework = yield* AWS.AuditManager.Framework("Compliance", {
117 * description: "Internal compliance framework",
118 * controlSets: [{
119 * name: "Access Management",
120 * controls: [{ id: control.controlId }],
121 * }],
122 * });
123 * ```
124 *
125 * @resource
126 */
127export const Framework = Resource<Framework>("AWS.AuditManager.Framework");
128
129const createFrameworkName = (
130 id: string,
131 props: { name?: string | undefined },
132) =>
133 props.name
134 ? Effect.succeed(props.name)
135 : createPhysicalName({ id, maxLength: 100 });
136
137const toAttributes = (
138 framework: auditmanager.Framework,
139): Framework["Attributes"] => ({
140 frameworkId: framework.id ?? "",
141 arn: framework.arn ?? "",
142 name: framework.name ?? "",
143 type: framework.type,
144 tags: toTagRecord(framework.tags),
145});
146
147const readFrameworkById = Effect.fn(function* (frameworkId: string) {
148 const response = yield* auditmanager
149 .getAssessmentFramework({ frameworkId })
150 .pipe(
151 Effect.catchTag("ResourceNotFoundException", () =>
152 Effect.succeed(undefined),
153 ),
154 );
155 return response?.framework;
156});
157
158const findFrameworkByName = Effect.fn(function* (name: string) {
159 const pages = yield* auditmanager.listAssessmentFrameworks
160 .pages({ frameworkType: "Custom" })
161 .pipe(EffectStream.runCollect);
162 const match = Array.from(pages)
163 .flatMap((page) => page.frameworkMetadataList ?? [])
164 .find((framework) => framework.name === name);
165 if (!match?.id) return undefined;
166 return yield* readFrameworkById(match.id);
167});
168
169/**
170 * Projection used for drift detection — control set names with their sorted
171 * control ids.
172 */
173const projectControlSets = (
174 controlSets: {
175 name?: string | undefined;
176 controls?: { id?: string | undefined }[] | undefined;
177 }[],
178) =>
179 controlSets.map((set) => ({
180 name: set.name ?? "",
181 controls: (set.controls ?? [])
182 .flatMap((control) => (control.id ? [control.id] : []))
183 .sort(),
184 }));
185
186export const FrameworkProvider = () =>
187 Provider.effect(
188 Framework,
189 Effect.gen(function* () {
190 return {
191 stables: ["frameworkId", "arn"],
192 list: () =>
193 Effect.gen(function* () {
194 const pages = yield* auditmanager.listAssessmentFrameworks
195 .pages({ frameworkType: "Custom" })
196 .pipe(EffectStream.runCollect);
197 const ids = Array.from(pages)
198 .flatMap((page) => page.frameworkMetadataList ?? [])
199 .flatMap((framework) => (framework.id ? [framework.id] : []));
200 const hydrated = yield* Effect.forEach(
201 ids,
202 (frameworkId) => readFrameworkById(frameworkId),
203 { concurrency: 5 },
204 );
205 return hydrated.flatMap((framework) =>
206 framework === undefined ? [] : [toAttributes(framework)],
207 );
208 }),
209 read: Effect.fn(function* ({ id, olds, output }) {
210 const framework = output?.frameworkId
211 ? yield* readFrameworkById(output.frameworkId)
212 : yield* findFrameworkByName(
213 yield* createFrameworkName(id, olds ?? {}),
214 );
215 if (!framework) return undefined;
216 const attrs = toAttributes(framework);
217 return (yield* hasAlchemyTags(id, attrs.tags as Tags))
218 ? attrs
219 : Unowned(attrs);
220 }),
221 reconcile: Effect.fn(function* ({ id, news, output, session }) {
222 const name = yield* createFrameworkName(id, news);
223 const internalTags = yield* createInternalTags(id);
224 const desiredTags = { ...internalTags, ...news.tags };
225
226 // Observe — prefer the cached id; fall back to a name lookup so a
227 // create whose state failed to persist is adopted, not duplicated.
228 let framework = output?.frameworkId
229 ? yield* readFrameworkById(output.frameworkId)
230 : yield* findFrameworkByName(name);
231
232 // Ensure — create if missing.
233 if (framework === undefined) {
234 const created = yield* auditmanager.createAssessmentFramework({
235 name,
236 description: news.description,
237 complianceType: news.complianceType,
238 controlSets: news.controlSets,
239 tags: desiredTags,
240 });
241 framework = created.framework;
242 if (!framework?.id) {
243 return yield* Effect.fail(
244 new Error(
245 `CreateAssessmentFramework for '${name}' returned no framework`,
246 ),
247 );
248 }
249 yield* session.note(`Created framework ${name} (${framework.id})`);
250 }
251
252 // Sync — diff observed against desired; update on drift.
253 const drifted =
254 (framework.name ?? "") !== name ||
255 (framework.description ?? "") !== (news.description ?? "") ||
256 (unredact(framework.complianceType) ?? "") !==
257 (news.complianceType ?? "") ||
258 JSON.stringify(projectControlSets(framework.controlSets ?? [])) !==
259 JSON.stringify(projectControlSets(news.controlSets));
260 if (drifted) {
261 // Reuse the server-assigned control set id for sets whose name is
262 // unchanged so AWS updates them in place instead of replacing.
263 const observedByName = new Map(
264 (framework.controlSets ?? []).map((set) => [set.name, set.id]),
265 );
266 const updated = yield* auditmanager.updateAssessmentFramework({
267 frameworkId: framework.id ?? "",
268 name,
269 description: news.description,
270 complianceType: news.complianceType,
271 controlSets: news.controlSets.map((set) => ({
272 id: observedByName.get(set.name),
273 name: set.name,
274 controls: set.controls ?? [],
275 })),
276 });
277 framework = updated.framework ?? framework;
278 yield* session.note(`Updated framework ${name}`);
279 }
280
281 // Sync tags — diff against observed cloud tags.
282 const attrs = toAttributes(framework);
283 const { removed, upsert } = diffTags(attrs.tags, desiredTags);
284 if (removed.length > 0) {
285 yield* auditmanager.untagResource({
286 resourceArn: attrs.arn,
287 tagKeys: removed,
288 });
289 }
290 if (upsert.length > 0) {
291 yield* auditmanager.tagResource({
292 resourceArn: attrs.arn,
293 tags: Object.fromEntries(
294 upsert.map(({ Key, Value }) => [Key, Value]),
295 ),
296 });
297 }
298
299 yield* session.note(attrs.arn);
300 return { ...attrs, tags: desiredTags };
301 }),
302 delete: Effect.fn(function* ({ output }) {
303 yield* auditmanager
304 .deleteAssessmentFramework({ frameworkId: output.frameworkId })
305 .pipe(
306 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
307 );
308 }),
309 };
310 }),
311 );