1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface SSHPublicKeyProps {
10 /**
11 * User that owns the SSH public key.
12 */
13 userName: string;
14 /**
15 * SSH public key body.
16 */
17 sshPublicKeyBody: string;
18 /**
19 * Desired key status.
20 * @default "Active"
21 */
22 status?: iam.StatusType;
23}
24
25export interface SSHPublicKey extends Resource<
26 "AWS.IAM.SSHPublicKey",
27 SSHPublicKeyProps,
28 {
29 /** The IAM user the SSH public key belongs to. */
30 userName: string;
31 /** The unique ID of the SSH public key. */
32 sshPublicKeyId: string;
33 /** The MD5 fingerprint of the SSH public key. */
34 fingerprint: string;
35 /** The SSH public key material. */
36 sshPublicKeyBody: string;
37 /** Whether the key is `Active` or `Inactive`. */
38 status: iam.StatusType;
39 /** When the key was uploaded. */
40 uploadDate: Date | undefined;
41 },
42 never,
43 Providers
44> {}
45
46/**
47 * An IAM SSH public key for CodeCommit-compatible workflows.
48 *
49 * `SSHPublicKey` uploads and manages a user's public key for services such as
50 * AWS CodeCommit that authenticate through IAM-backed SSH credentials.
51 * ### Managing SSH Keys
52 * **Example:** Upload an SSH Public Key
53 * ```typescript
54 * const user = yield* User("GitUser", {
55 * userName: "codecommit-user",
56 * });
57 *
58 * const key = yield* SSHPublicKey("GitKey", {
59 * userName: user.userName,
60 * sshPublicKeyBody: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample codecommit-user",
61 * });
62 * ```
63 *
64 * @resource
65 */
66export const SSHPublicKey = Resource<SSHPublicKey>("AWS.IAM.SSHPublicKey");
67
68export const SSHPublicKeyProvider = () =>
69 Provider.succeed(SSHPublicKey, {
70 stables: ["sshPublicKeyId"],
71 diff: Effect.fn(function* ({ olds, news }) {
72 if (!isResolved(news)) return;
73 if (
74 olds.userName !== news.userName ||
75 olds.sshPublicKeyBody !== news.sshPublicKeyBody
76 ) {
77 return { action: "replace" } as const;
78 }
79 }),
80 read: Effect.fn(function* ({ output }) {
81 if (!output) {
82 return undefined;
83 }
84 const response = yield* iam
85 .getSSHPublicKey({
86 UserName: output.userName,
87 SSHPublicKeyId: output.sshPublicKeyId,
88 Encoding: "SSH",
89 })
90 .pipe(
91 Effect.catchTag("NoSuchEntityException", () =>
92 Effect.succeed(undefined),
93 ),
94 );
95 if (!response?.SSHPublicKey?.SSHPublicKeyId) {
96 return undefined;
97 }
98 return {
99 userName: response.SSHPublicKey.UserName,
100 sshPublicKeyId: response.SSHPublicKey.SSHPublicKeyId,
101 fingerprint: response.SSHPublicKey.Fingerprint,
102 sshPublicKeyBody: response.SSHPublicKey.SSHPublicKeyBody,
103 status: response.SSHPublicKey.Status,
104 uploadDate: response.SSHPublicKey.UploadDate,
105 };
106 }),
107 reconcile: Effect.fn(function* ({ news, output, session }) {
108 // Observe — SSH key ids are AWS-generated; we can only locate the
109 // existing key when we have its id from a prior output. The body
110 // and identity are immutable (`diff` triggers replacement on body
111 // change), so a missing key always means we need to upload.
112 const observed = output
113 ? yield* iam
114 .getSSHPublicKey({
115 UserName: output.userName,
116 SSHPublicKeyId: output.sshPublicKeyId,
117 Encoding: "SSH",
118 })
119 .pipe(
120 Effect.map((r) => r.SSHPublicKey),
121 Effect.catchTag("NoSuchEntityException", () =>
122 Effect.succeed(undefined),
123 ),
124 )
125 : undefined;
126
127 // Ensure — upload when missing.
128 let key = observed;
129 if (!key?.SSHPublicKeyId) {
130 const uploaded = yield* iam.uploadSSHPublicKey({
131 UserName: news.userName,
132 SSHPublicKeyBody: news.sshPublicKeyBody,
133 });
134 if (!uploaded.SSHPublicKey?.SSHPublicKeyId) {
135 return yield* Effect.fail(
136 new Error(`uploadSSHPublicKey returned no key id`),
137 );
138 }
139 key = uploaded.SSHPublicKey;
140 }
141
142 // Sync — apply the desired status when it differs from observed.
143 const desiredStatus = news.status ?? key.Status;
144 if (desiredStatus !== key.Status) {
145 yield* iam.updateSSHPublicKey({
146 UserName: news.userName,
147 SSHPublicKeyId: key.SSHPublicKeyId!,
148 Status: desiredStatus,
149 });
150 }
151
152 yield* session.note(key.SSHPublicKeyId!);
153 return {
154 userName: key.UserName,
155 sshPublicKeyId: key.SSHPublicKeyId!,
156 fingerprint: key.Fingerprint,
157 sshPublicKeyBody: key.SSHPublicKeyBody,
158 status: desiredStatus,
159 uploadDate: key.UploadDate,
160 };
161 }),
162 delete: Effect.fn(function* ({ output }) {
163 yield* iam
164 .deleteSSHPublicKey({
165 UserName: output.userName,
166 SSHPublicKeyId: output.sshPublicKeyId,
167 })
168 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
169 }),
170 // IAM is a global service. `listSSHPublicKeys` requires a `UserName`, so we
171 // enumerate every IAM user first (paginated) and then list each user's keys
172 // (also paginated) with bounded concurrency. The list metadata omits the
173 // fingerprint and key body, so each key is hydrated with `getSSHPublicKey`
174 // to produce the full `Attributes` shape that `read` returns.
175 list: Effect.fn(function* () {
176 const users = yield* iam.listUsers.pages({}).pipe(
177 Stream.runCollect,
178 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Users)),
179 );
180 const perUser = yield* Effect.forEach(
181 users,
182 (user) =>
183 iam.listSSHPublicKeys.pages({ UserName: user.UserName }).pipe(
184 Stream.runCollect,
185 Effect.map((chunk) =>
186 Array.from(chunk).flatMap((page) => page.SSHPublicKeys ?? []),
187 ),
188 Effect.flatMap((metas) =>
189 Effect.forEach(
190 metas,
191 (meta) =>
192 iam
193 .getSSHPublicKey({
194 UserName: meta.UserName,
195 SSHPublicKeyId: meta.SSHPublicKeyId,
196 Encoding: "SSH",
197 })
198 .pipe(
199 Effect.map((r) => r.SSHPublicKey),
200 // The key may be deleted between enumeration and
201 // hydration.
202 Effect.catchTag("NoSuchEntityException", () =>
203 Effect.succeed(undefined),
204 ),
205 ),
206 { concurrency: 10 },
207 ),
208 ),
209 Effect.map((keys) =>
210 keys
211 .filter(
212 (key): key is iam.SSHPublicKey => key?.SSHPublicKeyId != null,
213 )
214 .map((key) => ({
215 userName: key.UserName,
216 sshPublicKeyId: key.SSHPublicKeyId,
217 fingerprint: key.Fingerprint,
218 sshPublicKeyBody: key.SSHPublicKeyBody,
219 status: key.Status,
220 uploadDate: key.UploadDate,
221 })),
222 ),
223 // The user may be deleted between enumeration and per-user list.
224 Effect.catchTag("NoSuchEntityException", () => Effect.succeed([])),
225 ),
226 { concurrency: 10 },
227 );
228 return perUser.flat();
229 }),
230 });