Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/cloud/src/auth/handlers.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -29,13 +30,17 @@ const makeAuthFetch = (workos: Partial<WorkOSAuth["Type"]>) => {
const UserStoreTest = Layer.succeed(UserStoreService, {
use: <A>() => 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(
HttpApiBuilder.api(TestAuthPublicApi).pipe(
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),
Expand Down
26 changes: 13 additions & 13 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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<typeof org> => org !== null),
organizations,
activeOrganizationId: session.organizationId,
};
}),
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions apps/cloud/src/identity/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalAccount>;
readonly localMemberships?: ReadonlyArray<LocalMembership>;
readonly providerMemberships?: ReadonlyArray<IdentityMembership>;
}) => {
const accounts = new Map((options.accounts ?? []).map((account) => [account.id, account]));
const localMemberships = [...(options.localMemberships ?? [])];
const providerMemberships = options.providerMemberships ?? [activeMembership];
let providerRefreshes = 0;
Expand All @@ -34,23 +43,30 @@ const makeDirectory = (options: {
use: <A>(fn: (store: {
getOrganization: (id: string) => Promise<IdentityOrganization | null>;
upsertOrganization: (input: IdentityOrganization) => Promise<IdentityOrganization>;
getAccount: (id: string) => Promise<LocalAccount | null>;
getMembership: (
accountId: string,
organizationId: string,
) => Promise<LocalMembership | null>;
listMembershipsForAccount: (accountId: string) => Promise<ReadonlyArray<LocalMembership>>;
listMembershipsForOrganization: (
organizationId: string,
) => Promise<ReadonlyArray<LocalMembership>>;
upsertMembership: (input: LocalMembership) => Promise<LocalMembership>;
}) => Promise<A>) =>
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;
Expand Down Expand Up @@ -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,
},
]);
}),
);
});
31 changes: 31 additions & 0 deletions apps/cloud/src/identity/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +19,9 @@ export class IdentityDirectory extends Context.Tag("@executor/cloud/IdentityDire
readonly listUserOrganizations: (
accountId: string,
) => Effect.Effect<ReadonlyArray<IdentityOrganization>, UserStoreError | WorkOSError>;
readonly listOrganizationMembers: (
organizationId: string,
) => Effect.Effect<ReadonlyArray<IdentityMemberProfile>, UserStoreError>;
readonly requireRole: (
accountId: string,
organizationId: string,
Expand Down Expand Up @@ -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) =>
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/org/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class OrgApi extends HttpApiGroup.make("org")
.add(
HttpApiEndpoint.get("listMembers")`/org/members`
.addSuccess(OrgMembersResponse)
.addError(UserStoreError)
.addError(WorkOSError),
)
.add(
Expand Down
4 changes: 2 additions & 2 deletions apps/cloud/src/org/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading