1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
12import { toRedactedBytes, toTagRecord } from "./common.ts";
13
14export interface VirtualMFADeviceProps {
15 /**
16 * Name of the virtual MFA device. If omitted, a deterministic name is generated.
17 */
18 virtualMFADeviceName?: string;
19 /**
20 * Optional IAM path prefix.
21 * @default "/"
22 */
23 path?: string;
24 /**
25 * Optional user to activate the device for.
26 */
27 userName?: string;
28 /**
29 * First authentication code used when activating the device.
30 */
31 authenticationCode1?: string;
32 /**
33 * Second authentication code used when activating the device.
34 */
35 authenticationCode2?: string;
36 /**
37 * User-defined tags.
38 */
39 tags?: Record<string, string>;
40}
41
42export interface VirtualMFADevice extends Resource<
43 "AWS.IAM.VirtualMFADevice",
44 VirtualMFADeviceProps,
45 {
46 /** The serial number (ARN) of the virtual MFA device. */
47 serialNumber: string;
48 /** The user the device is enabled for, if activated. */
49 userName: string | undefined;
50 /** When the device was activated, if activated. */
51 enableDate: Date | undefined;
52 /** The base32 seed for configuring an authenticator app. AWS only returns it at creation. */
53 base32StringSeed:
54 | Redacted.Redacted<Uint8Array<ArrayBufferLike>>
55 | undefined;
56 /** A QR-code PNG encoding the seed. AWS only returns it at creation. */
57 qrCodePNG: Redacted.Redacted<Uint8Array<ArrayBufferLike>> | undefined;
58 /** The tags applied to the device. */
59 tags: Record<string, string>;
60 },
61 never,
62 Providers
63> {}
64
65/**
66 * An IAM virtual MFA device.
67 *
68 * `VirtualMFADevice` creates a software MFA device and can optionally activate
69 * it for a user during creation when the initial authentication codes are
70 * provided.
71 * ### Managing MFA Devices
72 * **Example:** Create and Activate a Virtual MFA Device
73 * ```typescript
74 * const user = yield* User("AdminUser", {
75 * userName: "admin-user",
76 * });
77 *
78 * const device = yield* VirtualMFADevice("AdminMfa", {
79 * userName: user.userName,
80 * authenticationCode1: "123456",
81 * authenticationCode2: "654321",
82 * });
83 * ```
84 *
85 * @resource
86 */
87export const VirtualMFADevice = Resource<VirtualMFADevice>(
88 "AWS.IAM.VirtualMFADevice",
89);
90
91export const VirtualMFADeviceProvider = () =>
92 Provider.effect(
93 VirtualMFADevice,
94 Effect.gen(function* () {
95 const toName = (id: string, props: VirtualMFADeviceProps) =>
96 props.virtualMFADeviceName
97 ? Effect.succeed(props.virtualMFADeviceName)
98 : createPhysicalName({ id, maxLength: 226 });
99
100 const readDevice = Effect.fn(function* ({
101 serialNumber,
102 userName,
103 }: {
104 serialNumber: string;
105 userName: string | undefined;
106 }) {
107 if (!userName) {
108 const device = yield* iam.listVirtualMFADevices
109 .items({ AssignmentStatus: "Unassigned" })
110 .pipe(
111 Stream.filter((entry) => entry.SerialNumber === serialNumber),
112 Stream.runHead,
113 Effect.map(Option.getOrUndefined),
114 );
115 return device
116 ? {
117 SerialNumber: device.SerialNumber,
118 UserName: device.User?.UserName,
119 EnableDate: device.EnableDate,
120 }
121 : undefined;
122 }
123 return yield* iam
124 .getMFADevice({
125 SerialNumber: serialNumber,
126 UserName: userName,
127 })
128 .pipe(
129 Effect.catchTag("NoSuchEntityException", () =>
130 Effect.succeed(undefined),
131 ),
132 );
133 });
134
135 return {
136 stables: ["serialNumber"],
137 list: () =>
138 Effect.gen(function* () {
139 // Enumerate every virtual MFA device in the account. The list op
140 // does not return tags, so hydrate them per-device via
141 // listMFADeviceTags (matching the exact shape `read` returns).
142 const devices = yield* iam.listVirtualMFADevices.pages({}).pipe(
143 Stream.runCollect,
144 Effect.map((chunk) =>
145 Array.from(chunk).flatMap(
146 (page) => page.VirtualMFADevices ?? [],
147 ),
148 ),
149 );
150 return yield* Effect.forEach(
151 devices,
152 (device) =>
153 Effect.gen(function* () {
154 const tags = yield* iam
155 .listMFADeviceTags({
156 SerialNumber: device.SerialNumber,
157 })
158 .pipe(
159 Effect.map((resp) => resp.Tags),
160 // The device may vanish between listing and tag lookup.
161 Effect.catchTag("NoSuchEntityException", () =>
162 Effect.succeed<iam.Tag[]>([]),
163 ),
164 );
165 return {
166 serialNumber: device.SerialNumber,
167 userName: device.User?.UserName,
168 enableDate: device.EnableDate,
169 // The seed and QR code are only returned at creation time
170 // and are never available from enumeration.
171 base32StringSeed: undefined,
172 qrCodePNG: undefined,
173 tags: toTagRecord(tags),
174 };
175 }),
176 { concurrency: 10 },
177 );
178 }),
179 diff: Effect.fn(function* ({ id, olds, news }) {
180 if (!isResolved(news)) return;
181 if (
182 (yield* toName(id, olds ?? ({} as VirtualMFADeviceProps))) !==
183 (yield* toName(id, news)) ||
184 (olds.path ?? "/") !== (news.path ?? "/") ||
185 (olds.userName ?? undefined) !== (news.userName ?? undefined) ||
186 (olds.authenticationCode1 ?? undefined) !==
187 (news.authenticationCode1 ?? undefined) ||
188 (olds.authenticationCode2 ?? undefined) !==
189 (news.authenticationCode2 ?? undefined)
190 ) {
191 return { action: "replace" } as const;
192 }
193 }),
194 read: Effect.fn(function* ({ output }) {
195 if (!output) {
196 return undefined;
197 }
198 const response = yield* readDevice({
199 serialNumber: output.serialNumber,
200 userName: output.userName,
201 });
202 if (!response?.SerialNumber) {
203 return undefined;
204 }
205 const tags = yield* iam.listMFADeviceTags({
206 SerialNumber: output.serialNumber,
207 });
208 return {
209 serialNumber: response.SerialNumber,
210 userName: response.UserName ?? output.userName,
211 enableDate: response.EnableDate,
212 base32StringSeed: output.base32StringSeed,
213 qrCodePNG: output.qrCodePNG,
214 tags: toTagRecord(tags.Tags),
215 };
216 }),
217 reconcile: Effect.fn(function* ({ id, news, output, session }) {
218 const desiredTags = {
219 ...(yield* createInternalTags(id)),
220 ...news.tags,
221 };
222
223 // Observe — virtual MFA devices have AWS-generated serial
224 // numbers, so we can only locate the existing device when we
225 // already have its serial from a prior output.
226 const observed = output
227 ? yield* readDevice({
228 serialNumber: output.serialNumber,
229 userName: output.userName,
230 })
231 : undefined;
232
233 // Ensure — create the device when missing. The seed and QR
234 // code are only returned on creation, so on adoption the best
235 // we can do is preserve the existing redacted values.
236 let serialNumber = observed?.SerialNumber ?? output?.serialNumber;
237 let base32StringSeed = output?.base32StringSeed;
238 let qrCodePNG = output?.qrCodePNG;
239
240 if (!observed?.SerialNumber) {
241 const deviceName = yield* toName(id, news);
242 const created = yield* iam.createVirtualMFADevice({
243 Path: news.path,
244 VirtualMFADeviceName: deviceName,
245 Tags: createTagsList(desiredTags),
246 });
247 if (!created.VirtualMFADevice.SerialNumber) {
248 return yield* Effect.fail(
249 new Error(`createVirtualMFADevice returned no serial number`),
250 );
251 }
252 serialNumber = created.VirtualMFADevice.SerialNumber;
253 base32StringSeed = toRedactedBytes(
254 created.VirtualMFADevice.Base32StringSeed,
255 );
256 qrCodePNG = toRedactedBytes(created.VirtualMFADevice.QRCodePNG);
257
258 // The device was just created, so it is unassigned. Activate
259 // it for the user when activation codes are provided. After
260 // first activation the codes lose their meaning (`diff`
261 // triggers replacement on code change).
262 if (
263 news.userName &&
264 news.authenticationCode1 &&
265 news.authenticationCode2
266 ) {
267 yield* iam.enableMFADevice({
268 UserName: news.userName,
269 SerialNumber: serialNumber,
270 AuthenticationCode1: news.authenticationCode1,
271 AuthenticationCode2: news.authenticationCode2,
272 });
273 }
274 }
275
276 if (!serialNumber) {
277 return yield* Effect.fail(
278 new Error(`Virtual MFA device has no serial number`),
279 );
280 }
281
282 // Sync tags against the cloud's actual tags.
283 const observedTagsResp = yield* iam.listMFADeviceTags({
284 SerialNumber: serialNumber,
285 });
286 const observedTags = toTagRecord(observedTagsResp.Tags);
287 const { removed, upsert } = diffTags(observedTags, desiredTags);
288 if (upsert.length > 0) {
289 yield* iam.tagMFADevice({
290 SerialNumber: serialNumber,
291 Tags: upsert,
292 });
293 }
294 if (removed.length > 0) {
295 yield* iam.untagMFADevice({
296 SerialNumber: serialNumber,
297 TagKeys: removed,
298 });
299 }
300
301 // Re-read for fresh `EnableDate` / `UserName` after activation.
302 const fresh = yield* readDevice({
303 serialNumber,
304 userName: news.userName ?? output?.userName,
305 });
306
307 yield* session.note(serialNumber);
308 return {
309 serialNumber,
310 userName: fresh?.UserName ?? news.userName ?? output?.userName,
311 enableDate: fresh?.EnableDate ?? output?.enableDate,
312 base32StringSeed,
313 qrCodePNG,
314 tags: desiredTags,
315 };
316 }),
317 delete: Effect.fn(function* ({ output }) {
318 if (output.userName) {
319 yield* iam
320 .deactivateMFADevice({
321 UserName: output.userName,
322 SerialNumber: output.serialNumber,
323 })
324 .pipe(
325 Effect.catchTag("NoSuchEntityException", () => Effect.void),
326 );
327 }
328 yield* iam
329 .deleteVirtualMFADevice({
330 SerialNumber: output.serialNumber,
331 })
332 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
333 }),
334 };
335 }),
336 );