1import * as ssoAdmin from "@distilled.cloud/aws/sso-admin";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 {
10 listInstances,
11 resolveInstance,
12 retryIdentityCenter,
13} from "./common.ts";
14
15export interface AccountAssignmentProps {
16 /**
17 * Explicit Identity Center instance ARN.
18 * If omitted, Alchemy adopts the only visible instance.
19 */
20 instanceArn?: string;
21 /**
22 * Permission set ARN to assign.
23 */
24 permissionSetArn: string;
25 /**
26 * Principal ID from the IAM Identity Center identity store.
27 */
28 principalId: string;
29 /**
30 * Principal type.
31 */
32 principalType: "USER" | "GROUP";
33 /**
34 * Target AWS account ID.
35 */
36 targetId: string;
37}
38
39export interface AccountAssignment extends Resource<
40 "AWS.IdentityCenter.AccountAssignment",
41 AccountAssignmentProps,
42 {
43 /** The Identity Center instance the assignment lives in. */
44 instanceArn: string;
45 /** The permission set provisioned to the target. */
46 permissionSetArn: string;
47 /** The user or group ID that was assigned. */
48 principalId: string;
49 /** Whether the principal is a `USER` or `GROUP`. */
50 principalType: "USER" | "GROUP";
51 /** The AWS account ID the assignment targets. */
52 targetId: string;
53 /** The target type (`AWS_ACCOUNT`). */
54 targetType: "AWS_ACCOUNT";
55 },
56 never,
57 Providers
58> {}
59
60/**
61 * Assigns an IAM Identity Center permission set to a user or group in an AWS
62 * account.
63 * ### Creating Assignments
64 * **Example:** Assign A Group To A Workload Account
65 * ```typescript
66 * const assignment = yield* AccountAssignment("ProdAdminAssignment", {
67 * permissionSetArn: admin.permissionSetArn,
68 * principalType: "GROUP",
69 * principalId: engineers.groupId,
70 * targetId: prod.accountId,
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const AccountAssignment = Resource<AccountAssignment>(
77 "AWS.IdentityCenter.AccountAssignment",
78);
79
80export const AccountAssignmentProvider = () =>
81 Provider.effect(
82 AccountAssignment,
83 Effect.gen(function* () {
84 return {
85 stables: [
86 "instanceArn",
87 "permissionSetArn",
88 "principalId",
89 "principalType",
90 "targetId",
91 "targetType",
92 ],
93 diff: Effect.fn(function* ({ olds, news }) {
94 if (!isResolved(news)) return;
95 if (
96 olds?.instanceArn !== news.instanceArn ||
97 olds?.permissionSetArn !== news.permissionSetArn ||
98 olds?.principalId !== news.principalId ||
99 olds?.principalType !== news.principalType ||
100 olds?.targetId !== news.targetId
101 ) {
102 return { action: "replace" } as const;
103 }
104 }),
105 // Enumerate every account assignment in the account/region. Assignments
106 // are keyed by (instance, permissionSet, account, principal) with no
107 // top-level list API, so fan out: list instances -> permission sets per
108 // instance -> accounts provisioned to each permission set -> account
109 // assignments per (account, permissionSet). Each `listAccountAssignments`
110 // item already carries every field of the `read`-shaped Attributes, so
111 // no per-item hydration is needed. All pagination is exhausted; missing
112 // parents (deleted concurrently) are skipped via the typed
113 // `ResourceNotFoundException` tag. Bounded concurrency keeps the fan-out
114 // from exploding.
115 list: () =>
116 Effect.gen(function* () {
117 const instances = yield* listInstances();
118
119 const perInstance = yield* Effect.forEach(
120 instances,
121 (instance) => {
122 const instanceArn = instance.InstanceArn;
123 if (!instanceArn) {
124 return Effect.succeed(
125 [] as AccountAssignment["Attributes"][],
126 );
127 }
128 return listAssignmentsForInstance(instanceArn);
129 },
130 { concurrency: 5 },
131 );
132
133 return perInstance.flat();
134 }),
135 read: Effect.fn(function* ({ olds, output }) {
136 if (output?.instanceArn) {
137 return yield* readAssignment({
138 instanceArn: output.instanceArn,
139 permissionSetArn: output.permissionSetArn,
140 principalId: output.principalId,
141 principalType: output.principalType,
142 targetId: output.targetId,
143 });
144 }
145
146 if (!olds) {
147 return undefined;
148 }
149
150 return yield* readAssignment(olds);
151 }),
152 reconcile: Effect.fn(function* ({ news, output, session }) {
153 // Observe — look up the assignment between (principal,
154 // permissionSet, target). All identifying fields are stable;
155 // diff() forces a replace on any change, so this reconcile is
156 // existence-only.
157 const existing = yield* readAssignment({
158 ...news,
159 instanceArn: output?.instanceArn ?? news.instanceArn,
160 });
161 if (existing) {
162 yield* session.note(
163 `${existing.targetId}:${existing.permissionSetArn}:${existing.principalId}`,
164 );
165 return existing;
166 }
167
168 // Ensure — create the assignment if missing.
169 const instance = yield* resolveInstance(news.instanceArn);
170 const response = yield* retryIdentityCenter(
171 ssoAdmin.createAccountAssignment({
172 InstanceArn: instance.InstanceArn!,
173 PermissionSetArn: news.permissionSetArn,
174 PrincipalId: news.principalId,
175 PrincipalType: news.principalType,
176 TargetId: news.targetId,
177 TargetType: "AWS_ACCOUNT",
178 }),
179 );
180
181 const requestId =
182 response.AccountAssignmentCreationStatus?.RequestId ?? undefined;
183 if (requestId) {
184 yield* waitForAssignmentCreation(instance.InstanceArn!, requestId);
185 }
186
187 const created = yield* readAssignment({
188 ...news,
189 instanceArn: instance.InstanceArn,
190 });
191 if (!created) {
192 return yield* Effect.fail(
193 new Error("account assignment not found after create"),
194 );
195 }
196
197 yield* session.note(
198 `${created.targetId}:${created.permissionSetArn}:${created.principalId}`,
199 );
200 return created;
201 }),
202 delete: Effect.fn(function* ({ output }) {
203 if (!(yield* readAssignment(output))) {
204 return;
205 }
206
207 const response = yield* retryIdentityCenter(
208 ssoAdmin
209 .deleteAccountAssignment({
210 InstanceArn: output.instanceArn,
211 PermissionSetArn: output.permissionSetArn,
212 PrincipalId: output.principalId,
213 PrincipalType: output.principalType,
214 TargetId: output.targetId,
215 TargetType: "AWS_ACCOUNT",
216 })
217 .pipe(
218 Effect.catchTag("ResourceNotFoundException", () =>
219 Effect.succeed(undefined),
220 ),
221 ),
222 );
223
224 const requestId =
225 response?.AccountAssignmentDeletionStatus?.RequestId ?? undefined;
226 if (requestId) {
227 yield* waitForAssignmentDeletion(output.instanceArn, requestId);
228 }
229 }),
230 };
231 }),
232 );
233
234const listAssignmentsForInstance = Effect.fn(function* (instanceArn: string) {
235 const permissionSetArns = yield* retryIdentityCenter(
236 ssoAdmin.listPermissionSets
237 .items({ InstanceArn: instanceArn, MaxResults: 100 })
238 .pipe(
239 Stream.runCollect,
240 Effect.map((items) => Array.from(items) as string[]),
241 ),
242 ).pipe(
243 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed([])),
244 );
245
246 const perPermissionSet = yield* Effect.forEach(
247 permissionSetArns,
248 (permissionSetArn) =>
249 listAssignmentsForPermissionSet(instanceArn, permissionSetArn),
250 { concurrency: 10 },
251 );
252
253 return perPermissionSet.flat();
254});
255
256const listAssignmentsForPermissionSet = Effect.fn(function* (
257 instanceArn: string,
258 permissionSetArn: string,
259) {
260 const accountIds = yield* retryIdentityCenter(
261 ssoAdmin.listAccountsForProvisionedPermissionSet
262 .items({
263 InstanceArn: instanceArn,
264 PermissionSetArn: permissionSetArn,
265 MaxResults: 100,
266 })
267 .pipe(
268 Stream.runCollect,
269 Effect.map((items) => Array.from(items) as string[]),
270 ),
271 ).pipe(
272 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed([])),
273 );
274
275 const perAccount = yield* Effect.forEach(
276 accountIds,
277 (accountId) =>
278 ssoAdmin.listAccountAssignments
279 .items({
280 InstanceArn: instanceArn,
281 AccountId: accountId,
282 PermissionSetArn: permissionSetArn,
283 MaxResults: 100,
284 })
285 .pipe(
286 Stream.runCollect,
287 Effect.map(
288 (items) => Array.from(items) as ssoAdmin.AccountAssignment[],
289 ),
290 retryIdentityCenter,
291 Effect.map((assignments) =>
292 assignments.flatMap(
293 (assignment): AccountAssignment["Attributes"][] => {
294 if (
295 !assignment.AccountId ||
296 !assignment.PermissionSetArn ||
297 !assignment.PrincipalId ||
298 (assignment.PrincipalType !== "USER" &&
299 assignment.PrincipalType !== "GROUP")
300 ) {
301 return [];
302 }
303 return [
304 {
305 instanceArn,
306 permissionSetArn: assignment.PermissionSetArn,
307 principalId: assignment.PrincipalId,
308 principalType: assignment.PrincipalType as "USER" | "GROUP",
309 targetId: assignment.AccountId,
310 targetType: "AWS_ACCOUNT",
311 },
312 ];
313 },
314 ),
315 ),
316 Effect.catchTag("ResourceNotFoundException", () =>
317 Effect.succeed([]),
318 ),
319 ),
320 { concurrency: 10 },
321 );
322
323 return perAccount.flat();
324});
325
326const readAssignment = Effect.fn(function* ({
327 instanceArn,
328 permissionSetArn,
329 principalId,
330 principalType,
331 targetId,
332}: AccountAssignmentProps) {
333 // A `creating` row can serialize without resolved Outputs (`targetId`
334 // from `account.accountId`, etc.). Distilled `ListAccountAssignments`
335 // then fails with `ParseError: Expected string at ["AccountId"]`.
336 if (!targetId || !permissionSetArn || !principalId) {
337 return undefined;
338 }
339
340 const instance = yield* resolveInstance(instanceArn);
341 const assignments = yield* ssoAdmin.listAccountAssignments
342 .items({
343 InstanceArn: instance.InstanceArn!,
344 AccountId: targetId,
345 PermissionSetArn: permissionSetArn,
346 MaxResults: 100,
347 })
348 .pipe(
349 Stream.runCollect,
350 Effect.map((items) => Array.from(items) as ssoAdmin.AccountAssignment[]),
351 );
352
353 const match = assignments.find(
354 (assignment) =>
355 assignment.PrincipalId === principalId &&
356 assignment.PrincipalType === principalType,
357 );
358
359 if (!match) {
360 return undefined;
361 }
362 const result: AccountAssignment["Attributes"] = {
363 instanceArn: instance.InstanceArn!,
364 permissionSetArn,
365 principalId,
366 principalType,
367 targetId,
368 targetType: "AWS_ACCOUNT",
369 };
370 return result;
371});
372
373const waitForAssignmentCreation = (instanceArn: string, requestId: string) =>
374 Effect.gen(function* () {
375 const response = yield* retryIdentityCenter(
376 ssoAdmin.describeAccountAssignmentCreationStatus({
377 InstanceArn: instanceArn,
378 AccountAssignmentCreationRequestId: requestId,
379 }),
380 );
381 const status = response.AccountAssignmentCreationStatus;
382
383 if (!status?.Status || status.Status === "IN_PROGRESS") {
384 return yield* Effect.fail({
385 _tag: "AssignmentCreationInProgress" as const,
386 });
387 }
388
389 if (status.Status === "FAILED") {
390 return yield* Effect.fail(
391 new Error(
392 `account assignment creation failed: ${status.FailureReason ?? "unknown failure"}`,
393 ),
394 );
395 }
396
397 return status;
398 }).pipe(
399 Effect.retry({
400 while: (error: any) => error?._tag === "AssignmentCreationInProgress",
401 schedule: Schedule.max([
402 Schedule.spaced("2 seconds"),
403 Schedule.recurs(120),
404 ]),
405 }),
406 );
407
408const waitForAssignmentDeletion = (instanceArn: string, requestId: string) =>
409 Effect.gen(function* () {
410 const response = yield* retryIdentityCenter(
411 ssoAdmin.describeAccountAssignmentDeletionStatus({
412 InstanceArn: instanceArn,
413 AccountAssignmentDeletionRequestId: requestId,
414 }),
415 );
416 const status = response.AccountAssignmentDeletionStatus;
417
418 if (!status?.Status || status.Status === "IN_PROGRESS") {
419 return yield* Effect.fail({
420 _tag: "AssignmentDeletionInProgress" as const,
421 });
422 }
423
424 if (status.Status === "FAILED") {
425 return yield* Effect.fail(
426 new Error(
427 `account assignment deletion failed: ${status.FailureReason ?? "unknown failure"}`,
428 ),
429 );
430 }
431
432 return status;
433 }).pipe(
434 Effect.retry({
435 while: (error: any) => error?._tag === "AssignmentDeletionInProgress",
436 schedule: Schedule.max([
437 Schedule.spaced("2 seconds"),
438 Schedule.recurs(120),
439 ]),
440 }),
441 );