Skip to content
Closed
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
26 changes: 26 additions & 0 deletions apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,32 @@ export const workosAccountProvider: Layer.Layer<
return { success: true };
}),

// Org keys read every user in the tenant through the `/admin/*` plane, so
// both paths are admin-gated — unlike personal keys, which any member may
// mint for themselves.
listOrgApiKeys: (headers) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization(headers);
yield* requireAdmin(session.accountId, org.id);
const keys = yield* apiKeys
.listOrgKeys({ organizationId: org.id })
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
return { apiKeys: keys };
}),

createOrgApiKey: (headers, name) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization(headers);
yield* requireAdmin(session.accountId, org.id);
const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
if (!trimmed) {
return yield* new AccountError({ message: "API key name is required" });
}
return yield* apiKeys
.createOrgKey({ organizationId: org.id, name: trimmed })
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
}),

listMembers: (headers) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization(headers);
Expand Down
203 changes: 203 additions & 0 deletions apps/cloud/src/admin/admin-users-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// ---------------------------------------------------------------------------
// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers`
// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`.
//
// TWO credentials reach this plane, and they are the two an operator actually
// has:
// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS
// validated it and reported which org owns it, and there is no member
// behind it to check membership for. This is the machine credential
// (a customer's backend calling us).
// 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership`
// whose role slug is `admin` AND whose status is `active`, matching the
// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) —
// a pending admin invite is not an admin.
// A plain member session, or a USER-scoped api key, is refused: both name one
// acting member, and this plane deliberately serves the whole tenant.
//
// The executor is built by `makePlatformExecutor` — `{ tenant, subject:
// undefined, platformView: true }` — so the reads are tenant-wide and read-only
// by storage policy, and no `subject` row is minted for the caller.
//
// Cross-tenant isolation is structural, not a check in this file: the tenant is
// taken from the resolved credential (the key's own org, or the session's
// authorized org), never from client input, and `reach: "tenant"` still filters
// every query by that tenant.
// ---------------------------------------------------------------------------

import { HttpRouter } from "effect/unstable/http";
import { Effect, Layer } from "effect";

import {
AdminUsersProvider,
DbProvider,
HostConfig,
PluginsProvider,
listAdminUserConnections,
listAdminUsers,
listAdminUsersWithConnections,
makeAdminUsersApiLayer,
makePlatformExecutor,
platformViewOf,
requestScopedMiddleware,
type AdminUsersHeaders,
} from "@executor-js/api/server";
import { AdminUsersError, AdminUsersForbidden, AdminUsersUnauthorized } from "@executor-js/api";
import type { Executor } from "@executor-js/sdk";

import { ApiKeyService } from "../auth/api-keys";
import { UserStoreService } from "../auth/context";
import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
import { WorkOSClient } from "../auth/workos";
import { DbService } from "../db/db";
import { CloudExecutionSeamsLayer } from "../engine/execution-stack";

/**
* Resolve the tenant this request may read, or fail with the neutral 401/403.
*
* Returns only the organization id: nothing downstream needs to know WHICH of
* the two credentials got the caller here, and keeping the acting member out of
* the return value means no admin read can accidentally become subject-scoped.
*/
const authorizeTenant = (
request: Request,
): Effect.Effect<
string,
AdminUsersUnauthorized | AdminUsersForbidden,
WorkOSClient | ApiKeyService | UserStoreService
> =>
Effect.gen(function* () {
// (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`,
// which rejects org keys for the product plane) is what distinguishes an
// org key from a user key.
const bearer = yield* resolveBearerAuth(request).pipe(
// Every rejected-credential and infra failure collapses to one refusal:
// this plane must not report whether a key exists, belongs to another
// org, or merely lacks privilege.
Effect.catchCause(() => Effect.succeed(null)),
);
if (bearer !== null) {
if (isPlatformAuth(bearer)) return bearer.organizationId;
// A user-scoped key authenticated fine but names one member; the platform
// plane has no honest way to serve it.
return yield* new AdminUsersForbidden();
}

// (2) The session path: a live admin membership in the selected org.
const workos = yield* WorkOSClient;
const session = yield* workos
.authenticateRequest(request)
.pipe(Effect.catchCause(() => Effect.succeed(null)));
if (!session) return yield* new AdminUsersUnauthorized();

const selector = orgSelectorFromRequest(request) ?? session.organizationId;
if (!selector) return yield* new AdminUsersForbidden();
// Re-checks live membership, so the org selector header can only ever name
// an org the caller already belongs to.
const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe(
Effect.catchCause(() => Effect.succeed(null)),
);
if (!org) return yield* new AdminUsersForbidden();

const membership = yield* workos
.getUserOrgMembership(org.id, session.userId)
.pipe(Effect.catchCause(() => Effect.succeed(null)));
// A pending admin invite is not an active admin — require both.
if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") {
return yield* new AdminUsersForbidden();
}
return org.id;
});

