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
1 change: 1 addition & 0 deletions apps/cloud/src/api/protected-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const stubUsers = Layer.succeed(UserStoreService)({
slug,
createdAt,
}),
deleteOrganizationCascade: async () => {},
}),
),
});
Expand Down
16 changes: 13 additions & 3 deletions apps/cloud/src/api/protected-jwt-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const stubUsers = Layer.succeed(UserStoreService)({
slug,
createdAt,
}),
deleteOrganizationCascade: async () => {},
}),
),
});
Expand Down Expand Up @@ -143,7 +144,10 @@ describe("protected JWT (device-login) auth", () => {

const error = yield* Effect.flip(run(request(token), config));

expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" });
expect(error).toMatchObject({
_tag: "Unauthorized",
code: "invalid_access_token",
});
}),
);

Expand All @@ -154,7 +158,10 @@ describe("protected JWT (device-login) auth", () => {

const error = yield* Effect.flip(run(request(token), config));

expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
expect(error).toMatchObject({
_tag: "NoOrganization",
code: "no_organization",
});
}),
);

Expand All @@ -166,7 +173,10 @@ describe("protected JWT (device-login) auth", () => {

const error = yield* Effect.flip(run(request(token), config));

expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
expect(error).toMatchObject({
_tag: "NoOrganization",
code: "no_organization",
});
}),
);
});
32 changes: 31 additions & 1 deletion apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ const CreateOrganizationResponse = Schema.Struct({
slug: Schema.String,
});

// Deleting an org requires re-typing its name (the label the UI shows) as a
// deliberate, non-automatable confirmation. Verified server-side too.
const DeleteOrganizationBody = Schema.Struct({
confirmName: Schema.String,
});

const DeleteOrganizationResponse = Schema.Struct({
success: Schema.Boolean,
});

// CLI device-login discovery (`executor login`). Tells the CLI where to run
// the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client
// to use. The CLI hits these provider endpoints directly, gets a WorkOS access
Expand Down Expand Up @@ -146,6 +156,15 @@ export class McpSessionForbiddenError extends Schema.TaggedErrorClass<McpSession
{ httpApiStatus: 403 },
) {}

// Refused org deletion: the caller is not an admin of the org, or the typed
// confirmation did not match. Deliberately one error for both so it never
// reveals which check failed.
export class OrganizationDeletionForbidden extends Schema.TaggedErrorClass<OrganizationDeletionForbidden>()(
"OrganizationDeletionForbidden",
{},
{ httpApiStatus: 403 },
) {}

export const AUTH_PATHS = {
login: "/api/auth/login",
logout: "/api/auth/logout",
Expand All @@ -168,7 +187,11 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
error: AuthErrors,
}),
)
.add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {}
.add(
HttpApiEndpoint.get("cliLogin", "/auth/cli-login", {
success: CliLoginResponse,
}),
) {}

/** Session auth endpoints — require a logged-in user, may not have an org */
export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
Expand All @@ -192,6 +215,13 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
error: AuthErrors,
}),
)
.add(
HttpApiEndpoint.post("deleteOrganization", "/auth/delete-organization", {
payload: DeleteOrganizationBody,
success: DeleteOrganizationResponse,
error: [...AuthErrors, NoOrganization, OrganizationDeletionForbidden],
}),
)
.add(
HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", {
success: PendingInvitationsResponse,
Expand Down
87 changes: 84 additions & 3 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CloudAuthPublicApi,
McpExecutionNotFoundError,
McpSessionForbiddenError,
OrganizationDeletionForbidden,
} from "./api";
import { NoOrganization } from "@executor-js/api/server";
// Pure constants/codec module (no React) — safe in the backend graph.
Expand Down Expand Up @@ -242,7 +243,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
}

if (!sealedSession) {
return HttpServerResponse.text("Failed to create session", { status: 500 });
return HttpServerResponse.text("Failed to create session", {
status: 500,
});
}

