1import * as auditmanager from "@distilled.cloud/aws/auditmanager";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as EffectStream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 diffTags,
13 hasAlchemyTags,
14 type Tags,
15} from "../../Tags.ts";
16import { AWSEnvironment } from "../Environment.ts";
17import type { Providers } from "../Providers.ts";
18import { toTagRecord, unredact } from "./internal.ts";
19
20/**
21 * Where Audit Manager stores generated assessment reports.
22 */
23export interface AssessmentReportsDestinationProps {
24 /**
25 * The destination type. Only `S3` is supported.
26 * @default "S3"
27 */
28 destinationType?: auditmanager.AssessmentReportDestinationType;
29 /**
30 * The destination bucket, as an S3 URL (e.g. `s3://my-bucket`).
31 */
32 destination: string;
33}
34
35/**
36 * An AWS account included in the scope of an assessment.
37 */
38export interface AssessmentScopeAccount {
39 /**
40 * The account id.
41 */
42 id: string;
43}
44
45/**
46 * An AWS service included in the scope of an assessment.
47 */
48export interface AssessmentScopeService {
49 /**
50 * The service name (e.g. `s3`, `iam`).
51 */
52 serviceName: string;
53}
54
55/**
56 * The accounts and services an assessment collects evidence for.
57 */
58export interface AssessmentScope {
59 /**
60 * The accounts in scope.
61 */
62 awsAccounts?: AssessmentScopeAccount[];
63 /**
64 * The services in scope. Omit to let Audit Manager infer services from
65 * the in-scope accounts.
66 */
67 awsServices?: AssessmentScopeService[];
68}
69
70/**
71 * A user or role responsible for an assessment.
72 */
73export interface AssessmentRole {
74 /**
75 * The role type — `PROCESS_OWNER` or `RESOURCE_OWNER`.
76 */
77 roleType: auditmanager.RoleType;
78 /**
79 * ARN of the IAM user or role.
80 */
81 roleArn: string;
82}
83
84export interface AssessmentProps {
85 /**
86 * Name of the assessment.
87 * @default ${app}-${stage}-${id}
88 */
89 name?: string;
90 /**
91 * A description of the assessment.
92 */
93 description?: string;
94 /**
95 * The unique identifier of the framework the assessment is created from.
96 * Changing it replaces the assessment.
97 */
98 frameworkId: string;
99 /**
100 * Where generated assessment reports are stored.
101 */
102 assessmentReportsDestination: AssessmentReportsDestinationProps;
103 /**
104 * The accounts and services in scope.
105 * @default the current account
106 */
107 scope?: AssessmentScope;
108 /**
109 * The users and roles responsible for the assessment.
110 */
111 roles: AssessmentRole[];
112 /**
113 * Tags to associate with the assessment.
114 */
115 tags?: Record<string, string>;
116}
117
118export interface Assessment extends Resource<
119 "AWS.AuditManager.Assessment",
120 AssessmentProps,
121 {
122 /**
123 * Service-assigned unique identifier of the assessment.
124 */
125 assessmentId: string;
126 /**
127 * ARN of the assessment.
128 */
129 arn: string;
130 /**
131 * The assessment's name.
132 */
133 name: string;
134 /**
135 * Current status of the assessment (`ACTIVE` or `INACTIVE`).
136 */
137 status: auditmanager.AssessmentStatus | undefined;
138 /**
139 * The id of the framework the assessment was created from.
140 */
141 frameworkId: string;
142 /**
143 * Current tags reported for the assessment.
144 */
145 tags: Record<string, string>;
146 },
147 never,
148 Providers
149> {}
150
151/**
152 * An AWS Audit Manager assessment — an active evidence-collection engagement
153 * created from a framework, continuously gathering evidence for the controls
154 * in scope.
155 *
156 * :::note
157 * Audit Manager must be registered in the account (`RegisterAccount`)
158 * before assessments can be created.
159 * :::
160 * ### Creating Assessments
161 * **Example:** Assessment from a Custom Framework
162 * ```typescript
163 * import * as AWS from "alchemy/AWS";
164 *
165 * const reports = yield* AWS.S3.Bucket("AuditReports", {});
166 *
167 * const owner = yield* AWS.IAM.Role("AuditOwner", {
168 * assumeRolePolicyDocument: {
169 * Version: "2012-10-17",
170 * Statement: [{
171 * Effect: "Allow",
172 * Principal: { Service: "auditmanager.amazonaws.com" },
173 * Action: ["sts:AssumeRole"],
174 * }],
175 * },
176 * });
177 *
178 * const assessment = yield* AWS.AuditManager.Assessment("Quarterly", {
179 * frameworkId: framework.frameworkId,
180 * assessmentReportsDestination: {
181 * destination: reports.bucketName.apply((name) => `s3://${name}`),
182 * },
183 * roles: [{ roleType: "PROCESS_OWNER", roleArn: owner.roleArn }],
184 * });
185 * ```
186 *
187 * @resource
188 */
189export const Assessment = Resource<Assessment>("AWS.AuditManager.Assessment");
190
191const createAssessmentName = (
192 id: string,
193 props: { name?: string | undefined },
194) =>
195 props.name
196 ? Effect.succeed(props.name)
197 : createPhysicalName({ id, maxLength: 100 });
198
199const toAttributes = (
200 assessment: auditmanager.Assessment,
201): Assessment["Attributes"] => ({
202 assessmentId: assessment.metadata?.id ?? "",
203 arn: assessment.arn ?? "",
204 name: unredact(assessment.metadata?.name) ?? "",
205 status: assessment.metadata?.status,
206 frameworkId: assessment.framework?.id ?? "",
207 tags: toTagRecord(assessment.tags),
208});
209
210const readAssessmentById = Effect.fn(function* (assessmentId: string) {
211 const response = yield* auditmanager
212 .getAssessment({ assessmentId })
213 .pipe(
214 Effect.catchTag("ResourceNotFoundException", () =>
215 Effect.succeed(undefined),
216 ),
217 );
218 return response?.assessment;
219});
220
221const findAssessmentByName = Effect.fn(function* (name: string) {
222 const pages = yield* auditmanager.listAssessments
223 .pages({})
224 .pipe(EffectStream.runCollect);
225 const match = Array.from(pages)
226 .flatMap((page) => page.assessmentMetadata ?? [])
227 .find(
228 (assessment) =>
229 unredact(assessment.name) === name && assessment.status !== "INACTIVE",
230 );
231 if (!match?.id) return undefined;
232 return yield* readAssessmentById(match.id);
233});
234
235/**
236 * Projection of the mutable assessment surface used for drift detection.
237 */
238const projectAssessment = (input: {
239 name: string;
240 description: string;
241 destinationType: string;
242 destination: string;
243 accountIds: string[];
244 serviceNames: string[];
245 roles: { roleType: string; roleArn: string }[];
246}) => ({
247 ...input,
248 accountIds: [...input.accountIds].sort(),
249 serviceNames: [...input.serviceNames].sort(),
250 roles: [...input.roles].sort((l, r) =>
251 `${l.roleArn}:${l.roleType}`.localeCompare(`${r.roleArn}:${r.roleType}`),
252 ),
253});
254
255// CreateAssessment validates the process-owner IAM roles up front; a
256// freshly-created role may not have propagated yet and surfaces as
257// ValidationException. Bounded retry through the propagation window (~60s).
258// Explicitly typed — an inline `Effect.retry` in provider lifecycle code
259// leaks `Retry.Return`'s conditional type into declaration emit and widens
260// the provider layer to `unknown` for every consumer of `AWS.providers()`.
261const retryThroughIamPropagation = <A, E extends { readonly _tag: string }, R>(
262 self: Effect.Effect<A, E, R>,
263): Effect.Effect<A, E, R> =>
264 Effect.retry(self, {
265 while: (e) => e._tag === "ValidationException",
266 schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(12)]),
267 });
268
269export const AssessmentProvider = () =>
270 Provider.effect(
271 Assessment,
272 Effect.gen(function* () {
273 return {
274 stables: ["assessmentId", "arn"],
275 list: () =>
276 Effect.gen(function* () {
277 const pages = yield* auditmanager.listAssessments
278 .pages({})
279 .pipe(EffectStream.runCollect);
280 const ids = Array.from(pages)
281 .flatMap((page) => page.assessmentMetadata ?? [])
282 .flatMap((assessment) => (assessment.id ? [assessment.id] : []));
283 const hydrated = yield* Effect.forEach(
284 ids,
285 (assessmentId) => readAssessmentById(assessmentId),
286 { concurrency: 5 },
287 );
288 return hydrated.flatMap((assessment) =>
289 assessment === undefined ? [] : [toAttributes(assessment)],
290 );
291 }),
292 read: Effect.fn(function* ({ id, olds, output }) {
293 const assessment = output?.assessmentId
294 ? yield* readAssessmentById(output.assessmentId)
295 : yield* findAssessmentByName(
296 yield* createAssessmentName(id, olds ?? {}),
297 );
298 if (!assessment) return undefined;
299 const attrs = toAttributes(assessment);
300 return (yield* hasAlchemyTags(id, attrs.tags as Tags))
301 ? attrs
302 : Unowned(attrs);
303 }),
304 diff: Effect.fn(function* ({ news, olds }) {
305 if (!isResolved(news)) return;
306 if (olds === undefined) return;
307 // The source framework is fixed at creation.
308 if (olds.frameworkId !== news.frameworkId) {
309 return { action: "replace" } as const;
310 }
311 }),
312 reconcile: Effect.fn(function* ({ id, news, output, session }) {
313 const { accountId } = yield* AWSEnvironment.current;
314 const name = yield* createAssessmentName(id, news);
315 const internalTags = yield* createInternalTags(id);
316 const desiredTags = { ...internalTags, ...news.tags };
317 const desiredScope: AssessmentScope = news.scope ?? {
318 awsAccounts: [{ id: accountId }],
319 };
320 const desiredDestination = {
321 destinationType:
322 news.assessmentReportsDestination.destinationType ?? "S3",
323 destination: news.assessmentReportsDestination.destination,
324 };
325
326 // Observe — prefer the cached id; fall back to a name lookup so a
327 // create whose state failed to persist is adopted, not duplicated.
328 let assessment = output?.assessmentId
329 ? yield* readAssessmentById(output.assessmentId)
330 : yield* findAssessmentByName(name);
331
332 // Ensure — create if missing.
333 if (assessment === undefined) {
334 const created = yield* retryThroughIamPropagation(
335 auditmanager.createAssessment({
336 name,
337 description: news.description,
338 frameworkId: news.frameworkId,
339 assessmentReportsDestination: desiredDestination,
340 scope: desiredScope,
341 roles: news.roles,
342 tags: desiredTags,
343 }),
344 );
345 assessment = created.assessment;
346 if (!assessment?.metadata?.id) {
347 return yield* Effect.fail(
348 new Error(
349 `CreateAssessment for '${name}' returned no assessment`,
350 ),
351 );
352 }
353 yield* session.note(
354 `Created assessment ${name} (${assessment.metadata.id})`,
355 );
356 }
357
358 // Sync — diff observed against desired; update on drift.
359 const metadata = assessment.metadata;
360 const observed = projectAssessment({
361 name: unredact(metadata?.name) ?? "",
362 description: unredact(metadata?.description) ?? "",
363 destinationType:
364 metadata?.assessmentReportsDestination?.destinationType ?? "S3",
365 destination:
366 metadata?.assessmentReportsDestination?.destination ?? "",
367 accountIds: (metadata?.scope?.awsAccounts ?? []).flatMap(
368 (account) => (account.id ? [account.id] : []),
369 ),
370 serviceNames: (metadata?.scope?.awsServices ?? []).flatMap(
371 (service) => (service.serviceName ? [service.serviceName] : []),
372 ),
373 roles: (metadata?.roles ?? []).map((role) => ({
374 roleType: role.roleType,
375 roleArn: role.roleArn,
376 })),
377 });
378 const desired = projectAssessment({
379 name,
380 description: news.description ?? "",
381 destinationType: desiredDestination.destinationType,
382 destination: desiredDestination.destination,
383 accountIds: (desiredScope.awsAccounts ?? []).map(
384 (account) => account.id,
385 ),
386 serviceNames: (desiredScope.awsServices ?? []).map(
387 (service) => service.serviceName,
388 ),
389 roles: news.roles.map((role) => ({
390 roleType: role.roleType,
391 roleArn: role.roleArn,
392 })),
393 });
394 // Audit Manager expands an accounts-only scope with the services it
395 // detects, so only compare services the user explicitly pinned.
396 const serviceDrifted =
397 news.scope?.awsServices !== undefined &&
398 JSON.stringify(observed.serviceNames) !==
399 JSON.stringify(desired.serviceNames);
400 const drifted =
401 observed.name !== desired.name ||
402 observed.description !== desired.description ||
403 observed.destinationType !== desired.destinationType ||
404 observed.destination !== desired.destination ||
405 JSON.stringify(observed.accountIds) !==
406 JSON.stringify(desired.accountIds) ||
407 JSON.stringify(observed.roles) !== JSON.stringify(desired.roles) ||
408 serviceDrifted;
409 const assessmentId = metadata?.id ?? output?.assessmentId ?? "";
410 if (drifted) {
411 const updated = yield* retryThroughIamPropagation(
412 auditmanager.updateAssessment({
413 assessmentId,
414 assessmentName: name,
415 assessmentDescription: news.description,
416 scope: desiredScope,
417 assessmentReportsDestination: desiredDestination,
418 roles: news.roles,
419 }),
420 );
421 assessment = updated.assessment ?? assessment;
422 yield* session.note(`Updated assessment ${name}`);
423 }
424
425 // Sync tags — diff against observed cloud tags.
426 const attrs = toAttributes(assessment);
427 const { removed, upsert } = diffTags(attrs.tags, desiredTags);
428 if (removed.length > 0) {
429 yield* auditmanager.untagResource({
430 resourceArn: attrs.arn,
431 tagKeys: removed,
432 });
433 }
434 if (upsert.length > 0) {
435 yield* auditmanager.tagResource({
436 resourceArn: attrs.arn,
437 tags: Object.fromEntries(
438 upsert.map(({ Key, Value }) => [Key, Value]),
439 ),
440 });
441 }
442
443 yield* session.note(attrs.arn);
444 return { ...attrs, tags: desiredTags };
445 }),
446 delete: Effect.fn(function* ({ output }) {
447 yield* auditmanager
448 .deleteAssessment({ assessmentId: output.assessmentId })
449 .pipe(
450 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
451 );
452 }),
453 };
454 }),
455 );