| 1 | import * as IdentityCenter from "@/AWS/IdentityCenter"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Result from "effect/Result"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 13 | |
| 14 | export 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 | */ |
| 24 | export 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 | ); |