/**
* Authorize, then run `body` against the tenant's platform view.
*
* The executor is built per request and closed after, like every other cloud
* execution stack: it holds the per-request postgres socket, which Cloudflare's
* I/O isolation forbids sharing across requests.
*/
const withPlatformView = <A>(
headers: AdminUsersHeaders,
body: (executor: Executor) => Effect.Effect<A, AdminUsersError>,
): Effect.Effect<
A,
AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
> =>
Effect.gen(function* () {
const organizationId = yield* authorizeTenant(
new Request("https://admin.invalid", { headers }),
);
const executor = yield* makePlatformExecutor(organizationId).pipe(
Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
);
return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore));
});

/**
* Cloud's `AdminUsersProvider`, built per request so the platform executor
* closes over the per-request postgres socket.
*/
export const workosAdminUsersProvider: Layer.Layer<
AdminUsersProvider,
never,
WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
> = Layer.effect(AdminUsersProvider)(
Effect.gen(function* () {
const context = yield* Effect.context<
WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
>();
return AdminUsersProvider.of({
listUsers: (headers, options) =>
withPlatformView(headers, (executor) =>
platformViewOf(executor).pipe(Effect.flatMap((admin) => listAdminUsers(admin, options))),
).pipe(Effect.provideContext(context)),
listUsersWithConnections: (headers, options) =>
withPlatformView(headers, (executor) =>
platformViewOf(executor).pipe(
Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options)),
),
).pipe(Effect.provideContext(context)),
listUserConnections: (headers, externalId) =>
withPlatformView(headers, (executor) =>
platformViewOf(executor).pipe(
Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)),
),
).pipe(Effect.provideContext(context)),
});
}),
);

// Builds the provider per request, providing it to the handlers. Long-lived
// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
// per-request `DbService`/`UserStoreService` (and the execution seams built
// over them) are supplied by the combined `requestScopedMiddleware`.
const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
Effect.gen(function* () {
const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
return (httpEffect) =>
Effect.gen(function* () {
// Built inside the request body so the execution seams close over the
// per-request postgres socket.
const provider = yield* Effect.provide(
AdminUsersProvider.asEffect(),
workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)),
);
return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider);
}).pipe(Effect.provideContext(longLived));
}),
);

/**
* The cloud admin-users route layer, mounted as an app extension under the same
* `/api` prefix as the rest of the cloud router.
*/
export const makeCloudAdminUsersRoutes = (
rsLive: Layer.Layer<DbService | UserStoreService>,
options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
) =>
makeAdminUsersApiLayer(
AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer,
options,
);
2 changes: 2 additions & 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 @@ -23,6 +23,8 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({
listUserKeys: () => Effect.succeed([]),
createUserKey: () => Effect.die("protected API auth test does not create API keys"),
revokeUserKey: () => Effect.void,
listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
});

const stubWorkOS = Layer.succeed(
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/api/protected-jwt-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({
listUserKeys: () => Effect.succeed([]),
createUserKey: () => Effect.die("JWT auth test does not create API keys"),
revokeUserKey: () => Effect.void,
listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
});

const stubWorkOS = Layer.succeed(
Expand Down
Loading
Loading