return deleteResponseCookie(
Expand Down Expand Up @@ -323,7 +326,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
const organizations = yield* Effect.all(
memberships.data.map((m) =>
resolveOrganization(m.organizationId).pipe(
Effect.map((org) => ({ id: org.id, name: org.name, slug: org.slug })),
Effect.map((org) => ({
id: org.id,
name: org.name,
slug: org.slug,
})),
Effect.orElseSucceed(() => null),
),
),
Expand Down Expand Up @@ -354,7 +361,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
activeMemberships.map((membership) =>
autumn
.use((client) =>
client.customers.getOrCreate({ customerId: membership.organizationId }),
client.customers.getOrCreate({
customerId: membership.organizationId,
}),
)
.pipe(
Effect.map((customer) =>
Expand Down Expand Up @@ -410,6 +419,78 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
return { id: org.id, name: org.name, slug: mirrored.slug };
}),
)
.handle("deleteOrganization", ({ payload }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
const users = yield* UserStoreService;
const autumn = yield* AutumnService;

// Target the caller's currently-selected org (honors the org-selector
// header, same as the other org-scoped auth handlers). NoOrganization
// when the session has no org to act on.
const session = yield* requireSelectedOrganization;
const organizationId = session.organizationId;

// Admin-only. Live WorkOS check so a member removed/demoted moments
// ago can't delete the workspace. A pending admin invite is not an
// active admin, so require active status too.
const membership = yield* workos.getUserOrgMembership(organizationId, session.accountId);
if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") {
return yield* new OrganizationDeletionForbidden();
}

// The typed confirmation must match the org's current name — the same
// label the settings page shows. Trimmed on both sides.
const org = yield* users.use((s) => s.getOrganization(organizationId));
if (!org || payload.confirmName.trim() !== org.name.trim()) {
return yield* new OrganizationDeletionForbidden();
}

// WorkOS FIRST. Once the org is gone there, membership authorization
// fails for every member, so the workspace is truly deleted even if a
// later local step lags (leftover local rows become unreachable, not
// user-visible). The reverse order risks the org resurrecting as an
// empty workspace when a later request re-mirrors it with a new slug.
yield* workos.deleteOrganization(organizationId);

// Purge all local tenant data, secrets, and the identity mirror
// (cascades local memberships) in one transaction. If this fails
// after the WorkOS delete already succeeded, the org is gone for
// everyone (unreachable) but its secrets/tenant rows linger orphaned —
// alert loudly so that window gets swept, then surface the failure.
yield* users
.use((s) => s.deleteOrganizationCascade(organizationId))
.pipe(
Effect.tapError((error) =>
Effect.logError(
"deleteOrganization: WorkOS org deleted but local purge failed, tenant data and secrets orphaned",
{ organizationId, error },
),
),
);

// Cancel billing. Best-effort: the org is already deleted, so a
// lingering Autumn customer is a billing loose end (log loudly) rather
// than a correctness failure that should 500 the caller.
yield* autumn
.use((client) => client.customers.delete({ customerId: organizationId }))
.pipe(
Effect.catchTag("AutumnError", (error) =>
Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {
organizationId,
error,
}),
),
);

// The caller's session is pinned to the now-deleted org — clear it so
// the browser bounces to login and rehydrates to another membership
// (or the create-org screen when they have none left).
(yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS);

return { success: true };
}),
)
.handle("pendingInvitations", () =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
Expand Down
19 changes: 16 additions & 3 deletions apps/cloud/src/auth/org-selector-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ const stubWorkOS = Layer.succeed(
get: (_t, prop) => {
if (prop === "authenticateRequest") {
return () =>
Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG });
Effect.succeed({
userId: MEMBER,
email: "u@e2e.test",
organizationId: SESSION_ORG,
});
}
if (prop === "listUserMemberships") {
return (userId: string) =>
Expand Down Expand Up @@ -66,7 +70,12 @@ const stubUsers = Layer.succeed(UserStoreService)({
slug: org.id,
createdAt,
}),
getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }),
getOrganization: async (id: string) => ({
id,
name: `Org ${id}`,
slug: id,
createdAt,
}),
// The URL slug maps to URL_ORG (the member's other org); any other slug
// maps to an org the caller is NOT a member of, so membership rejects it.
getOrganizationBySlug: async (slug: string) => ({
Expand All @@ -75,6 +84,7 @@ const stubUsers = Layer.succeed(UserStoreService)({
slug,
createdAt,
}),
deleteOrganizationCascade: async () => {},
}),
),
});
Expand Down Expand Up @@ -117,7 +127,10 @@ describe("resolveSessionPrincipal · URL org selector", () => {
// The slug resolves to a real org id, but membership is re-checked — a
// slug is a selector, not a trust boundary, so a non-member is rejected.
const error = yield* Effect.flip(
run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }),
run({
cookie: "wos-session=x",
"x-executor-organization": "outsider-slug",
}),
);
expect(error).toMatchObject({ _tag: "NoOrganization" });
}),
Expand Down
6 changes: 6 additions & 0 deletions apps/cloud/src/auth/user-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { generateOrgSlug } from "@executor-js/api";

import { accounts, organizations } from "../db/schema";
import type { DrizzleDb } from "../db/db";
import { purgeOrganizationData } from "../db/org-deletion";

export type Account = typeof accounts.$inferSelect;
export type Organization = typeof organizations.$inferSelect;
Expand Down Expand Up @@ -96,5 +97,10 @@ export const makeUserStore = (db: DrizzleDb) => {
const rows = await db.select().from(organizations).where(eq(organizations.slug, slug));
return rows[0] ?? null;
},

// Permanently delete an org and everything it owns (tenant data, secrets,
// identity mirror + cascaded memberships) in a single transaction. Callers
// sequence the external WorkOS/Autumn deletions around this.
deleteOrganizationCascade: (id: string) => purgeOrganizationData(db, id),
};
};
26 changes: 23 additions & 3 deletions apps/cloud/src/auth/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,11 @@ const make = Effect.gen(function* () {
});
}

const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) });
const workos = new WorkOS({
apiKey,
clientId,
...workosApiUrlOptions(env.WORKOS_API_URL),
});

const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
withServiceLogging(
Expand Down Expand Up @@ -389,7 +393,11 @@ const make = Effect.gen(function* () {

/** Update a membership's role. */
updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>
use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })),
use((wos) =>
wos.userManagement.updateOrganizationMembership(membershipId, {
roleSlug,
}),
),

/** List available roles for an organization. */
listOrgRoles: (organizationId: string) =>
Expand All @@ -401,7 +409,19 @@ const make = Effect.gen(function* () {

/** Update an organization. */
updateOrganization: (organizationId: string, name: string) =>
use((wos) => wos.organizations.updateOrganization({ organization: organizationId, name })),
use((wos) =>
wos.organizations.updateOrganization({
organization: organizationId,
name,
}),
),

/**
* Delete an organization. Cascades in WorkOS: the org's memberships,
* invitations, and domains go with it, so every member loses access.
*/
deleteOrganization: (organizationId: string) =>
use((wos) => wos.organizations.deleteOrganization(organizationId)),

/** Generate an Admin Portal link for domain verification. */
generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>
Expand Down
Loading
Loading