1import * as identitystore from "@distilled.cloud/aws/identitystore";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7import {
8 listGroups,
9 resolveIdentityStoreId,
10 retryIdentityCenter,
11 unredact,
12} from "./common.ts";
13
14export interface GroupProps {
15 /**
16 * Explicit identity store ID.
17 * If omitted, Alchemy resolves it from the selected Identity Center instance.
18 */
19 identityStoreId?: string;
20 /**
21 * Optional instance ARN used to discover the identity store ID.
22 */
23 instanceArn?: string;
24 /**
25 * Group display name.
26 */
27 displayName: string;
28 /**
29 * Optional group description.
30 */
31 description?: string;
32}
33
34export interface Group extends Resource<
35 "AWS.IdentityCenter.Group",
36 GroupProps,
37 {
38 /** The identity store containing the group. */
39 identityStoreId: string;
40 /** The unique ID of the group. */
41 groupId: string;
42 /** The display name of the group. */
43 displayName: string | undefined;
44 /** The description of the group. */
45 description: string | undefined;
46 /** When the group was created. */
47 createdAt: Date | undefined;
48 /** When the group was last updated. */
49 updatedAt: Date | undefined;
50 },
51 never,
52 Providers
53> {}
54
55/**
56 * A group in the IAM Identity Center identity store.
57 * ### Creating Groups
58 * **Example:** Platform Engineers
59 * ```typescript
60 * const engineers = yield* Group("PlatformEngineers", {
61 * displayName: "platform-engineers",
62 * description: "Platform engineering team",
63 * });
64 * ```
65 *
66 * @resource
67 */
68export const Group = Resource<Group>("AWS.IdentityCenter.Group");
69
70export const GroupProvider = () =>
71 Provider.effect(
72 Group,
73 Effect.gen(function* () {
74 return {
75 stables: ["identityStoreId", "groupId"],
76 list: () =>
77 Effect.gen(function* () {
78 // Identity Center groups live in the instance's identity store.
79 // Resolve it from the (single) enabled SSO instance, then
80 // enumerate every group and hydrate each into the exact `read`
81 // shape via `describeGroup` (bounded concurrency, typed
82 // per-item not-found handled inside `readGroupById`).
83 const identityStoreId = yield* resolveIdentityStoreId({});
84 const groups = yield* listGroups(identityStoreId);
85 const rows = yield* Effect.forEach(
86 groups,
87 (group) =>
88 group.GroupId
89 ? readGroupById(identityStoreId, group.GroupId)
90 : Effect.succeed(undefined),
91 { concurrency: 10 },
92 );
93 return rows.filter(
94 (row): row is Group["Attributes"] => row !== undefined,
95 );
96 }),
97 diff: Effect.fn(function* ({ olds, news }) {
98 if (!isResolved(news)) return;
99 if (olds?.identityStoreId !== news.identityStoreId) {
100 return { action: "replace" } as const;
101 }
102 }),
103 read: Effect.fn(function* ({ olds, output }) {
104 if (output?.groupId && output.identityStoreId) {
105 return yield* readGroupById(output.identityStoreId, output.groupId);
106 }
107
108 if (!olds) {
109 return undefined;
110 }
111
112 return yield* readGroupByDisplayName(olds);
113 }),
114 reconcile: Effect.fn(function* ({ news, output, session }) {
115 const identityStoreId =
116 output?.identityStoreId ?? (yield* resolveIdentityStoreId(news));
117
118 // Observe — find the group by id (if we already have one) or
119 // by display name. We never trust `output.groupId` blindly: a
120 // group deleted out of band shows up as missing here.
121 let existing =
122 (output?.groupId
123 ? yield* readGroupById(identityStoreId, output.groupId)
124 : undefined) ??
125 (yield* readGroupByDisplayName({
126 ...news,
127 identityStoreId,
128 }));
129
130 // Ensure — create the group if missing.
131 if (!existing) {
132 const response = yield* retryIdentityCenter(
133 identitystore.createGroup({
134 IdentityStoreId: identityStoreId,
135 DisplayName: news.displayName,
136 Description: news.description,
137 }),
138 );
139
140 existing =
141 (response.GroupId
142 ? yield* readGroupById(identityStoreId, response.GroupId)
143 : undefined) ??
144 (yield* readGroupByDisplayName({
145 ...news,
146 identityStoreId,
147 }));
148
149 if (!existing) {
150 return yield* Effect.fail(
151 new Error(`group '${news.displayName}' not found after create`),
152 );
153 }
154
155 yield* session.note(existing.groupId);
156 return existing;
157 }
158
159 // Sync mutable attributes — `updateGroup` overwrites
160 // displayName and description in one call. We diff against
161 // observed cloud state so adoption converges.
162 const operations: {
163 AttributePath: string;
164 AttributeValue: string;
165 }[] = [];
166 // Attribute paths are SCIM-style camelCase (`displayName`,
167 // `description`) per the UpdateGroup API.
168 if (existing.displayName !== news.displayName) {
169 operations.push({
170 AttributePath: "displayName",
171 AttributeValue: news.displayName,
172 });
173 }
174 const desiredDescription = news.description ?? "";
175 if ((existing.description ?? "") !== desiredDescription) {
176 operations.push({
177 AttributePath: "description",
178 AttributeValue: desiredDescription,
179 });
180 }
181
182 if (operations.length > 0) {
183 yield* retryIdentityCenter(
184 identitystore.updateGroup({
185 IdentityStoreId: existing.identityStoreId,
186 GroupId: existing.groupId,
187 Operations: operations,
188 }),
189 );
190
191 const updated = yield* readGroupById(
192 existing.identityStoreId,
193 existing.groupId,
194 );
195 if (!updated) {
196 return yield* Effect.fail(
197 new Error(`group '${existing.groupId}' not found after update`),
198 );
199 }
200 yield* session.note(updated.groupId);
201 return updated;
202 }
203
204 yield* session.note(existing.groupId);
205 return existing;
206 }),
207 delete: Effect.fn(function* ({ output }) {
208 yield* retryIdentityCenter(
209 identitystore
210 .deleteGroup({
211 IdentityStoreId: output.identityStoreId,
212 GroupId: output.groupId,
213 })
214 .pipe(
215 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
216 ),
217 );
218 }),
219 };
220 }),
221 );
222
223const readGroupById = Effect.fn(function* (
224 identityStoreId: string,
225 groupId: string,
226) {
227 const response = yield* retryIdentityCenter(
228 identitystore
229 .describeGroup({
230 IdentityStoreId: identityStoreId,
231 GroupId: groupId,
232 })
233 .pipe(
234 Effect.catchTag("ResourceNotFoundException", () =>
235 Effect.succeed(undefined),
236 ),
237 ),
238 );
239
240 if (!response?.GroupId || !response.IdentityStoreId) {
241 return undefined;
242 }
243
244 return {
245 identityStoreId: response.IdentityStoreId,
246 groupId: response.GroupId,
247 // Sensitive-trait fields decode to Redacted at runtime — unwrap before
248 // persisting into Attributes / diffing against desired props.
249 displayName: unredact(response.DisplayName),
250 description: unredact(response.Description),
251 createdAt: response.CreatedAt,
252 updatedAt: response.UpdatedAt,
253 } satisfies Group["Attributes"];
254});
255
256const readGroupByDisplayName = Effect.fn(function* ({
257 identityStoreId,
258 instanceArn,
259 displayName,
260}: Pick<GroupProps, "identityStoreId" | "instanceArn" | "displayName">) {
261 const resolvedIdentityStoreId = yield* resolveIdentityStoreId({
262 identityStoreId,
263 instanceArn,
264 });
265 const groups = yield* listGroups(resolvedIdentityStoreId);
266 const match = groups.find(
267 (group) => unredact(group.DisplayName) === displayName,
268 );
269 return match?.GroupId
270 ? yield* readGroupById(resolvedIdentityStoreId, match.GroupId)
271 : undefined;
272});