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
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 @@ -89,6 +89,7 @@ describe("protected API key auth", () => {
);

expect(identity).toEqual({
kind: "member",
accountId: "user_123",
organizationId: "org_123",
organizationName: "Org org_123",
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/api/protected-jwt-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("protected JWT (device-login) auth", () => {
const identity = yield* run(request(token), config);

expect(identity).toEqual({
kind: "member",
accountId: "user_123",
organizationId: "org_123",
organizationName: "Org org_123",
Expand Down
24 changes: 15 additions & 9 deletions apps/cloud/src/auth/org-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,25 @@ describe("org-level API keys", () => {
}),
);

it.effect("are REJECTED on the product surface rather than bound to a subject", () =>
it.effect("resolve on the product surface as a platform principal, never a subject", () =>
Effect.gen(function* () {
// THE security property of the api-key half: a product request carrying
// an org key must fail, not silently act as some user.
const error = yield* Effect.flip(
resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(Effect.provide(layers)),
// THE security property of the api-key half, updated for platform reads:
// a product request carrying an org key resolves to the NEUTRAL platform
// shape — which the shared middleware routes to the subject-less,
// GET-only platform executor — and must never carry an accountId a
// handler could bind as an acting member.
const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(
Effect.provide(layers),
);

expect(error).toMatchObject({
_tag: "Unauthorized",
code: "invalid_api_key",
message: "Organization API keys cannot be used on this endpoint",
expect(principal).toEqual({
kind: "platform",
organizationId: "org_123",
organizationName: "Org org_123",
organizationSlug: "org-slug-org_123",
keyId: "api_key_org",
});
expect(principal).not.toHaveProperty("accountId");
}),
);

Expand Down
53 changes: 36 additions & 17 deletions apps/cloud/src/auth/workos-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ import {
Unauthorized,
Unavailable,
} from "@executor-js/api/server";
import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";
import type {
FailureRenderingStrategy,
IdentityFailure,
PlatformPrincipal,
Principal,
ResolvedPrincipal,
} from "@executor-js/api/server";

import { ApiKeyService } from "./api-keys";
import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
Expand Down Expand Up @@ -104,14 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = {
code: "no_organization",
message: "No organization in access token",
};
// An org-level key resolves to the PLATFORM view, which has no acting member.
// The product endpoints are bound to one subject, so they reject it outright
// rather than inventing a subject for it to act as.
const ORG_KEY_ON_PRODUCT_SURFACE = {
code: "invalid_api_key",
message: "Organization API keys cannot be used on this endpoint",
};

// A bearer value with three dot-separated segments is a JWT (a WorkOS access
// token from the CLI device-login); anything else is treated as an API key.
// Same discriminator the MCP plane uses (`mcp/auth.ts`).
Expand Down Expand Up @@ -147,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);

return {
kind: "member",
accountId: verified.accountId,
organizationId: org.id,
organizationName: org.name,
Expand Down Expand Up @@ -245,6 +244,7 @@ export const resolveBearerAuth = (
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);

return {
kind: "member",
accountId: owner.accountId,
organizationId: org.id,
organizationName: org.name,
Expand All @@ -257,23 +257,36 @@ export const resolveBearerAuth = (
});

/**
* The PRODUCT-view bearer resolver: as {@link resolveBearerAuth}, but an
* org-level key is REJECTED rather than downgraded. The product endpoints are
* bound to one acting subject, so there is no honest way to serve them an
* org key — those belong at the `/admin/*` mount instead. (Kept the historical
* name; the re-export and resolver tests reference it.)
* The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral
* seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared
* middleware routes it to the subject-less, read-only platform executor
* (refusing non-GET up front). Previously the product plane rejected org keys
* outright; serving tenant-level READS to them is deliberate: the catalog,
* tools, policies, and org-owned connection listings are tenant-shared answers
* a machine credential can honestly receive, while everything subject-bound
* stays structurally out of reach (a platform executor binds no subject, so no
* member's personal rows resolve). (Kept the historical name; the re-export and
* resolver tests reference it.)
*/
export const resolveApiKeyPrincipal = (
request: Request,
jwt: JwtBearerConfig | null = null,
): Effect.Effect<
Principal | null,
ResolvedPrincipal | null,
Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
WorkOSClient | ApiKeyService | UserStoreService
> =>
Effect.gen(function* () {
const auth = yield* resolveBearerAuth(request, jwt);
if (isPlatformAuth(auth)) return yield* new Unauthorized(ORG_KEY_ON_PRODUCT_SURFACE);
if (isPlatformAuth(auth)) {
return {
kind: "platform",
organizationId: auth.organizationId,
organizationName: auth.organizationName,
...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }),
keyId: auth.keyId,
} satisfies PlatformPrincipal;
}
return auth;
});

Expand Down Expand Up @@ -304,6 +317,7 @@ export const resolveSessionPrincipal = (request: Request) =>
const org = yield* authorizeOrganizationSelector(session.userId, selector);
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
return {
kind: "member",
accountId: session.userId,
organizationId: org.id,
organizationName: org.name,
Expand All @@ -330,7 +344,7 @@ export const resolveProtectedPrincipal = (
request: Request,
jwt: JwtBearerConfig | null = null,
): Effect.Effect<
Principal,
ResolvedPrincipal,
Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
WorkOSClient | ApiKeyService | UserStoreService
> =>
Expand Down Expand Up @@ -411,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail
"service_unavailable",
"Service temporarily unavailable",
),
ReadOnlyCredential: renderIdentityFailure(
403,
"read_only_credential",
"Organization API keys are read-only",
),
}),
),
};
1 change: 1 addition & 0 deletions apps/cloud/src/org/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) =>
// Mirrors `org/handlers.ts` `requireAdmin`.
const requireAdmin = Effect.gen(function* () {
const auth = yield* AuthContext;
if (auth.accountId === null) return yield* new Forbidden();
const workos = yield* WorkOSClient;
const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
if (!current || current.role?.slug !== "admin") {
Expand Down
5 changes: 5 additions & 0 deletions apps/cloud/src/org/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api";

const requireAdmin = Effect.gen(function* () {
const auth = yield* AuthContext;
// This plane is mounted behind the session-only `orgAuthMiddleware`, so the
// caller is always a member — but `AuthContext.accountId` is nullable for the
// platform credential, and membership of "no member" is not a question worth
// asking WorkOS. Refuse rather than assert.
if (auth.accountId === null) return yield* new Forbidden();
const workos = yield* WorkOSClient;
const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
if (!currentMembership || currentMembership.role?.slug !== "admin") {
Expand Down
2 changes: 2 additions & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const principalFromAccessClaims = (
const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase());

return {
kind: "member",
accountId: sub || email || commonName,
organizationId: config.organizationId,
organizationName: config.organizationName,
Expand Down Expand Up @@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {
// fixed admin. Only when explicitly enabled (and the instance is otherwise
// unprotected). Mirrors the local app's single-user model.
const devPrincipal: Principal = {
kind: "member",
accountId: "dev",
organizationId: config.organizationId,
organizationName: config.organizationName,
Expand Down
1 change: 1 addition & 0 deletions apps/host-selfhost/src/auth/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette
// default to the seeded org rather than rejecting with NoOrganization.
const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId;
return {
kind: "member" as const,
accountId: resolved.user.id,
organizationId: resolvedOrganizationId,
organizationName,
Expand Down
9 changes: 7 additions & 2 deletions apps/host-selfhost/src/mcp/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Effect, Layer } from "effect";
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";

import { IdentityProvider } from "@executor-js/api/server";
import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server";
import {
authenticated,
McpAuthProvider,
Expand Down Expand Up @@ -233,12 +233,17 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I
}).pipe(Effect.orElseSucceed(() => null));

/** (b) The existing cookie / bearer-session / x-api-key path. The fallback's
* api `Principal` shape is byte-identical to host-mcp's `Principal`. */
* api `Principal` shape is byte-identical to host-mcp's `Principal`. The
* neutral seam can also resolve a platform credential, which self-host's
* identity never produces — narrowed away rather than asserted, so an MCP
* session can never bind to a subject-less credential if that changes. */
const authenticateSession = (request: Request): Effect.Effect<Principal | null> =>
fallback.authenticate(request).pipe(
Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)),
Effect.catchTags({
Unauthorized: () => Effect.succeed(null),
NoOrganization: () => Effect.succeed(null),
ReadOnlyCredential: () => Effect.succeed(null),
}),
);

Expand Down
Loading
Loading