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 SigningCertificateProps {
10 /**
11 * User that owns the signing certificate.
12 */
13 userName: string;
14 /**
15 * X.509 signing certificate body.
16 */
17 certificateBody: string;
18 /**
19 * Desired certificate status.
20 * @default "Active"
21 */
22 status?: iam.StatusType;
23}
24
25export interface SigningCertificate extends Resource<
26 "AWS.IAM.SigningCertificate",
27 SigningCertificateProps,
28 {
29 /** The IAM user the signing certificate belongs to. */
30 userName: string;
31 /** The unique ID of the signing certificate. */
32 certificateId: string;
33 /** The PEM-encoded certificate body. */
34 certificateBody: string;
35 /** Whether the certificate is `Active` or `Inactive`. */
36 status: iam.StatusType;
37 /** When the certificate was uploaded. */
38 uploadDate: Date | undefined;
39 },
40 never,
41 Providers
42> {}
43
44/**
45 * An IAM signing certificate for a user.
46 *
47 * `SigningCertificate` uploads an X.509 signing certificate for legacy
48 * IAM-integrated workflows that still depend on user-scoped certificates.
49 * ### Managing User Certificates
50 * **Example:** Upload a Signing Certificate
51 * ```typescript
52 * const user = yield* User("Signer", {
53 * userName: "build-signer",
54 * });
55 *
56 * const certificate = yield* SigningCertificate("SigningCertificate", {
57 * userName: user.userName,
58 * certificateBody: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
59 * });
60 * ```
61 *
62 * @resource
63 */
64export const SigningCertificate = Resource<SigningCertificate>(
65 "AWS.IAM.SigningCertificate",
66);
67
68export const SigningCertificateProvider = () =>
69 Provider.succeed(SigningCertificate, {
70 stables: ["certificateId"],
71 // IAM is a global service. `listSigningCertificates` requires a `UserName`,
72 // so we enumerate every IAM user first (paginated) and then list each
73 // user's signing certificates (also paginated) with bounded concurrency.
74 // The list response carries the full certificate body, so each entry maps
75 // directly to the `Attributes` shape `read` returns without further
76 // hydration.
77 list: Effect.fn(function* () {
78 const users = yield* iam.listUsers.pages({}).pipe(
79 Stream.runCollect,
80 Effect.map((chunk) =>
81 Array.from(chunk).flatMap((page) => page.Users ?? []),
82 ),
83 );
84 const perUser = yield* Effect.forEach(
85 users,
86 (user) =>
87 iam.listSigningCertificates.pages({ UserName: user.UserName }).pipe(
88 Stream.runCollect,
89 Effect.map((chunk) =>
90 Array.from(chunk)
91 .flatMap((page) => page.Certificates ?? [])
92 .map((cert) => ({
93 userName: cert.UserName,
94 certificateId: cert.CertificateId,
95 certificateBody: cert.CertificateBody,
96 status: cert.Status,
97 uploadDate: cert.UploadDate,
98 })),
99 ),
100 // The user may be deleted between enumeration and per-user list.
101 Effect.catchTag("NoSuchEntityException", () => Effect.succeed([])),
102 ),
103 { concurrency: 10 },
104 );
105 return perUser.flat();
106 }),
107 diff: Effect.fn(function* ({ olds, news }) {
108 if (!isResolved(news)) return;
109 if (
110 olds.userName !== news.userName ||
111 olds.certificateBody !== news.certificateBody
112 ) {
113 return { action: "replace" } as const;
114 }
115 }),
116 read: Effect.fn(function* ({ output }) {
117 if (!output) {
118 return undefined;
119 }
120 const listed = yield* iam.listSigningCertificates({
121 UserName: output.userName,
122 });
123 const cert = listed.Certificates.find(
124 (entry) => entry.CertificateId === output.certificateId,
125 );
126 if (!cert?.CertificateId) {
127 return undefined;
128 }
129 return {
130 userName: cert.UserName,
131 certificateId: cert.CertificateId,
132 certificateBody: cert.CertificateBody,
133 status: cert.Status,
134 uploadDate: cert.UploadDate,
135 };
136 }),
137 reconcile: Effect.fn(function* ({ news, output, session }) {
138 // Observe — certificate ids are AWS-generated; we can only locate
139 // the live entry via the prior output. The certificate body is
140 // immutable (`diff` triggers replacement on body change), so a
141 // missing entry always means we need to upload.
142 const observed = output
143 ? yield* iam
144 .listSigningCertificates({ UserName: output.userName })
145 .pipe(
146 Effect.map((r) =>
147 r.Certificates.find(
148 (entry) => entry.CertificateId === output.certificateId,
149 ),
150 ),
151 Effect.catchTag("NoSuchEntityException", () =>
152 Effect.succeed(undefined),
153 ),
154 )
155 : undefined;
156
157 // Ensure — upload when missing.
158 let cert = observed;
159 if (!cert?.CertificateId) {
160 const uploaded = yield* iam.uploadSigningCertificate({
161 UserName: news.userName,
162 CertificateBody: news.certificateBody,
163 });
164 cert = uploaded.Certificate;
165 }
166
167 // Sync — apply the desired status when it differs.
168 const desiredStatus = news.status ?? cert.Status;
169 if (desiredStatus !== cert.Status) {
170 yield* iam.updateSigningCertificate({
171 UserName: news.userName,
172 CertificateId: cert.CertificateId,
173 Status: desiredStatus,
174 });
175 }
176
177 yield* session.note(cert.CertificateId);
178 return {
179 userName: cert.UserName,
180 certificateId: cert.CertificateId,
181 certificateBody: cert.CertificateBody,
182 status: desiredStatus,
183 uploadDate: cert.UploadDate,
184 };
185 }),
186 delete: Effect.fn(function* ({ output }) {
187 yield* iam
188 .deleteSigningCertificate({
189 UserName: output.userName,
190 CertificateId: output.certificateId,
191 })
192 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
193 }),
194 });