1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { toRedactedString } from "./common.ts";
10
11export interface AccessKeyProps {
12 /**
13 * User that owns the access key.
14 */
15 userName: string;
16 /**
17 * Desired access key status.
18 * @default "Active"
19 */
20 status?: iam.StatusType;
21}
22
23export interface AccessKey extends Resource<
24 "AWS.IAM.AccessKey",
25 AccessKeyProps,
26 {
27 /** The IAM user the access key belongs to. */
28 userName: string;
29 /** The access key ID. */
30 accessKeyId: string;
31 /** Whether the key is `Active` or `Inactive`. */
32 status: iam.StatusType;
33 /** When the access key was created. */
34 createDate: Date | undefined;
35 /** The secret access key. AWS only returns it at creation; later reads preserve the originally stored redacted value. */
36 secretAccessKey: Redacted.Redacted<string> | undefined;
37 /** When the access key was last used, if ever. */
38 lastUsedDate: Date | undefined;
39 /** The AWS service the key last authenticated to. */
40 lastUsedServiceName: string | undefined;
41 /** The region of the key's last use. */
42 lastUsedRegion: string | undefined;
43 },
44 never,
45 Providers
46> {}
47
48/**
49 * An IAM access key for a user.
50 *
51 * `AccessKey` manages long-lived programmatic credentials for an IAM user. The
52 * secret access key is only returned during creation, so later reads preserve
53 * the originally stored redacted value instead of pretending AWS can return it again.
54 * ### Managing Programmatic Credentials
55 * **Example:** Create an Access Key
56 * ```typescript
57 * const user = yield* User("DeployUser", {
58 * userName: "deploy-user",
59 * });
60 *
61 * const key = yield* AccessKey("DeployUserKey", {
62 * userName: user.userName,
63 * status: "Active",
64 * });
65 * ```
66 *
67 * @resource
68 */
69export const AccessKey = Resource<AccessKey>("AWS.IAM.AccessKey");
70
71export const AccessKeyProvider = () =>
72 Provider.succeed(AccessKey, {
73 stables: ["accessKeyId"],
74 diff: Effect.fn(function* ({ olds, news }) {
75 if (!isResolved(news)) return;
76 if (olds.userName !== news.userName) {
77 return { action: "replace" } as const;
78 }
79 }),
80 read: Effect.fn(function* ({ output }) {
81 if (!output) {
82 return undefined;
83 }
84 const listed = yield* iam.listAccessKeys({
85 UserName: output.userName,
86 });
87 const metadata = listed.AccessKeyMetadata.find(
88 (entry) => entry.AccessKeyId === output.accessKeyId,
89 );
90 if (!metadata?.AccessKeyId) {
91 return undefined;
92 }
93 const lastUsed = yield* iam.getAccessKeyLastUsed({
94 AccessKeyId: output.accessKeyId,
95 });
96 return {
97 userName: metadata.UserName ?? output.userName,
98 accessKeyId: metadata.AccessKeyId,
99 status: metadata.Status ?? output.status,
100 createDate: metadata.CreateDate,
101 secretAccessKey: output.secretAccessKey,
102 lastUsedDate: lastUsed?.AccessKeyLastUsed?.LastUsedDate,
103 lastUsedServiceName: lastUsed?.AccessKeyLastUsed?.ServiceName,
104 lastUsedRegion: lastUsed?.AccessKeyLastUsed?.Region,
105 };
106 }),
107 reconcile: Effect.fn(function* ({ news, output, session }) {
108 // Observe — `accessKeyId` is generated by AWS, so we can only locate
109 // an existing key when we already have its id from a prior output.
110 // Without it (first reconciliation), we go straight to create.
111 const existing = output
112 ? yield* iam.listAccessKeys({ UserName: output.userName }).pipe(
113 Effect.map((listed) =>
114 listed.AccessKeyMetadata.find(
115 (entry) => entry.AccessKeyId === output.accessKeyId,
116 ),
117 ),
118 Effect.catchTag("NoSuchEntityException", () =>
119 Effect.succeed(undefined),
120 ),
121 )
122 : undefined;
123
124 // Ensure — create the access key if it does not exist on the cloud.
125 // The secret is only returned at creation time, so on adoption the
126 // best we can do is preserve any redacted value already stored.
127 let accessKeyId = existing?.AccessKeyId ?? output?.accessKeyId;
128 let secretAccessKey = output?.secretAccessKey;
129 let createDate: Date | undefined =
130 existing?.CreateDate ?? output?.createDate;
131
132 if (!existing) {
133 const created = yield* iam.createAccessKey({
134 UserName: news.userName,
135 });
136 accessKeyId = created.AccessKey.AccessKeyId;
137 secretAccessKey = toRedactedString(created.AccessKey.SecretAccessKey);
138 createDate = created.AccessKey.CreateDate;
139 }
140
141 if (!accessKeyId) {
142 return yield* Effect.fail(
143 new Error(`AccessKey for user '${news.userName}' has no id`),
144 );
145 }
146
147 // Sync — apply the desired status when it differs from the observed
148 // value. Default of `Active` matches AWS's behaviour.
149 const observedStatus = existing?.Status ?? output?.status ?? "Active";
150 const desiredStatus = news.status ?? observedStatus;
151 if (desiredStatus !== observedStatus) {
152 yield* iam.updateAccessKey({
153 UserName: news.userName,
154 AccessKeyId: accessKeyId,
155 Status: desiredStatus,
156 });
157 }
158
159 const lastUsed = yield* iam.getAccessKeyLastUsed({
160 AccessKeyId: accessKeyId,
161 });
162
163 yield* session.note(accessKeyId);
164 return {
165 userName: existing?.UserName ?? news.userName,
166 accessKeyId,
167 status: desiredStatus,
168 createDate,
169 secretAccessKey,
170 lastUsedDate: lastUsed?.AccessKeyLastUsed?.LastUsedDate,
171 lastUsedServiceName: lastUsed?.AccessKeyLastUsed?.ServiceName,
172 lastUsedRegion: lastUsed?.AccessKeyLastUsed?.Region,
173 };
174 }),
175 delete: Effect.fn(function* ({ output }) {
176 yield* iam
177 .deleteAccessKey({
178 UserName: output.userName,
179 AccessKeyId: output.accessKeyId,
180 })
181 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
182 }),
183 // IAM is a global service. `listAccessKeys` requires a `UserName`, so we
184 // enumerate every IAM user first (paginated) and then list the keys for
185 // each user (also paginated) with bounded concurrency. The secret access
186 // key and last-used details are not part of the list metadata — the secret
187 // is only returned at creation and cannot be re-read — so those fields are
188 // left undefined here.
189 list: Effect.fn(function* () {
190 const users = yield* iam.listUsers.pages({}).pipe(
191 Stream.runCollect,
192 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Users)),
193 );
194 const perUser = yield* Effect.forEach(
195 users,
196 (user) =>
197 iam.listAccessKeys.pages({ UserName: user.UserName }).pipe(
198 Stream.runCollect,
199 Effect.map((chunk) =>
200 Array.from(chunk).flatMap((page) =>
201 page.AccessKeyMetadata.filter(
202 (
203 entry,
204 ): entry is iam.AccessKeyMetadata & {
205 AccessKeyId: string;
206 } => entry.AccessKeyId != null,
207 ).map((entry) => ({
208 userName: entry.UserName ?? user.UserName,
209 accessKeyId: entry.AccessKeyId,
210 status: entry.Status ?? "Active",
211 createDate: entry.CreateDate,
212 secretAccessKey: undefined,
213 lastUsedDate: undefined,
214 lastUsedServiceName: undefined,
215 lastUsedRegion: undefined,
216 })),
217 ),
218 ),
219 // The user may be deleted between enumeration and per-user list.
220 Effect.catchTag("NoSuchEntityException", () => Effect.succeed([])),
221 ),
222 { concurrency: 10 },
223 );
224 return perUser.flat();
225 }),
226 });