1import * as iam from "@distilled.cloud/aws/iam";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
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";
10
11/**
12 * Deleting a service-linked role is asynchronous: `deleteServiceLinkedRole`
13 * returns a deletion task that can end in `FAILED` (usually because the
14 * linked service still has resources using the role). The failure reason —
15 * including the offending resource ARNs, when the service reports them —
16 * is surfaced on this error.
17 */
18export class ServiceLinkedRoleDeletionFailed extends Data.TaggedError(
19 "ServiceLinkedRoleDeletionFailed",
20)<{
21 readonly roleName: string;
22 readonly status: string;
23 readonly reason: string | undefined;
24}> {
25 override get message() {
26 return `IAM service-linked role ${this.roleName} deletion ${this.status}${this.reason ? `: ${this.reason}` : ""}`;
27 }
28}
29
30export interface ServiceLinkedRoleProps {
31 /**
32 * The service principal the role is linked to, e.g.
33 * `autoscaling.amazonaws.com` or `elasticbeanstalk.amazonaws.com`.
34 * The linked service owns the role's name, trust policy, and permissions.
35 */
36 awsServiceName: string;
37 /**
38 * Optional suffix appended to the AWS-generated role name
39 * (`AWSServiceRoleFor<Service>_<customSuffix>`). Only some services allow
40 * suffixes; services that don't reject the create with `InvalidInputException`.
41 * Use a suffix to create multiple service-linked roles for the same service.
42 */
43 customSuffix?: string;
44 /**
45 * Optional description for the role. The description is the only aspect of
46 * a service-linked role IAM allows editing after creation.
47 */
48 description?: string;
49}
50
51export interface ServiceLinkedRole extends Resource<
52 "AWS.IAM.ServiceLinkedRole",
53 ServiceLinkedRoleProps,
54 {
55 /** The name of the service-linked role. */
56 roleName: string;
57 /** The ARN of the service-linked role. */
58 roleArn: string;
59 /** The stable unique ID of the role. */
60 roleId: string | undefined;
61 /** The IAM path of the role. */
62 path: string | undefined;
63 /** The AWS service principal the role is linked to. */
64 awsServiceName: string;
65 /** The custom suffix appended to the role name, if any. */
66 customSuffix: string | undefined;
67 /** The description of the role. */
68 description: string | undefined;
69 },
70 never,
71 Providers
72> {}
73
74/**
75 * An IAM role linked to (and managed by) a specific AWS service.
76 *
77 * The linked service controls the role's trust and permissions policies; the
78 * only mutable aspect is the description. Deletion is asynchronous — the
79 * provider submits a deletion task and waits (bounded) for it to complete,
80 * failing with {@link ServiceLinkedRoleDeletionFailed} when the linked service
81 * still has resources using the role.
82 *
83 * Some services auto-create their service-linked role on first use; deploying
84 * this resource over an existing role adopts it (the create API reports the
85 * collision and the provider converges on the existing role).
86 * ### Creating Service-Linked Roles
87 * **Example:** Auto Scaling Service-Linked Role
88 * ```typescript
89 * const role = yield* ServiceLinkedRole("AutoScalingRole", {
90 * awsServiceName: "autoscaling.amazonaws.com",
91 * });
92 * ```
93 *
94 * **Example:** Suffixed Role for a Dedicated Workload
95 * ```typescript
96 * const role = yield* ServiceLinkedRole("WorkloadRole", {
97 * awsServiceName: "autoscaling.amazonaws.com",
98 * customSuffix: "analytics",
99 * description: "Auto Scaling role scoped to the analytics workload",
100 * });
101 * ```
102 *
103 * @resource
104 */
105export const ServiceLinkedRole = Resource<ServiceLinkedRole>(
106 "AWS.IAM.ServiceLinkedRole",
107);
108
109/**
110 * Derive the custom suffix back out of an `AWSServiceRoleFor..._suffix`
111 * role name. Base (unsuffixed) service-linked role names never contain an
112 * underscore, so everything after the first `_` is the suffix.
113 */
114const suffixFromRoleName = (roleName: string): string | undefined => {
115 const index = roleName.indexOf("_");
116 return index === -1 ? undefined : roleName.slice(index + 1);
117};
118
119const toAttributes = (
120 role: iam.Role,
121 awsServiceName: string,
122): ServiceLinkedRole["Attributes"] => ({
123 roleName: role.RoleName,
124 roleArn: role.Arn,
125 roleId: role.RoleId,
126 path: role.Path,
127 awsServiceName,
128 customSuffix: suffixFromRoleName(role.RoleName),
129 description: role.Description,
130});
131
132export const ServiceLinkedRoleProvider = () =>
133 Provider.effect(
134 ServiceLinkedRole,
135 Effect.gen(function* () {
136 // Service-linked roles live under a deterministic path
137 // (`/aws-service-role/{service}/`), so we can always find ours by
138 // listing that path and matching the (optional) custom suffix — the
139 // role NAME itself is generated by AWS and not derivable up front.
140 const findRole = Effect.fn(function* (props: {
141 awsServiceName: string;
142 customSuffix?: string;
143 }) {
144 const roles = yield* iam.listRoles
145 .pages({ PathPrefix: `/aws-service-role/${props.awsServiceName}/` })
146 .pipe(
147 Stream.runCollect,
148 Effect.map((chunk) =>
149 Array.from(chunk).flatMap((page) => page.Roles ?? []),
150 ),
151 );
152 return roles.find((role) =>
153 props.customSuffix
154 ? role.RoleName.endsWith(`_${props.customSuffix}`)
155 : !role.RoleName.includes("_"),
156 );
157 });
158
159 const getRole = Effect.fn(function* (roleName: string) {
160 const response = yield* iam
161 .getRole({ RoleName: roleName })
162 .pipe(
163 Effect.catchTag("NoSuchEntityException", () =>
164 Effect.succeed(undefined),
165 ),
166 );
167 return response?.Role;
168 });
169
170 const submitDeletion = Effect.fn(function* (roleName: string) {
171 return yield* iam.deleteServiceLinkedRole({ RoleName: roleName }).pipe(
172 Effect.retry({
173 while: (error) =>
174 error._tag === "LimitExceededException" ||
175 error._tag === "ServiceFailureException",
176 schedule: Schedule.max([
177 Schedule.exponential("500 millis"),
178 Schedule.recurs(6),
179 ]),
180 }),
181 Effect.catchTag("NoSuchEntityException", () =>
182 Effect.succeed(undefined),
183 ),
184 );
185 });
186
187 // Deletion is asynchronous. Poll both task status and the role itself on
188 // a bounded budget (30 × 2s = 60s); observable role absence, not merely
189 // task success/absence, is terminal. IAM can transiently mark a deletion
190 // task FAILED even though immediately resubmitting it succeeds. Treat a
191 // failed or expired task as a request to re-observe and resubmit within
192 // the same budget. If a dependency really remains, every task continues
193 // to fail and the final typed error retains the resource in state.
194 const deleteAndWait = Effect.fn(function* (roleName: string) {
195 let deletionTaskId: string | undefined;
196 let lastStatus = "NOT_SUBMITTED";
197 let lastReason: string | undefined;
198 for (let attempt = 0; attempt < 30; attempt++) {
199 // The role's observable absence is the terminal condition. A newly
200 // submitted deletion task can briefly return NoSuchEntity before
201 // its status record propagates, so task absence alone is not proof.
202 const role = yield* getRole(roleName);
203 if (!role) {
204 return;
205 }
206
207 if (deletionTaskId === undefined) {
208 const submitted = yield* submitDeletion(roleName);
209 deletionTaskId = submitted?.DeletionTaskId;
210 lastStatus = deletionTaskId ? "SUBMITTED" : "NOT_FOUND";
211 }
212
213 if (deletionTaskId !== undefined) {
214 const status = yield* iam
215 .getServiceLinkedRoleDeletionStatus({
216 DeletionTaskId: deletionTaskId,
217 })
218 .pipe(
219 // Both the task record and IAM itself are eventually
220 // consistent. Keep polling the role on transient status-read
221 // failures; never translate them into successful deletion.
222 Effect.catchTag("NoSuchEntityException", () =>
223 Effect.succeed("TASK_NOT_FOUND" as const),
224 ),
225 Effect.catchTag("ServiceFailureException", () =>
226 Effect.succeed(undefined),
227 ),
228 );
229 if (status === "TASK_NOT_FOUND") {
230 lastStatus = status;
231 deletionTaskId = undefined;
232 } else if (status !== undefined) {
233 lastStatus = status.Status;
234 if (status.Status === "FAILED") {
235 const usage = (status.Reason?.RoleUsageList ?? [])
236 .flatMap((entry) => entry.Resources ?? [])
237 .join(", ");
238 lastReason = [status.Reason?.Reason, usage]
239 .filter((part) => part)
240 .join(" — ");
241 // AWS documents that a new request must be submitted after a
242 // failed deletion task. The next bounded iteration does so.
243 deletionTaskId = undefined;
244 }
245 }
246 }
247 yield* Effect.sleep("2 seconds");
248 }
249 return yield* Effect.fail(
250 new ServiceLinkedRoleDeletionFailed({
251 roleName,
252 status: "TIMED_OUT",
253 reason:
254 lastReason ??
255 `deletion task ${deletionTaskId ?? "not returned"} was ${lastStatus}, but role remained observable after 60s`,
256 }),
257 );
258 });
259
260 return {
261 stables: [
262 "roleName",
263 "roleArn",
264 "roleId",
265 "path",
266 "awsServiceName",
267 "customSuffix",
268 ],
269 list: () =>
270 Effect.gen(function* () {
271 // IAM is global; every service-linked role lives under the
272 // `/aws-service-role/` path, with the linked service name as
273 // the next path segment.
274 const roles = yield* iam.listRoles
275 .pages({ PathPrefix: "/aws-service-role/" })
276 .pipe(
277 Stream.runCollect,
278 Effect.map((chunk) =>
279 Array.from(chunk).flatMap((page) => page.Roles ?? []),
280 ),
281 );
282 return roles.flatMap((role) => {
283 const awsServiceName = role.Path.split("/")[2];
284 return awsServiceName ? [toAttributes(role, awsServiceName)] : [];
285 });
286 }),
287 diff: Effect.fn(function* ({ olds, news }) {
288 if (!isResolved(news)) return;
289 // The linked service and suffix are baked into the AWS-generated
290 // role name — changing either requires a new role.
291 if (olds.awsServiceName !== news.awsServiceName) {
292 return { action: "replace" } as const;
293 }
294 if (
295 (olds.customSuffix ?? undefined) !==
296 (news.customSuffix ?? undefined)
297 ) {
298 return { action: "replace" } as const;
299 }
300 }),
301 read: Effect.fn(function* ({ olds, output }) {
302 // Service-linked roles cannot carry user tags (IAM rejects
303 // modifications beyond the description), so there is no Alchemy
304 // ownership brand — the role is an account singleton per
305 // (service, suffix) and is reported as-is.
306 const role = output?.roleName
307 ? yield* getRole(output.roleName)
308 : olds?.awsServiceName !== undefined
309 ? yield* findRole(olds)
310 : undefined;
311 if (!role) {
312 return undefined;
313 }
314 const awsServiceName =
315 olds?.awsServiceName ??
316 output?.awsServiceName ??
317 role.Path.split("/")[2] ??
318 "";
319 return toAttributes(role, awsServiceName);
320 }),
321 reconcile: Effect.fn(function* ({ news, output, session }) {
322 // Observe — prefer the cached role name; fall back to searching
323 // the service's deterministic path.
324 let observed = output?.roleName
325 ? yield* getRole(output.roleName)
326 : undefined;
327 if (!observed) {
328 observed = yield* findRole(news);
329 }
330
331 // Ensure — create when missing. AWS reports an existing role
332 // (auto-created by the service, or a concurrent reconcile) as
333 // `InvalidInputException`; re-observe and adopt it. If nothing
334 // is found the input really was invalid — rethrow.
335 if (!observed) {
336 const created = yield* iam
337 .createServiceLinkedRole({
338 AWSServiceName: news.awsServiceName,
339 CustomSuffix: news.customSuffix,
340 Description: news.description,
341 })
342 .pipe(
343 Effect.catchTag("InvalidInputException", (error) =>
344 findRole(news).pipe(
345 Effect.flatMap((existing) =>
346 existing
347 ? Effect.succeed({ Role: existing })
348 : Effect.fail(error),
349 ),
350 ),
351 ),
352 );
353 observed = created.Role;
354 }
355 if (!observed) {
356 return yield* Effect.fail(
357 new Error(
358 `Service-linked role for '${news.awsServiceName}' was created but could not be described`,
359 ),
360 );
361 }
362
363 // Sync description — the only aspect IAM lets us edit on a
364 // service-linked role. Only converge when the user declared one;
365 // an omitted prop leaves the service's default description alone.
366 if (
367 news.description !== undefined &&
368 observed.Description !== news.description
369 ) {
370 yield* iam
371 .updateRole({
372 RoleName: observed.RoleName,
373 Description: news.description,
374 })
375 .pipe(
376 // A few linked services lock the role entirely — treat the
377 // description as service-managed rather than failing.
378 Effect.catchTag(
379 "UnmodifiableEntityException",
380 () => Effect.void,
381 ),
382 );
383 }
384
385 // Return fresh attributes.
386 const fresh = (yield* getRole(observed.RoleName)) ?? observed;
387 yield* session.note(fresh.Arn);
388 return toAttributes(fresh, news.awsServiceName);
389 }),
390 delete: Effect.fn(function* ({ output }) {
391 yield* deleteAndWait(output.roleName);
392 }),
393 };
394 }),
395 );