From d47a309eb9bb9b716f5591cf5c3bee8052482699 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:41:31 -0700 Subject: [PATCH] Use identity read model for organization reads --- apps/cloud/src/auth/handlers.node.test.ts | 5 +++ apps/cloud/src/auth/handlers.ts | 26 +++++------ apps/cloud/src/identity/directory.test.ts | 54 +++++++++++++++++++++++ apps/cloud/src/identity/directory.ts | 31 +++++++++++++ apps/cloud/src/org/api.ts | 1 + apps/cloud/src/org/handlers.ts | 4 +- 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/apps/cloud/src/auth/handlers.node.test.ts b/apps/cloud/src/auth/handlers.node.test.ts index 8cbed1ff2..9c0790fb8 100644 --- a/apps/cloud/src/auth/handlers.node.test.ts +++ b/apps/cloud/src/auth/handlers.node.test.ts @@ -11,6 +11,7 @@ import { CloudAuthPublicApi } from "./api"; import { CloudAuthPublicHandlers } from "./handlers"; import { UserStoreService } from "./context"; import { WorkOSAuth } from "./workos"; +import { IdentityDirectory } from "../identity/directory"; const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi); @@ -29,6 +30,9 @@ const makeAuthFetch = (workos: Partial) => { const UserStoreTest = Layer.succeed(UserStoreService, { use: () => Effect.sync(() => undefined as A), }); + const IdentityDirectoryTest = Layer.succeed(IdentityDirectory, { + refreshAccountMemberships: () => Effect.succeed([]), + } as unknown as IdentityDirectory["Type"]); const app = Effect.flatMap( HttpApiBuilder.httpApp.pipe( Effect.provide( @@ -36,6 +40,7 @@ const makeAuthFetch = (workos: Partial) => { Layer.provide(CloudAuthPublicHandlers), Layer.provideMerge(WorkOSTest), Layer.provideMerge(UserStoreTest), + Layer.provideMerge(IdentityDirectoryTest), Layer.provideMerge(HttpServer.layerContext), Layer.provideMerge(HttpApiBuilder.Router.Live), Layer.provideMerge(HttpApiBuilder.Middleware.layer), diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 00e497a54..8f28f8f6e 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -153,6 +153,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( } } + const directory = yield* IdentityDirectory; + yield* directory.refreshAccountMemberships(result.user.id); + if (!sealedSession) { return HttpServerResponse.text("Failed to create session", { status: 500 }); } @@ -203,24 +206,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }) .handle("organizations", () => Effect.gen(function* () { - const workos = yield* WorkOSAuth; const directory = yield* IdentityDirectory; const session = yield* SessionContext; - const memberships = yield* workos.listUserMemberships(session.accountId); - const organizations = yield* Effect.all( - memberships.data.map((m) => - workos.getOrganization(m.organizationId).pipe( - Effect.map((org) => ({ id: org.id, name: org.name })), - Effect.orElseSucceed(() => null), - ), - ), - { concurrency: "unbounded" }, - ); - yield* directory.refreshAccountMemberships(session.accountId); + const organizations = yield* directory.listUserOrganizations(session.accountId); return { - organizations: organizations.filter((org): org is NonNullable => org !== null), + organizations, activeOrganizationId: session.organizationId, }; }), @@ -249,6 +241,14 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const org = yield* workos.createOrganization(name); yield* workos.createMembership(org.id, session.accountId, "admin"); yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); + yield* users.use((s) => + s.upsertMembership({ + accountId: session.accountId, + organizationId: org.id, + status: "active", + roleSlug: "admin", + }), + ); // Try to attach the new org to the current session. This can fail // (or silently return a session still scoped to the old org) when diff --git a/apps/cloud/src/identity/directory.test.ts b/apps/cloud/src/identity/directory.test.ts index d89c28960..5092bf9f3 100644 --- a/apps/cloud/src/identity/directory.test.ts +++ b/apps/cloud/src/identity/directory.test.ts @@ -16,16 +16,25 @@ const activeMembership: IdentityMembership = { }; type LocalMembership = { + readonly externalId?: string | null; readonly accountId: string; readonly organizationId: string; readonly status: string; readonly roleSlug: string; }; +type LocalAccount = { + readonly id: string; + readonly email: string | null; + readonly name: string | null; + readonly avatarUrl: string | null; +}; const makeDirectory = (options: { + readonly accounts?: ReadonlyArray; readonly localMemberships?: ReadonlyArray; readonly providerMemberships?: ReadonlyArray; }) => { + const accounts = new Map((options.accounts ?? []).map((account) => [account.id, account])); const localMemberships = [...(options.localMemberships ?? [])]; const providerMemberships = options.providerMemberships ?? [activeMembership]; let providerRefreshes = 0; @@ -34,23 +43,30 @@ const makeDirectory = (options: { use: (fn: (store: { getOrganization: (id: string) => Promise; upsertOrganization: (input: IdentityOrganization) => Promise; + getAccount: (id: string) => Promise; getMembership: ( accountId: string, organizationId: string, ) => Promise; listMembershipsForAccount: (accountId: string) => Promise>; + listMembershipsForOrganization: ( + organizationId: string, + ) => Promise>; upsertMembership: (input: LocalMembership) => Promise; }) => Promise) => Effect.promise(() => fn({ getOrganization: async (id) => (id === org.id ? org : null), upsertOrganization: async (input) => input, + getAccount: async (id) => accounts.get(id) ?? null, getMembership: async (accountId, organizationId) => localMemberships.find( (m) => m.accountId === accountId && m.organizationId === organizationId, ) ?? null, listMembershipsForAccount: async (accountId) => localMemberships.filter((m) => m.accountId === accountId), + listMembershipsForOrganization: async (organizationId) => + localMemberships.filter((m) => m.organizationId === organizationId), upsertMembership: async (input) => { localMemberships.push(input); return input; @@ -138,4 +154,42 @@ describe("IdentityDirectory", () => { expect(directory.providerRefreshes).toBe(1); }), ); + + it.effect("lists organization members from local accounts and memberships", () => + Effect.gen(function* () { + const directory = makeDirectory({ + accounts: [ + { + id: "user_1", + email: "admin@test.com", + name: "Admin", + avatarUrl: null, + }, + ], + localMemberships: [{ ...activeMembership, externalId: "mem_1" }], + }); + + const members = yield* Effect.provide( + Effect.gen(function* () { + const service = yield* IdentityDirectory; + return yield* service.listOrganizationMembers("org_1"); + }), + directory.layer, + ); + + expect(members).toEqual([ + { + id: "mem_1", + accountId: "user_1", + organizationId: "org_1", + status: "active", + roleSlug: "admin", + email: "admin@test.com", + name: "Admin", + avatarUrl: null, + lastActiveAt: null, + }, + ]); + }), + ); }); diff --git a/apps/cloud/src/identity/directory.ts b/apps/cloud/src/identity/directory.ts index 4e736b78a..1742c4e94 100644 --- a/apps/cloud/src/identity/directory.ts +++ b/apps/cloud/src/identity/directory.ts @@ -4,6 +4,7 @@ import { UserStoreService } from "../auth/context"; import { WorkOSError, type UserStoreError } from "../auth/errors"; import { IdentityProvider } from "./provider"; import type { IdentityMembership, IdentityOrganization } from "./types"; +import type { IdentityMemberProfile } from "./types"; export class IdentityDirectory extends Context.Tag("@executor/cloud/IdentityDirectory")< IdentityDirectory, @@ -18,6 +19,9 @@ export class IdentityDirectory extends Context.Tag("@executor/cloud/IdentityDire readonly listUserOrganizations: ( accountId: string, ) => Effect.Effect, UserStoreError | WorkOSError>; + readonly listOrganizationMembers: ( + organizationId: string, + ) => Effect.Effect, UserStoreError>; readonly requireRole: ( accountId: string, organizationId: string, @@ -120,6 +124,33 @@ export class IdentityDirectory extends Context.Tag("@executor/cloud/IdentityDire fresh.filter((membership) => membership.status === "active"), ); }), + listOrganizationMembers: (organizationId) => + Effect.gen(function* () { + const memberships = yield* users.use((store) => + store.listMembershipsForOrganization(organizationId), + ); + return yield* Effect.all( + memberships.map((membership) => + Effect.gen(function* () { + const account = yield* users.use((store) => + store.getAccount(membership.accountId), + ); + return { + id: membership.externalId ?? `${membership.accountId}:${membership.organizationId}`, + accountId: membership.accountId, + organizationId: membership.organizationId, + status: membership.status, + roleSlug: membership.roleSlug, + email: account?.email ?? "", + name: account?.name ?? null, + avatarUrl: account?.avatarUrl ?? null, + lastActiveAt: null, + } satisfies IdentityMemberProfile; + }), + ), + { concurrency: 5 }, + ); + }), requireRole: (accountId, organizationId, roleSlug) => Effect.gen(function* () { const local = yield* users.use((store) => diff --git a/apps/cloud/src/org/api.ts b/apps/cloud/src/org/api.ts index 002b7c626..3afde5955 100644 --- a/apps/cloud/src/org/api.ts +++ b/apps/cloud/src/org/api.ts @@ -89,6 +89,7 @@ export class OrgApi extends HttpApiGroup.make("org") .add( HttpApiEndpoint.get("listMembers")`/org/members` .addSuccess(OrgMembersResponse) + .addError(UserStoreError) .addError(WorkOSError), ) .add( diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index bf8d4b55e..2cce3de47 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -58,9 +58,9 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => .handle("listMembers", () => Effect.gen(function* () { const auth = yield* AuthContext; - const identity = yield* IdentityProvider; + const directory = yield* IdentityDirectory; - const memberships = yield* identity.listOrganizationMembers(auth.organizationId); + const memberships = yield* directory.listOrganizationMembers(auth.organizationId); const members = memberships.map((m) => ({ id: m.id,