Skip to content
Merged
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
6 changes: 0 additions & 6 deletions .changeset/quiet-admin-unlock.md

This file was deleted.

3 changes: 1 addition & 2 deletions apps/cloud/src/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { UserStoreService } from "../auth/context";
import { WorkOsMirror } from "../auth/workos-mirror";
import { sessionFromSealed, type Session } from "../auth/middleware";
import { WorkOSClient } from "../auth/workos";
import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof";
import { AutumnService } from "../extensions/billing/service";
import { DbService } from "../db/db";
import { AccountCaller, workosAccountProvider } from "./workos-account-service";
Expand Down Expand Up @@ -70,7 +69,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{
const request = yield* HttpServerRequest.HttpServerRequest;
const cookieValue = request.cookies["wos-session"] ?? "";
const resolved = yield* workos
.authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE])
.authenticateSealedSession(cookieValue)
.pipe(Effect.orElseSucceed(() => null));
// The account API never re-sets the cookie, so the fallback sealed
// session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie).
Expand Down
1 change: 0 additions & 1 deletion apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ const session = (accountId: string) => ({
name: null,
avatarUrl: null,
organizationId: ORG,
adminVerified: true,
sealedSession: "sealed",
refreshedSession: null,
});
Expand Down
10 changes: 1 addition & 9 deletions apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,7 @@ export const workosAccountProvider: Layer.Layer<
// moments ago is denied as soon as the write-through or the Events
// reconciler has landed the change.
const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) =>
Effect.gen(function* () {
if (org.memberRole !== "admin") return yield* new AccountForbidden();
const session = yield* requireSession();
if (session.adminVerified !== true) {
return yield* new AccountForbidden({
message: "Verify your identity to use organization admin settings.",
});
}
});
org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden());

// Ownership check so an admin can't mutate a membership id from another
// org: the id must name a row the mirror holds for THIS org (any status —
Expand Down
36 changes: 4 additions & 32 deletions apps/cloud/src/admin/admin-users-api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const stubMirror = Layer.succeed(

// Only session authentication is served; membership is read from the mirror,
// so any other WorkOS call fails the test.
const stubWorkOS = (userId: string, adminVerified: boolean) =>
const stubWorkOS = (userId: string) =>
Layer.succeed(
WorkOSClient,
new Proxy({} as WorkOSClientService, {
Expand All @@ -144,7 +144,6 @@ const stubWorkOS = (userId: string, adminVerified: boolean) =>
return () =>
Effect.succeed({
userId,
adminVerified,
email: `${userId}@placeholder.test`,
organizationId: null,
});
Expand All @@ -154,52 +153,25 @@ const stubWorkOS = (userId: string, adminVerified: boolean) =>
}),
);

const authorizeAs = (userId: string, adminVerified = true, authorization?: string) =>
const authorizeAs = (userId: string) =>
authorizeTenant(
new Request("https://admin.invalid", {
headers: {
cookie: "wos-session=sealed",
[ORG_SELECTOR_HEADER]: ORG,
...(authorization === undefined ? {} : { authorization }),
},
headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG },
}),
).pipe(
Effect.provide(
Layer.mergeAll(
stubDirectory,
stubApiKeys,
stubUsers,
stubWorkOS(userId, adminVerified),
stubMirror,
),
Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror),
),
);

