History

fix WorkOS list boundaries (#809)

Rhys Sullivan committed 6 months ago

Commit 67b05a41 parent 368f2565 files · +249 −27Browse files

Unified · Split

5 files
  1. apps/cloud/src/auth/api-keys.node.test.ts +5 −0
  2. apps/cloud/src/auth/workos.node.test.ts +98 −0
  3. apps/cloud/src/auth/workos.ts +130 −15
  4. apps/cloud/src/org/handlers.test.ts +15 −10
  5. apps/cloud/src/org/handlers.ts +1 −2
apps/cloud/src/auth/api-keys.node.test.ts+5−0
@@ -109,6 +109,7 @@ describe("ApiKeyService.WorkOS", () => { · 108 unmodified lines
109109 stubWorkOS({
110110 listUserApiKeys: () =>
111111 Effect.succeed({
112+ object: "list" as const,
112113 data: [
113114 {
114115 id: "api_key_listed",
@@ -124,6 +125,10 @@ describe("ApiKeyService.WorkOS", () => { · 9 unmodified lines
124125 },
125126 },
126127 ],
128+ listMetadata: {
129+ before: null,
130+ after: null,
131+ },
127132 }),
128133 createUserApiKey: () =>
129134 Effect.succeed({
apps/cloud/src/auth/workos.node.test.ts+98−0
@@ -0,0 +1,98 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { collectRawWorkOSList, collectWorkOSList } from "./workos";
4+
5+describe("collectWorkOSList", () => {
6+ it("collects memberships beyond the first WorkOS page", async () => {
7+ const autoPaginationCalls: string[] = [];
8+
9+ const response = await collectWorkOSList({
10+ object: "list",
11+ data: [{ id: "om_first_page" }],
12+ listMetadata: {
13+ before: null,
14+ after: "om_next_page",
15+ },
16+ autoPagination: async () => {
17+ autoPaginationCalls.push("called");
18+ return [{ id: "om_first_page" }, { id: "om_second_page" }];
19+ },
20+ });
21+
22+ expect(response.data).toEqual([{ id: "om_first_page" }, { id: "om_second_page" }]);
23+ expect(response.listMetadata).toEqual({ before: null, after: null });
24+ expect(autoPaginationCalls).toEqual(["called"]);
25+ });
26+
27+ it("keeps the first page when WorkOS reports no next page", async () => {
28+ let autoPaginationCalls = 0;
29+
30+ const response = await collectWorkOSList({
31+ object: "list",
32+ data: [{ id: "om_only_page" }],
33+ listMetadata: {
34+ before: null,
35+ after: null,
36+ },
37+ autoPagination: async () => {
38+ autoPaginationCalls += 1;
39+ return [{ id: "om_unexpected_page" }];
40+ },
41+ });
42+
43+ expect(response.data).toEqual([{ id: "om_only_page" }]);
44+ expect(response.listMetadata).toEqual({ before: null, after: null });
45+ expect(autoPaginationCalls).toBe(0);
46+ });
47+});
48+
49+describe("collectRawWorkOSList", () => {
50+ it("collects raw WorkOS lists using snake-case cursors", async () => {
51+ const requestedCursors: Array<string | undefined> = [];
52+
53+ const response = await collectRawWorkOSList(async (after) => {
54+ requestedCursors.push(after);
55+ return after
56+ ? {
57+ data: [{ id: "api_key_second_page" }],
58+ list_metadata: {
59+ before: null,
60+ after: null,
61+ },
62+ }
63+ : {
64+ data: [{ id: "api_key_first_page" }],
65+ list_metadata: {
66+ before: null,
67+ after: "api_key_second_page",
68+ },
69+ };
70+ });
71+
72+ expect(response.data).toEqual([{ id: "api_key_first_page" }, { id: "api_key_second_page" }]);
73+ expect(response.listMetadata).toEqual({ before: null, after: null });
74+ expect(requestedCursors).toEqual([undefined, "api_key_second_page"]);
75+ });
76+
77+ it("collects raw WorkOS lists using camel-case cursors", async () => {
78+ const response = await collectRawWorkOSList(async (after) =>
79+ after
80+ ? {
81+ data: [{ id: "second" }],
82+ listMetadata: {
83+ before: null,
84+ after: null,
85+ },
86+ }
87+ : {
88+ data: [{ id: "first" }],
89+ listMetadata: {
90+ before: null,
91+ after: "second",
92+ },
93+ },
94+ );
95+
96+ expect(response.data).toEqual([{ id: "first" }, { id: "second" }]);
97+ });
98+});
apps/cloud/src/auth/workos.ts+130−15
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 // ---------------------------------------------------------------------------
44
55 import { env } from "cloudflare:workers";
6−import { Context, Data, Effect, Layer } from "effect";
6+import { Context, Data, Effect, Layer, Option, Schema } from "effect";
77 import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
88 import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
99
@@ -24,6 +24,88 @@ type RawWorkOS = WorkOS & { · 14 unmodified lines
2424 ) => Promise<{ readonly data: unknown }>;
2525 };
2626
27+type WorkOSListMetadata = {
28+ readonly before?: string | null;
29+ readonly after?: string | null;
30+};
31+
32+type WorkOSAutoPaginatable<Resource> = {
33+ readonly object: "list";
34+ readonly data: Resource[];
35+ readonly listMetadata: WorkOSListMetadata;
36+ readonly autoPagination: () => Promise<Resource[]>;
37+};
38+
39+export type WorkOSCollectedList<Resource> = {
40+ readonly object: "list";
41+ readonly data: Resource[];
42+ readonly listMetadata: {
43+ readonly before: string | null;
44+ readonly after: string | null;
45+ };
46+};
47+
48+const RawWorkOSListMetadata = Schema.Struct({
49+ before: Schema.optional(Schema.NullOr(Schema.String)),
50+ after: Schema.optional(Schema.NullOr(Schema.String)),
51+});
52+
53+const RawWorkOSListResponse = Schema.Struct({
54+ data: Schema.Array(Schema.Unknown),
55+ listMetadata: Schema.optional(RawWorkOSListMetadata),
56+ list_metadata: Schema.optional(RawWorkOSListMetadata),
57+});
58+
59+const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);
60+
61+const completedListMetadata = {
62+ before: null,
63+ after: null,
64+} as const;
65+
66+const nextCursorFromRawList = (response: typeof RawWorkOSListResponse.Type): string | null =>
67+ response.listMetadata?.after ?? response.list_metadata?.after ?? null;
68+
69+export const collectWorkOSList = async <Resource>(
70+ response: WorkOSAutoPaginatable<Resource>,
71+): Promise<WorkOSCollectedList<Resource>> => {
72+ const data = response.listMetadata.after ? await response.autoPagination() : response.data;
73+ return {
74+ object: "list",
75+ data,
76+ listMetadata: completedListMetadata,
77+ };
78+};
79+
80+export const collectRawWorkOSList = async (
81+ loadPage: (after?: string) => Promise<unknown>,
82+): Promise<WorkOSCollectedList<unknown>> => {
83+ const first = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage()));
84+ if (!first) {
85+ return {
86+ object: "list",
87+ data: [],
88+ listMetadata: completedListMetadata,
89+ };
90+ }
91+
92+ const data = [...first.data];
93+ let after = nextCursorFromRawList(first);
94+
95+ while (after) {
96+ const next = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage(after)));
97+ if (!next) break;
98+ data.push(...next.data);
99+ after = nextCursorFromRawList(next);
100+ }
101+
102+ return {
103+ object: "list",
104+ data,
105+ listMetadata: completedListMetadata,
106+ };
107+};
108+
27109 class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{
28110 readonly message: string;
29111 }> {}
@@ -132,11 +214,13 @@ const make = Effect.gen(function* () { · 102 unmodified lines
132214
133215 /** List organization memberships for a user. */
134216 listUserMemberships: (userId: string) =>
135− use((wos) =>
136− wos.userManagement.listOrganizationMemberships({
137− userId,
138− statuses: ["active", "pending"],
139− }),
217+ use(async (wos) =>
218+ collectWorkOSList(
219+ await wos.userManagement.listOrganizationMemberships({
220+ userId,
221+ statuses: ["active", "pending"],
222+ }),
223+ ),
140224 ),
141225
142226 /**
@@ -183,10 +267,16 @@ const make = Effect.gen(function* () { · 40 unmodified lines
183267 listUserApiKeys: (userId: string, organizationId: string) =>
184268 use(async (wos) => {
185269 const raw = wos as RawWorkOS;
186− const response = await raw.get(`/user_management/users/${userId}/api_keys`, {
187− query: { organization_id: organizationId },
270+ return collectRawWorkOSList(async (after) => {
271+ const response = await raw.get(`/user_management/users/${userId}/api_keys`, {
272+ query: {
273+ organization_id: organizationId,
274+ limit: 100,
275+ ...(after ? { after } : {}),
276+ },
277+ });
278+ return response.data;
188279 });
189− return response.data;
190280 }),
191281
192282 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>
@@ -203,12 +293,25 @@ const make = Effect.gen(function* () { · 10 unmodified lines
203293
204294 /** List organization memberships with user details. */
205295 listOrgMembers: (organizationId: string) =>
206− use((wos) =>
207− wos.userManagement.listOrganizationMemberships({
296+ use(async (wos) =>
297+ collectWorkOSList(
298+ await wos.userManagement.listOrganizationMemberships({
299+ organizationId,
300+ statuses: ["active", "pending"],
301+ }),
302+ ),
303+ ),
304+
305+ /** Get a user's membership in an organization. */
306+ getUserOrgMembership: (organizationId: string, userId: string) =>
307+ use(async (wos) => {
308+ const response = await wos.userManagement.listOrganizationMemberships({
208309 organizationId,
310+ userId,
209311 statuses: ["active", "pending"],
210− }),
211− ),
312+ });
313+ return response.data[0] ?? null;
314+ }),
212315
213316 /** Get a user by ID. */
214317 getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)),
@@ -229,7 +332,13 @@ const make = Effect.gen(function* () { · 14 unmodified lines
229332 * API level, so we filter after.
230333 */
231334 listPendingInvitations: (organizationId: string) =>
232− use((wos) => wos.userManagement.listInvitations({ organizationId })).pipe(
335+ use(async (wos) =>
336+ collectWorkOSList(
337+ await wos.userManagement.listInvitations({
338+ organizationId,
339+ }),
340+ ),
341+ ).pipe(
233342 Effect.map((response) => ({
234343 ...response,
235344 data: response.data.filter((i) => i.state === "pending"),
@@ -238,7 +347,13 @@ const make = Effect.gen(function* () { · 2 unmodified lines
238347
239348 /** List invitations for an email address (across all orgs). */
240349 listInvitationsByEmail: (email: string) =>
241− use((wos) => wos.userManagement.listInvitations({ email })),
350+ use(async (wos) =>
351+ collectWorkOSList(
352+ await wos.userManagement.listInvitations({
353+ email,
354+ }),
355+ ),
356+ ),
242357
243358 /** Accept an invitation; returns the (now accepted) invitation. */
244359 acceptInvitation: (invitationId: string) =>
apps/cloud/src/org/handlers.test.ts+15−10
@@ -14,6 +14,7 @@ type StubFn = (...args: never[]) => Effect.Effect<any>; · 13 unmodified lines
1414
1515 type StubOverrides = {
1616 listOrgMembers?: StubFn;
17+ getUserOrgMembership?: StubFn;
1718 getUser?: StubFn;
1819 sendInvitation?: StubFn;
1920 deleteOrgMembership?: StubFn;
@@ -122,8 +123,7 @@ const fakeRoles: FakeRole[] = [ · 102 unmodified lines
122123 const requireAdmin = Effect.gen(function* () {
123124 const auth = yield* AuthContext;
124125 const workos = yield* WorkOSAuth;
125− const memberships = yield* workos.listOrgMembers(auth.organizationId);
126− const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId);
126+ const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
127127 if (!current || current.role?.slug !== "admin") {
128128 return yield* new Forbidden();
129129 }
@@ -136,6 +136,11 @@ const withMembers: StubOverrides = { · 6 unmodified lines
136136 listOrgMembers: () => Effect.succeed({ data: fakeMemberships }),
137137 };
138138
139+const withCurrentMembership: StubOverrides = {
140+ getUserOrgMembership: (_organizationId: string, userId: string) =>
141+ Effect.succeed(fakeMemberships.find((m) => m.userId === userId) ?? null),
142+};
143+
139144 // ---------------------------------------------------------------------------
140145 // Tests
141146 // ---------------------------------------------------------------------------
@@ -205,14 +210,14 @@ describe("Org handlers", () => { · 63 unmodified lines
205210
206211 describe("requireAdmin", () => {
207212 it.effect("passes for admin user", () =>
208− requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))),
213+ requireAdmin.pipe(Effect.provide(provide(adminAuth, withCurrentMembership))),
209214 );
210215
211216 it.effect("rejects non-admin with Forbidden", () =>
212217 Effect.gen(function* () {
213218 const error = yield* Effect.flip(requireAdmin);
214219 expect(error).toBeInstanceOf(Forbidden);
215− }).pipe(Effect.provide(provide(memberAuth, withMembers))),
220+ }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
216221 );
217222 });
218223
@@ -231,7 +236,7 @@ describe("Org handlers", () => { · 12 unmodified lines
231236 }).pipe(
232237 Effect.provide(
233238 provide(adminAuth, {
234− ...withMembers,
239+ ...withCurrentMembership,
235240 sendInvitation: (p: { email: string }) =>
236241 Effect.succeed({ id: "inv_1", email: p.email }),
237242 }),
@@ -252,7 +257,7 @@ describe("Org handlers", () => { · 14 unmodified lines
252257 }),
253258 );
254259 expect(error).toBeInstanceOf(Forbidden);
255− }).pipe(Effect.provide(provide(memberAuth, withMembers))),
260+ }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
256261 );
257262 });
258263
@@ -265,7 +270,7 @@ describe("Org handlers", () => { · 6 unmodified lines
265270 }).pipe(
266271 Effect.provide(
267272 provide(adminAuth, {
268− ...withMembers,
273+ ...withCurrentMembership,
269274 deleteOrgMembership: () => Effect.void,
270275 }),
271276 ),
@@ -282,7 +287,7 @@ describe("Org handlers", () => { · 10 unmodified lines
282287 }),
283288 );
284289 expect(error).toBeInstanceOf(Forbidden);
285− }).pipe(Effect.provide(provide(memberAuth, withMembers))),
290+ }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
286291 );
287292 });
288293
@@ -295,7 +300,7 @@ describe("Org handlers", () => { · 6 unmodified lines
295300 }).pipe(
296301 Effect.provide(
297302 provide(adminAuth, {
298− ...withMembers,
303+ ...withCurrentMembership,
299304 updateOrgMembershipRole: () => Effect.void,
300305 }),
301306 ),
@@ -312,7 +317,7 @@ describe("Org handlers", () => { · 10 unmodified lines
312317 }),
313318 );
314319 expect(error).toBeInstanceOf(Forbidden);
315− }).pipe(Effect.provide(provide(memberAuth, withMembers))),
320+ }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
316321 );
317322 });
318323 });
apps/cloud/src/org/handlers.ts+1−2
@@ -13,8 +13,7 @@ import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-lim · 12 unmodified lines
1313 const requireAdmin = Effect.gen(function* () {
1414 const auth = yield* AuthContext;
1515 const workos = yield* WorkOSAuth;
16− const memberships = yield* workos.listOrgMembers(auth.organizationId);
17− const currentMembership = memberships.data.find((m) => m.userId === auth.accountId);
16+ const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
1817 if (!currentMembership || currentMembership.role?.slug !== "admin") {
1918 return yield* new Forbidden();
2019 }