1import * as IdentityCenter from "@/AWS/IdentityCenter";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Result from "effect/Result";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class IdentityCenterBindingsFunction extends Lambda.Function<Lambda.Function>()(
15 "IdentityCenterBindingsFunction",
16) {}
17
18/**
19 * Lambda fixture exercising every IdentityCenter runtime binding against the
20 * account's (pre-enabled) Identity Center instance. Deploying this fixture
21 * requires an enabled instance, so the E2E suite that drives it is gated
22 * behind ALCHEMY_TEST_IDENTITY_CENTER=1 (see Bindings.test.ts).
23 */
24export default IdentityCenterBindingsFunction.make(
25 {
26 main,
27 functionUrl: true,
28 timeout: Duration.seconds(60),
29 },
30 Effect.gen(function* () {
31 const instance = yield* IdentityCenter.Instance("IdcInstance", {
32 mode: "existing",
33 });
34 const group = yield* IdentityCenter.Group("IdcBindingsGroup", {
35 identityStoreId: instance.identityStoreId,
36 displayName: "alchemy-idc-bindings-group",
37 description: "Group used by the IdentityCenter Bindings E2E fixture",
38 });
39 const permissionSet = yield* IdentityCenter.PermissionSet(
40 "IdcBindingsPermissionSet",
41 {
42 instanceArn: instance.instanceArn,
43 name: "AlchemyIdcBindings",
44 description: "Permission set used by the Bindings E2E fixture",
45 sessionDuration: "1 hour",
46 },
47 );
48
49 // Identity Store data plane
50 const createUser = yield* IdentityCenter.CreateUser(instance);
51 const updateUser = yield* IdentityCenter.UpdateUser(instance);
52 const deleteUser = yield* IdentityCenter.DeleteUser(instance);
53 const describeUser = yield* IdentityCenter.DescribeUser(instance);
54 const getUserId = yield* IdentityCenter.GetUserId(instance);
55 const listUsers = yield* IdentityCenter.ListUsers(instance);
56 const getGroupId = yield* IdentityCenter.GetGroupId(instance);
57 const listGroups = yield* IdentityCenter.ListGroups(instance);
58 const createGroupMembership =
59 yield* IdentityCenter.CreateGroupMembership(instance);
60 const deleteGroupMembership =
61 yield* IdentityCenter.DeleteGroupMembership(instance);
62 const describeGroupMembership =
63 yield* IdentityCenter.DescribeGroupMembership(instance);
64 const getGroupMembershipId =
65 yield* IdentityCenter.GetGroupMembershipId(instance);
66 const listGroupMemberships =
67 yield* IdentityCenter.ListGroupMemberships(instance);
68 const listGroupMembershipsForMember =
69 yield* IdentityCenter.ListGroupMembershipsForMember(instance);
70 const isMemberInGroups = yield* IdentityCenter.IsMemberInGroups(instance);
71
72 // sso-admin audit reads
73 const listAccountAssignments =
74 yield* IdentityCenter.ListAccountAssignments(instance);
75 const listAccountAssignmentsForPrincipal =
76 yield* IdentityCenter.ListAccountAssignmentsForPrincipal(instance);
77 const listAccountsForProvisionedPermissionSet =
78 yield* IdentityCenter.ListAccountsForProvisionedPermissionSet(instance);
79 const listPermissionSets =
80 yield* IdentityCenter.ListPermissionSets(instance);
81 const describePermissionSet =
82 yield* IdentityCenter.DescribePermissionSet(instance);
83
84 const groupId = yield* group.groupId;
85 const permissionSetArn = yield* permissionSet.permissionSetArn;
86
87 const bound = {
88 createUser,
89 updateUser,
90 deleteUser,
91 describeUser,
92 getUserId,
93 listUsers,
94 getGroupId,
95 listGroups,
96 createGroupMembership,
97 deleteGroupMembership,
98 describeGroupMembership,
99 getGroupMembershipId,
100 listGroupMemberships,
101 listGroupMembershipsForMember,
102 isMemberInGroups,
103 listAccountAssignments,
104 listAccountAssignmentsForPrincipal,
105 listAccountsForProvisionedPermissionSet,
106 listPermissionSets,
107 describePermissionSet,
108 };
109
110 const TEST_USER_NAME = "alchemy-idc-bindings-user";
111
112 /**
113 * distilled types Identity Store PII (user names, display names) as
114 * sensitive (`string | Redacted<string>`); unwrap before JSON-encoding
115 * so the E2E assertions see the plain value.
116 */
117 const unwrap = (
118 value: string | Redacted.Redacted<string> | undefined,
119 ): string | undefined =>
120 Redacted.isRedacted(value) ? Redacted.value(value) : value;
121
122 /**
123 * Create the deterministic test user, tolerating a leftover from a
124 * previously crashed run by resolving the existing user's id instead.
125 */
126 const ensureTestUser = Effect.gen(function* () {
127 const created = yield* Effect.result(
128 createUser({
129 UserName: TEST_USER_NAME,
130 DisplayName: "Alchemy Bindings Test User",
131 Name: { GivenName: "Alchemy", FamilyName: "Test" },
132 Emails: [{ Value: "alchemy-idc-test@example.com", Primary: true }],
133 }),
134 );
135 if (Result.isSuccess(created) && created.success.UserId) {
136 return created.success.UserId;
137 }
138 const { UserId } = yield* getUserId({
139 AlternateIdentifier: {
140 UniqueAttribute: {
141 AttributePath: "userName",
142 AttributeValue: TEST_USER_NAME,
143 },
144 },
145 });
146 return UserId!;
147 });
148
149 return {
150 fetch: Effect.gen(function* () {
151 const request = yield* HttpServerRequest;
152 const url = new URL(request.originalUrl);
153 const pathname = url.pathname;
154
155 if (request.method === "GET" && pathname === "/bindings") {
156 return yield* HttpServerResponse.json({
157 bound: Object.keys(bound),
158 });
159 }
160
161 if (request.method === "GET" && pathname === "/groups") {
162 const { Groups } = yield* listGroups({ MaxResults: 50 });
163 return yield* HttpServerResponse.json({
164 displayNames: (Groups ?? []).map((g) => unwrap(g.DisplayName)),
165 });
166 }
167
168 if (request.method === "GET" && pathname === "/users") {
169 const { Users } = yield* listUsers({ MaxResults: 50 });
170 return yield* HttpServerResponse.json({
171 userNames: (Users ?? []).map((u) => unwrap(u.UserName)),
172 });
173 }
174
175 if (request.method === "GET" && pathname === "/group-id") {
176 const { GroupId } = yield* getGroupId({
177 AlternateIdentifier: {
178 UniqueAttribute: {
179 AttributePath: "displayName",
180 AttributeValue: "alchemy-idc-bindings-group",
181 },
182 },
183 });
184 return yield* HttpServerResponse.json({
185 groupId: GroupId,
186 matchesBoundGroup: GroupId === (yield* groupId),
187 });
188 }
189
190 // Full user lifecycle: create -> getUserId -> describe -> update ->
191 // describe -> delete. Deterministic user name; a leftover user from
192 // a crashed run is adopted instead of failing.
193 if (request.method === "POST" && pathname === "/user-roundtrip") {
194 const userId = yield* ensureTestUser;
195 const { UserId: resolvedId } = yield* getUserId({
196 AlternateIdentifier: {
197 UniqueAttribute: {
198 AttributePath: "userName",
199 AttributeValue: TEST_USER_NAME,
200 },
201 },
202 });
203 const before = yield* describeUser({ UserId: userId });
204 yield* updateUser({
205 UserId: userId,
206 Operations: [
207 {
208 AttributePath: "displayName",
209 AttributeValue: "Alchemy Bindings Test User (updated)",
210 },
211 ],
212 });
213 const after = yield* describeUser({ UserId: userId });
214 yield* deleteUser({ UserId: userId });
215 return yield* HttpServerResponse.json({
216 userId,
217 resolvedMatches: resolvedId === userId,
218 userName: unwrap(before.UserName),
219 displayNameBefore: unwrap(before.DisplayName),
220 displayNameAfter: unwrap(after.DisplayName),
221 });
222 }
223
224 // Full membership lifecycle against the fixture group: create user,
225 // add to group, resolve/describe/query the membership, check
226 // isMemberInGroups, then tear everything down.
227 if (request.method === "POST" && pathname === "/membership-roundtrip") {
228 const GroupId = yield* groupId;
229 const userId = yield* ensureTestUser;
230 const { MembershipId } = yield* createGroupMembership({
231 GroupId,
232 MemberId: { UserId: userId },
233 });
234 const resolved = yield* getGroupMembershipId({
235 GroupId,
236 MemberId: { UserId: userId },
237 });
238 const described = yield* describeGroupMembership({
239 MembershipId: MembershipId!,
240 });
241 const members = yield* listGroupMemberships({ GroupId });
242 const groupsOfUser = yield* listGroupMembershipsForMember({
243 MemberId: { UserId: userId },
244 });
245 const check = yield* isMemberInGroups({
246 MemberId: { UserId: userId },
247 GroupIds: [GroupId],
248 });
249 yield* deleteGroupMembership({ MembershipId: MembershipId! });
250 const checkAfter = yield* isMemberInGroups({
251 MemberId: { UserId: userId },
252 GroupIds: [GroupId],
253 });
254 yield* deleteUser({ UserId: userId });
255 return yield* HttpServerResponse.json({
256 membershipId: MembershipId,
257 resolvedMatches: resolved.MembershipId === MembershipId,
258 describedGroupMatches: described.GroupId === GroupId,
259 memberCount: (members.GroupMemberships ?? []).length,
260 memberOfCount: (groupsOfUser.GroupMemberships ?? []).length,
261 isMember: check.Results?.[0]?.MembershipExists === true,
262 isMemberAfterDelete:
263 checkAfter.Results?.[0]?.MembershipExists === true,
264 });
265 }
266
267 if (request.method === "GET" && pathname === "/permission-sets") {
268 const arn = yield* permissionSetArn;
269 const { PermissionSets } = yield* listPermissionSets({
270 MaxResults: 100,
271 });
272 const { PermissionSet } = yield* describePermissionSet({
273 PermissionSetArn: arn,
274 });
275 const { AccountIds } = yield* listAccountsForProvisionedPermissionSet(
276 {
277 PermissionSetArn: arn,
278 },
279 );
280 return yield* HttpServerResponse.json({
281 containsFixturePermissionSet: (PermissionSets ?? []).includes(arn),
282 name: PermissionSet?.Name,
283 sessionDuration: PermissionSet?.SessionDuration,
284 provisionedAccounts: AccountIds ?? [],
285 });
286 }
287
288 // Assignment audit reads. `listAccountAssignmentsForPrincipal` is
289 // only valid on organization instances (from the management
290 // account), so its failure tag is reported rather than crashing the
291 // route. `listAccountAssignments` needs a target account id, passed
292 // as ?accountId=.
293 if (request.method === "GET" && pathname === "/assignments") {
294 const GroupId = yield* groupId;
295 const arn = yield* permissionSetArn;
296 const forPrincipal = yield* Effect.result(
297 listAccountAssignmentsForPrincipal({
298 PrincipalId: GroupId,
299 PrincipalType: "GROUP",
300 }),
301 );
302 const accountId = url.searchParams.get("accountId");
303 const direct = accountId
304 ? yield* Effect.result(
305 listAccountAssignments({
306 AccountId: accountId,
307 PermissionSetArn: arn,
308 }),
309 )
310 : undefined;
311 return yield* HttpServerResponse.json({
312 forPrincipal: Result.isSuccess(forPrincipal)
313 ? {
314 ok: true,
315 count: (forPrincipal.success.AccountAssignments ?? []).length,
316 }
317 : { ok: false, errorTag: forPrincipal.failure._tag },
318 direct:
319 direct === undefined
320 ? undefined
321 : Result.isSuccess(direct)
322 ? {
323 ok: true,
324 count: (direct.success.AccountAssignments ?? []).length,
325 }
326 : { ok: false, errorTag: direct.failure._tag },
327 });
328 }
329
330 return yield* HttpServerResponse.json(
331 { error: "Not found", method: request.method, pathname },
332 { status: 404 },
333 );
334 }).pipe(Effect.orDie),
335 };
336 }).pipe(
337 Effect.provide(
338 Layer.mergeAll(
339 IdentityCenter.CreateUserHttp,
340 IdentityCenter.UpdateUserHttp,
341 IdentityCenter.DeleteUserHttp,
342 IdentityCenter.DescribeUserHttp,
343 IdentityCenter.GetUserIdHttp,
344 IdentityCenter.ListUsersHttp,
345 IdentityCenter.GetGroupIdHttp,
346 IdentityCenter.ListGroupsHttp,
347 IdentityCenter.CreateGroupMembershipHttp,
348 IdentityCenter.DeleteGroupMembershipHttp,
349 IdentityCenter.DescribeGroupMembershipHttp,
350 IdentityCenter.GetGroupMembershipIdHttp,
351 IdentityCenter.ListGroupMembershipsHttp,
352 IdentityCenter.ListGroupMembershipsForMemberHttp,
353 IdentityCenter.IsMemberInGroupsHttp,
354 IdentityCenter.ListAccountAssignmentsHttp,
355 IdentityCenter.ListAccountAssignmentsForPrincipalHttp,
356 IdentityCenter.ListAccountsForProvisionedPermissionSetHttp,
357 IdentityCenter.ListPermissionSetsHttp,
358 IdentityCenter.DescribePermissionSetHttp,
359 ),
360 ),
361 ),
362);