describe("authorizeTenant · admin session", () => {
it.effect("a bearer header cannot borrow a verified browser's cross-user access", () =>
Effect.gen(function* () {
for (const authorization of ["Bearer org_key", "Bearer user_key", "Bearer", "invalid"]) {
expect(yield* Effect.flip(authorizeAs("user_admin", true, authorization))).toBeInstanceOf(
AdminUsersForbidden,
);
}
}),
);
it.effect("an active admin resolves the selected org as the tenant", () =>
Effect.gen(function* () {
const tenant = yield* authorizeAs("user_admin");
expect(tenant).toBe(ORG);
}),
);

it.effect("an admin without a second factor is forbidden", () =>
Effect.gen(function* () {
expect(yield* Effect.flip(authorizeAs("user_admin", false))).toBeInstanceOf(
AdminUsersForbidden,
);
}),
);

it.effect("an active plain member is forbidden", () =>
Effect.gen(function* () {
const error = yield* Effect.flip(authorizeAs("user_member"));
Expand Down
50 changes: 41 additions & 9 deletions apps/cloud/src/admin/admin-users-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@
// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers`
// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`.
//
// Only an active admin browser session with a completed second factor reaches
// this plane. Bearer credentials retain ordinary product access, never cross-user
// access, even when accompanied by a verified browser cookie.
// 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 the caller's mirrored
// membership (the shared `MemberDirectory` over the local membership
// mirror) to carry the `admin` role AND `active` status, 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
Expand Down Expand Up @@ -44,8 +54,10 @@ import {
} from "@executor-js/api";
import type { Executor } from "@executor-js/sdk";

import { ApiKeyService } from "../auth/api-keys";
import { UserStoreService } from "../auth/context";
import { WorkOsMirror } from "../auth/workos-mirror";
import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
import { WorkOSClient } from "../auth/workos";
import { DbService } from "../db/db";
Expand All @@ -54,19 +66,37 @@ import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
/**
* Resolve the tenant this request may read, or fail with the neutral 401/403.
*
* Returns only the authorized organization id so admin reads remain tenant-scoped.
* 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.
* Exported for its test only.
*/
export const authorizeTenant = (
request: Request,
): Effect.Effect<
string,
AdminUsersUnauthorized | AdminUsersForbidden,
WorkOSClient | UserStoreService | MemberDirectory | WorkOsMirror
WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
> =>
Effect.gen(function* () {
if (request.headers.has("authorization")) return yield* new AdminUsersForbidden();
// (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: an active admin membership in the selected org,
// read from the mirror.
const workos = yield* WorkOSClient;
const session = yield* workos
.authenticateRequest(request)
Expand All @@ -85,7 +115,6 @@ export const authorizeTenant = (
);
if (!org) return yield* new AdminUsersForbidden();
if (org.memberRole !== "admin") return yield* new AdminUsersForbidden();
if (session.adminVerified !== true) return yield* new AdminUsersForbidden();
return org.id;
});

Expand All @@ -105,6 +134,7 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm
// way regardless of what `body` itself raises.
E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
| WorkOSClient
| ApiKeyService
| UserStoreService
| MemberDirectory
| WorkOsMirror
Expand Down Expand Up @@ -143,6 +173,7 @@ export const workosAdminUsersProvider: Layer.Layer<
AdminUsersProvider,
never,
| WorkOSClient
| ApiKeyService
| UserStoreService
| MemberDirectory
| WorkOsMirror
Expand All @@ -153,6 +184,7 @@ export const workosAdminUsersProvider: Layer.Layer<
Effect.gen(function* () {
const context = yield* Effect.context<
| WorkOSClient
| ApiKeyService
| UserStoreService
| MemberDirectory
| WorkOsMirror
Expand Down Expand Up @@ -201,15 +233,15 @@ export const workosAdminUsersProvider: Layer.Layer<
);

// Builds the provider per request, providing it to the handlers. Long-lived
// `WorkOSClient` come from the surrounding boot context; the
// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
// per-request `DbService`/`UserStoreService`/`MemberDirectory` (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>();
const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
return (httpEffect) =>
Effect.gen(function* () {
// Built inside the request body so the execution seams close over the
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/api/protected-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({
email: null,
name: null,
avatarUrl: null,
role: "admin",
role: "member",
status: "active" as const,
lastActiveAt: null,
}
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/api/protected-jwt-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({
email: null,
name: null,
avatarUrl: null,
role: "admin",
role: "member",
status: "active" as const,
lastActiveAt: null,
}
Expand Down
2 changes: 0 additions & 2 deletions apps/cloud/src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { UserStoreService } from "../auth/context";
import { WorkOsMirror } from "../auth/workos-mirror";
import { DbService } from "../db/db";
import { makeAccountApiLive } from "../account/account-api";
import { AdminMfaRoutes } from "../auth/admin-mfa-routes";

import { AutumnRoutesLive } from "../extensions/billing/route";
import { CloudDocsLive } from "../extensions/docs";
Expand Down Expand Up @@ -42,7 +41,6 @@ export const makeApiLive = (
Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
);
return Layer.mergeAll(
AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)),
makeNonProtectedApiLive(requestScopedLive),
makeOrgApiLive(requestScopedLive),
makeAccountApiLive(requestScopedLive),
Expand Down
Loading
Loading