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
109 stubWorkOS({109 stubWorkOS({
110 listUserApiKeys: () =>110 listUserApiKeys: () =>
111 Effect.succeed({111 Effect.succeed({
112 object: "list" as const,
112 data: [113 data: [
113 {114 {
114 id: "api_key_listed",115 id: "api_key_listed",
@@ -124,6 +125,10 @@ describe("ApiKeyService.WorkOS", () => { · 9 unmodified lines
124 },125 },
125 },126 },
126 ],127 ],
128 listMetadata: {
129 before: null,
130 after: null,
131 },
127 }),132 }),
128 createUserApiKey: () =>133 createUserApiKey: () =>
129 Effect.succeed({134 Effect.succeed({
apps/cloud/src/auth/workos.node.test.ts+98−0
@@ -0,0 +1,98 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { collectRawWorkOSList, collectWorkOSList } from "./workos";
4
5describe("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
49describe("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
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import { Context, Data, Effect, Layer } from "effect";6import { Context, Data, Effect, Layer, Option, Schema } from "effect";
7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";7import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
8import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";8import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
9 9
@@ -24,6 +24,88 @@ type RawWorkOS = WorkOS & { · 14 unmodified lines
24 ) => Promise<{ readonly data: unknown }>;24 ) => Promise<{ readonly data: unknown }>;
25};25};
26 26
27type WorkOSListMetadata = {
28 readonly before?: string | null;
29 readonly after?: string | null;
30};
31
32type WorkOSAutoPaginatable<Resource> = {
33 readonly object: "list";
34 readonly data: Resource[];
35 readonly listMetadata: WorkOSListMetadata;
36 readonly autoPagination: () => Promise<Resource[]>;
37};
38
39export 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
48const RawWorkOSListMetadata = Schema.Struct({
49 before: Schema.optional(Schema.NullOr(Schema.String)),
50 after: Schema.optional(Schema.NullOr(Schema.String)),
51});
52
53const RawWorkOSListResponse = Schema.Struct({
54 data: Schema.Array(Schema.Unknown),
55 listMetadata: Schema.optional(RawWorkOSListMetadata),
56 list_metadata: Schema.optional(RawWorkOSListMetadata),
57});
58
59const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);
60
61const completedListMetadata = {
62 before: null,
63 after: null,
64} as const;
65
66const nextCursorFromRawList = (response: typeof RawWorkOSListResponse.Type): string | null =>
67 response.listMetadata?.after ?? response.list_metadata?.after ?? null;
68
69export 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
80export 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
27class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{109class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{
28 readonly message: string;110 readonly message: string;
29}> {}111}> {}
@@ -132,11 +214,13 @@ const make = Effect.gen(function* () { · 102 unmodified lines
132 214
133 /** List organization memberships for a user. */215 /** List organization memberships for a user. */
134 listUserMemberships: (userId: string) =>216 listUserMemberships: (userId: string) =>
135 use((wos) =>217 use(async (wos) =>
136 wos.userManagement.listOrganizationMemberships({218 collectWorkOSList(
137 userId,219 await wos.userManagement.listOrganizationMemberships({
138 statuses: ["active", "pending"],220 userId,
139 }),221 statuses: ["active", "pending"],
222 }),
223 ),
140 ),224 ),
141 225
142 /**226 /**
@@ -183,10 +267,16 @@ const make = Effect.gen(function* () { · 40 unmodified lines
183 listUserApiKeys: (userId: string, organizationId: string) =>267 listUserApiKeys: (userId: string, organizationId: string) =>
184 use(async (wos) => {268 use(async (wos) => {
185 const raw = wos as RawWorkOS;269 const raw = wos as RawWorkOS;
186 const response = await raw.get(`/user_management/users/${userId}/api_keys`, {270 return collectRawWorkOSList(async (after) => {
187 query: { organization_id: organizationId },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;
188 });279 });
189 return response.data;
190 }),280 }),
191 281
192 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>282 createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>
@@ -203,12 +293,25 @@ const make = Effect.gen(function* () { · 10 unmodified lines
203 293
204 /** List organization memberships with user details. */294 /** List organization memberships with user details. */
205 listOrgMembers: (organizationId: string) =>295 listOrgMembers: (organizationId: string) =>
206 use((wos) =>296 use(async (wos) =>
207 wos.userManagement.listOrganizationMemberships({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({
208 organizationId,309 organizationId,
310 userId,
209 statuses: ["active", "pending"],311 statuses: ["active", "pending"],
210 }),312 });
211 ),313 return response.data[0] ?? null;
314 }),
212 315
213 /** Get a user by ID. */316 /** Get a user by ID. */
214 getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)),317 getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)),
@@ -229,7 +332,13 @@ const make = Effect.gen(function* () { · 14 unmodified lines
229 * API level, so we filter after.332 * API level, so we filter after.
230 */333 */
231 listPendingInvitations: (organizationId: string) =>334 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(
233 Effect.map((response) => ({342 Effect.map((response) => ({
234 ...response,343 ...response,
235 data: response.data.filter((i) => i.state === "pending"),344 data: response.data.filter((i) => i.state === "pending"),
@@ -238,7 +347,13 @@ const make = Effect.gen(function* () { · 2 unmodified lines
238 347
239 /** List invitations for an email address (across all orgs). */348 /** List invitations for an email address (across all orgs). */
240 listInvitationsByEmail: (email: string) =>349 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 ),
242 357
243 /** Accept an invitation; returns the (now accepted) invitation. */358 /** Accept an invitation; returns the (now accepted) invitation. */
244 acceptInvitation: (invitationId: string) =>359 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
14 14
15type StubOverrides = {15type StubOverrides = {
16 listOrgMembers?: StubFn;16 listOrgMembers?: StubFn;
17 getUserOrgMembership?: StubFn;
17 getUser?: StubFn;18 getUser?: StubFn;
18 sendInvitation?: StubFn;19 sendInvitation?: StubFn;
19 deleteOrgMembership?: StubFn;20 deleteOrgMembership?: StubFn;
@@ -122,8 +123,7 @@ const fakeRoles: FakeRole[] = [ · 102 unmodified lines
122const requireAdmin = Effect.gen(function* () {123const requireAdmin = Effect.gen(function* () {
123 const auth = yield* AuthContext;124 const auth = yield* AuthContext;
124 const workos = yield* WorkOSAuth;125 const workos = yield* WorkOSAuth;
125 const memberships = yield* workos.listOrgMembers(auth.organizationId);126 const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
126 const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId);
127 if (!current || current.role?.slug !== "admin") {127 if (!current || current.role?.slug !== "admin") {
128 return yield* new Forbidden();128 return yield* new Forbidden();
129 }129 }
@@ -136,6 +136,11 @@ const withMembers: StubOverrides = { · 6 unmodified lines
136 listOrgMembers: () => Effect.succeed({ data: fakeMemberships }),136 listOrgMembers: () => Effect.succeed({ data: fakeMemberships }),
137};137};
138 138
139const withCurrentMembership: StubOverrides = {
140 getUserOrgMembership: (_organizationId: string, userId: string) =>
141 Effect.succeed(fakeMemberships.find((m) => m.userId === userId) ?? null),
142};
143
139// ---------------------------------------------------------------------------144// ---------------------------------------------------------------------------
140// Tests145// Tests
141// ---------------------------------------------------------------------------146// ---------------------------------------------------------------------------
@@ -205,14 +210,14 @@ describe("Org handlers", () => { · 63 unmodified lines
205 210
206 describe("requireAdmin", () => {211 describe("requireAdmin", () => {
207 it.effect("passes for admin user", () =>212 it.effect("passes for admin user", () =>
208 requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))),213 requireAdmin.pipe(Effect.provide(provide(adminAuth, withCurrentMembership))),
209 );214 );
210 215
211 it.effect("rejects non-admin with Forbidden", () =>216 it.effect("rejects non-admin with Forbidden", () =>
212 Effect.gen(function* () {217 Effect.gen(function* () {
213 const error = yield* Effect.flip(requireAdmin);218 const error = yield* Effect.flip(requireAdmin);
214 expect(error).toBeInstanceOf(Forbidden);219 expect(error).toBeInstanceOf(Forbidden);
215 }).pipe(Effect.provide(provide(memberAuth, withMembers))),220 }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
216 );221 );
217 });222 });
218 223
@@ -231,7 +236,7 @@ describe("Org handlers", () => { · 12 unmodified lines
231 }).pipe(236 }).pipe(
232 Effect.provide(237 Effect.provide(
233 provide(adminAuth, {238 provide(adminAuth, {
234 ...withMembers,239 ...withCurrentMembership,
235 sendInvitation: (p: { email: string }) =>240 sendInvitation: (p: { email: string }) =>
236 Effect.succeed({ id: "inv_1", email: p.email }),241 Effect.succeed({ id: "inv_1", email: p.email }),
237 }),242 }),
@@ -252,7 +257,7 @@ describe("Org handlers", () => { · 14 unmodified lines
252 }),257 }),
253 );258 );
254 expect(error).toBeInstanceOf(Forbidden);259 expect(error).toBeInstanceOf(Forbidden);
255 }).pipe(Effect.provide(provide(memberAuth, withMembers))),260 }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
256 );261 );
257 });262 });
258 263
@@ -265,7 +270,7 @@ describe("Org handlers", () => { · 6 unmodified lines
265 }).pipe(270 }).pipe(
266 Effect.provide(271 Effect.provide(
267 provide(adminAuth, {272 provide(adminAuth, {
268 ...withMembers,273 ...withCurrentMembership,
269 deleteOrgMembership: () => Effect.void,274 deleteOrgMembership: () => Effect.void,
270 }),275 }),
271 ),276 ),
@@ -282,7 +287,7 @@ describe("Org handlers", () => { · 10 unmodified lines
282 }),287 }),
283 );288 );
284 expect(error).toBeInstanceOf(Forbidden);289 expect(error).toBeInstanceOf(Forbidden);
285 }).pipe(Effect.provide(provide(memberAuth, withMembers))),290 }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
286 );291 );
287 });292 });
288 293
@@ -295,7 +300,7 @@ describe("Org handlers", () => { · 6 unmodified lines
295 }).pipe(300 }).pipe(
296 Effect.provide(301 Effect.provide(
297 provide(adminAuth, {302 provide(adminAuth, {
298 ...withMembers,303 ...withCurrentMembership,
299 updateOrgMembershipRole: () => Effect.void,304 updateOrgMembershipRole: () => Effect.void,
300 }),305 }),
301 ),306 ),
@@ -312,7 +317,7 @@ describe("Org handlers", () => { · 10 unmodified lines
312 }),317 }),
313 );318 );
314 expect(error).toBeInstanceOf(Forbidden);319 expect(error).toBeInstanceOf(Forbidden);
315 }).pipe(Effect.provide(provide(memberAuth, withMembers))),320 }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))),
316 );321 );
317 });322 });
318});323});
apps/cloud/src/org/handlers.ts+1−2
@@ -13,8 +13,7 @@ import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-lim · 12 unmodified lines
13const requireAdmin = Effect.gen(function* () {13const requireAdmin = Effect.gen(function* () {
14 const auth = yield* AuthContext;14 const auth = yield* AuthContext;
15 const workos = yield* WorkOSAuth;15 const workos = yield* WorkOSAuth;
16 const memberships = yield* workos.listOrgMembers(auth.organizationId);16 const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
17 const currentMembership = memberships.data.find((m) => m.userId === auth.accountId);
18 if (!currentMembership || currentMembership.role?.slug !== "admin") {17 if (!currentMembership || currentMembership.role?.slug !== "admin") {
19 return yield* new Forbidden();18 return yield* new Forbidden();
20 }19 }