1import * as iam from "@distilled.cloud/aws/iam";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { toWireDays } from "../../Util/Duration.ts";
11import { toRedactedString } from "./common.ts";
12
13export interface ServiceSpecificCredentialProps {
14 /**
15 * User that owns the service-specific credential.
16 */
17 userName: string;
18 /**
19 * AWS service name that will consume the credential.
20 */
21 serviceName: string;
22 /**
23 * Optional credential validity duration, e.g. `"30 days"` or
24 * `Duration.days(30)`. Sent to IAM as whole days (a bare number is
25 * milliseconds). Changing it replaces the credential.
26 */
27 credentialAge?: Duration.Input;
28 /**
29 * Desired credential status.
30 * @default "Active"
31 */
32 status?: iam.StatusType;
33}
34
35export interface ServiceSpecificCredential extends Resource<
36 "AWS.IAM.ServiceSpecificCredential",
37 ServiceSpecificCredentialProps,
38 {
39 /** The IAM user the credential belongs to. */
40 userName: string;
41 /** The AWS service the credential is scoped to (e.g. `codecommit.amazonaws.com`). */
42 serviceName: string;
43 /** The unique ID of the credential. */
44 serviceSpecificCredentialId: string;
45 /** Whether the credential is `Active` or `Inactive`. */
46 status: iam.StatusType;
47 /** When the credential was created. */
48 createDate: Date | undefined;
49 /** When the credential expires, if an age was configured. */
50 expirationDate: Date | undefined;
51 /** The generated service-specific user name. */
52 serviceUserName: string | undefined;
53 /** The generated credential alias, if the service issues one. */
54 serviceCredentialAlias: string | undefined;
55 /** The generated password. AWS only returns it at creation; later reads preserve the originally stored redacted value. */
56 servicePassword: Redacted.Redacted<string> | undefined;
57 /** The generated secret. AWS only returns it at creation; later reads preserve the originally stored redacted value. */
58 serviceCredentialSecret: Redacted.Redacted<string> | undefined;
59 },
60 never,
61 Providers
62> {}
63
64/**
65 * A service-specific IAM credential.
66 *
67 * `ServiceSpecificCredential` creates service-bound credentials such as
68 * CodeCommit HTTPS passwords for an IAM user. AWS only returns the secret
69 * fields during creation, so subsequent reads preserve the originally stored
70 * redacted values.
71 * ### Managing Service Credentials
72 * **Example:** Create a CodeCommit Credential
73 * ```typescript
74 * const user = yield* User("CodeCommitUser", {
75 * userName: "codecommit-user",
76 * });
77 *
78 * const credential = yield* ServiceSpecificCredential("CodeCommitCredential", {
79 * userName: user.userName,
80 * serviceName: "codecommit.amazonaws.com",
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const ServiceSpecificCredential = Resource<ServiceSpecificCredential>(
87 "AWS.IAM.ServiceSpecificCredential",
88);
89
90export const ServiceSpecificCredentialProvider = () =>
91 Provider.succeed(ServiceSpecificCredential, {
92 stables: ["serviceSpecificCredentialId"],
93 list: Effect.fn(function* () {
94 // Service-specific credentials are owned per IAM user and IAM is a
95 // global service, so enumerate every user in the account
96 // (`listUsers`) and fan out `listServiceSpecificCredentials` per user
97 // (bounded concurrency). Omit `ServiceName` to capture credentials
98 // across all services. The credential secret is only returned at
99 // creation, so list cannot recover it — match `read` and leave the
100 // redacted secret fields undefined.
101 const users = yield* iam.listUsers.pages({}).pipe(
102 Stream.runCollect,
103 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Users)),
104 );
105 const perUser = yield* Effect.forEach(
106 users,
107 (user) =>
108 iam.listServiceSpecificCredentials({ UserName: user.UserName }).pipe(
109 Effect.map((response) =>
110 (response.ServiceSpecificCredentials ?? []).map((metadata) => ({
111 userName: metadata.UserName,
112 serviceName: metadata.ServiceName,
113 serviceSpecificCredentialId:
114 metadata.ServiceSpecificCredentialId,
115 status: metadata.Status,
116 createDate: metadata.CreateDate,
117 expirationDate: metadata.ExpirationDate,
118 serviceUserName: metadata.ServiceUserName,
119 serviceCredentialAlias: metadata.ServiceCredentialAlias,
120 servicePassword: undefined,
121 serviceCredentialSecret: undefined,
122 })),
123 ),
124 // The user may be deleted between enumeration and per-user list.
125 Effect.catchTag("NoSuchEntityException", () => Effect.succeed([])),
126 ),
127 { concurrency: 10 },
128 );
129 return perUser.flat();
130 }),
131 diff: Effect.fn(function* ({ olds, news }) {
132 if (!isResolved(news)) return;
133 if (
134 olds.userName !== news.userName ||
135 olds.serviceName !== news.serviceName ||
136 toWireDays(olds.credentialAge) !== toWireDays(news.credentialAge)
137 ) {
138 return { action: "replace" } as const;
139 }
140 }),
141 read: Effect.fn(function* ({ output }) {
142 if (!output) {
143 return undefined;
144 }
145 const listed = yield* iam.listServiceSpecificCredentials({
146 UserName: output.userName,
147 ServiceName: output.serviceName,
148 });
149 const metadata = listed.ServiceSpecificCredentials?.find(
150 (entry) =>
151 entry.ServiceSpecificCredentialId ===
152 output.serviceSpecificCredentialId,
153 );
154 if (!metadata?.ServiceSpecificCredentialId) {
155 return undefined;
156 }
157 return {
158 userName: metadata.UserName,
159 serviceName: metadata.ServiceName,
160 serviceSpecificCredentialId: metadata.ServiceSpecificCredentialId,
161 status: metadata.Status,
162 createDate: metadata.CreateDate,
163 expirationDate: metadata.ExpirationDate,
164 serviceUserName: metadata.ServiceUserName,
165 serviceCredentialAlias: metadata.ServiceCredentialAlias,
166 servicePassword: output.servicePassword,
167 serviceCredentialSecret: output.serviceCredentialSecret,
168 };
169 }),
170 reconcile: Effect.fn(function* ({ news, output, session }) {
171 // Observe — credential ids are AWS-generated; we can only locate
172 // the existing record via the prior output. The credential's
173 // identity (userName, serviceName, age) is immutable (`diff`
174 // triggers replacement on change), so a missing credential always
175 // requires recreation.
176 const observed = output
177 ? yield* iam
178 .listServiceSpecificCredentials({
179 UserName: output.userName,
180 ServiceName: output.serviceName,
181 })
182 .pipe(
183 Effect.map((r) =>
184 r.ServiceSpecificCredentials?.find(
185 (entry) =>
186 entry.ServiceSpecificCredentialId ===
187 output.serviceSpecificCredentialId,
188 ),
189 ),
190 Effect.catchTag("NoSuchEntityException", () =>
191 Effect.succeed(undefined),
192 ),
193 )
194 : undefined;
195
196 // Ensure — create when missing. The secret fields are only
197 // returned on first creation, so adoption preserves the prior
198 // redacted values.
199 let credentialId =
200 observed?.ServiceSpecificCredentialId ??
201 output?.serviceSpecificCredentialId;
202 let userName = observed?.UserName ?? output?.userName ?? news.userName;
203 let serviceName =
204 observed?.ServiceName ?? output?.serviceName ?? news.serviceName;
205 let createDate = observed?.CreateDate ?? output?.createDate;
206 let expirationDate = observed?.ExpirationDate ?? output?.expirationDate;
207 let serviceUserName =
208 observed?.ServiceUserName ?? output?.serviceUserName;
209 let serviceCredentialAlias =
210 observed?.ServiceCredentialAlias ?? output?.serviceCredentialAlias;
211 let servicePassword = output?.servicePassword;
212 let serviceCredentialSecret = output?.serviceCredentialSecret;
213 let observedStatus = observed?.Status ?? output?.status;
214
215 if (!observed) {
216 const created = yield* iam.createServiceSpecificCredential({
217 UserName: news.userName,
218 ServiceName: news.serviceName,
219 CredentialAgeDays: toWireDays(news.credentialAge),
220 });
221 const credential = created.ServiceSpecificCredential;
222 if (!credential?.ServiceSpecificCredentialId) {
223 return yield* Effect.fail(
224 new Error(
225 `createServiceSpecificCredential returned no credential id`,
226 ),
227 );
228 }
229 credentialId = credential.ServiceSpecificCredentialId;
230 userName = credential.UserName;
231 serviceName = credential.ServiceName;
232 createDate = credential.CreateDate;
233 expirationDate = credential.ExpirationDate;
234 serviceUserName = credential.ServiceUserName;
235 serviceCredentialAlias = credential.ServiceCredentialAlias;
236 servicePassword = toRedactedString(credential.ServicePassword);
237 serviceCredentialSecret = toRedactedString(
238 credential.ServiceCredentialSecret,
239 );
240 observedStatus = credential.Status;
241 }
242
243 if (!credentialId) {
244 return yield* Effect.fail(
245 new Error(
246 `ServiceSpecificCredential for user '${news.userName}' has no id`,
247 ),
248 );
249 }
250
251 // Sync — apply the desired status when it differs from observed.
252 const desiredStatus = news.status ?? observedStatus ?? "Active";
253 if (desiredStatus !== observedStatus) {
254 yield* iam.updateServiceSpecificCredential({
255 UserName: userName,
256 ServiceSpecificCredentialId: credentialId,
257 Status: desiredStatus,
258 });
259 }
260
261 yield* session.note(credentialId);
262 return {
263 userName,
264 serviceName,
265 serviceSpecificCredentialId: credentialId,
266 status: desiredStatus,
267 createDate,
268 expirationDate,
269 serviceUserName,
270 serviceCredentialAlias,
271 servicePassword,
272 serviceCredentialSecret,
273 };
274 }),
275 delete: Effect.fn(function* ({ output }) {
276 yield* iam
277 .deleteServiceSpecificCredential({
278 UserName: output.userName,
279 ServiceSpecificCredentialId: output.serviceSpecificCredentialId,
280 })
281 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
282 }),
283 });