Skip to content

Commit 1f4741a

Browse files
authored
Serve tenant-level product reads to organization API keys (#1544)
* Serve tenant-level product reads to organization API keys * Address review: exclude OAuth callback from platform reads, skip catalog sync on the platform view, discriminate the principal union
1 parent 676af1a commit 1f4741a

19 files changed

Lines changed: 576 additions & 57 deletions

apps/cloud/src/api/protected-api-key-auth.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ describe("protected API key auth", () => {
8989
);
9090

9191
expect(identity).toEqual({
92+
kind: "member",
9293
accountId: "user_123",
9394
organizationId: "org_123",
9495
organizationName: "Org org_123",

apps/cloud/src/api/protected-jwt-auth.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ describe("protected JWT (device-login) auth", () => {
110110
const identity = yield* run(request(token), config);
111111

112112
expect(identity).toEqual({
113+
kind: "member",
113114
accountId: "user_123",
114115
organizationId: "org_123",
115116
organizationName: "Org org_123",

apps/cloud/src/auth/org-api-key-auth.node.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,19 +124,25 @@ describe("org-level API keys", () => {
124124
}),
125125
);
126126

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

135-
expect(error).toMatchObject({
136-
_tag: "Unauthorized",
137-
code: "invalid_api_key",
138-
message: "Organization API keys cannot be used on this endpoint",
138+
expect(principal).toEqual({
139+
kind: "platform",
140+
organizationId: "org_123",
141+
organizationName: "Org org_123",
142+
organizationSlug: "org-slug-org_123",
143+
keyId: "api_key_org",
139144
});
145+
expect(principal).not.toHaveProperty("accountId");
140146
}),
141147
);
142148

apps/cloud/src/auth/workos-auth-provider.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ import {
3838
Unauthorized,
3939
Unavailable,
4040
} from "@executor-js/api/server";
41-
import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";
41+
import type {
42+
FailureRenderingStrategy,
43+
IdentityFailure,
44+
PlatformPrincipal,
45+
Principal,
46+
ResolvedPrincipal,
47+
} from "@executor-js/api/server";
4248

4349
import { ApiKeyService } from "./api-keys";
4450
import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
@@ -104,14 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = {
104110
code: "no_organization",
105111
message: "No organization in access token",
106112
};
107-
// An org-level key resolves to the PLATFORM view, which has no acting member.
108-
// The product endpoints are bound to one subject, so they reject it outright
109-
// rather than inventing a subject for it to act as.
110-
const ORG_KEY_ON_PRODUCT_SURFACE = {
111-
code: "invalid_api_key",
112-
message: "Organization API keys cannot be used on this endpoint",
113-
};
114-
115113
// A bearer value with three dot-separated segments is a JWT (a WorkOS access
116114
// token from the CLI device-login); anything else is treated as an API key.
117115
// Same discriminator the MCP plane uses (`mcp/auth.ts`).
@@ -147,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
147145
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
148146

149147
return {
148+
kind: "member",
150149
accountId: verified.accountId,
151150
organizationId: org.id,
152151
organizationName: org.name,
@@ -245,6 +244,7 @@ export const resolveBearerAuth = (
245244
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
246245

247246
return {
247+
kind: "member",
248248
accountId: owner.accountId,
249249
organizationId: org.id,
250250
organizationName: org.name,
@@ -257,23 +257,36 @@ export const resolveBearerAuth = (
257257
});
258258

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

@@ -304,6 +317,7 @@ export const resolveSessionPrincipal = (request: Request) =>
304317
const org = yield* authorizeOrganizationSelector(session.userId, selector);
305318
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
306319
return {
320+
kind: "member",
307321
accountId: session.userId,
308322
organizationId: org.id,
309323
organizationName: org.name,
@@ -330,7 +344,7 @@ export const resolveProtectedPrincipal = (
330344
request: Request,
331345
jwt: JwtBearerConfig | null = null,
332346
): Effect.Effect<
333-
Principal,
347+
ResolvedPrincipal,
334348
Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
335349
WorkOSClient | ApiKeyService | UserStoreService
336350
> =>
@@ -411,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail
411425
"service_unavailable",
412426
"Service temporarily unavailable",
413427
),
428+
ReadOnlyCredential: renderIdentityFailure(
429+
403,
430+
"read_only_credential",
431+
"Organization API keys are read-only",
432+
),
414433
}),
415434
),
416435
};

apps/cloud/src/org/handlers.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) =>
7070
// Mirrors `org/handlers.ts` `requireAdmin`.
7171
const requireAdmin = Effect.gen(function* () {
7272
const auth = yield* AuthContext;
73+
if (auth.accountId === null) return yield* new Forbidden();
7374
const workos = yield* WorkOSClient;
7475
const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
7576
if (!current || current.role?.slug !== "admin") {

apps/cloud/src/org/handlers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api";
1717

1818
const requireAdmin = Effect.gen(function* () {
1919
const auth = yield* AuthContext;
20+
// This plane is mounted behind the session-only `orgAuthMiddleware`, so the
21+
// caller is always a member — but `AuthContext.accountId` is nullable for the
22+
// platform credential, and membership of "no member" is not a question worth
23+
// asking WorkOS. Refuse rather than assert.
24+
if (auth.accountId === null) return yield* new Forbidden();
2025
const workos = yield* WorkOSClient;
2126
const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
2227
if (!currentMembership || currentMembership.role?.slug !== "admin") {

apps/host-cloudflare/src/auth/cloudflare-access.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const principalFromAccessClaims = (
3838
const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase());
3939

4040
return {
41+
kind: "member",
4142
accountId: sub || email || commonName,
4243
organizationId: config.organizationId,
4344
organizationName: config.organizationName,
@@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {
6869
// fixed admin. Only when explicitly enabled (and the instance is otherwise
6970
// unprotected). Mirrors the local app's single-user model.
7071
const devPrincipal: Principal = {
72+
kind: "member",
7173
accountId: "dev",
7274
organizationId: config.organizationId,
7375
organizationName: config.organizationName,

apps/host-selfhost/src/auth/identity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette
6767
// default to the seeded org rather than rejecting with NoOrganization.
6868
const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId;
6969
return {
70+
kind: "member" as const,
7071
accountId: resolved.user.id,
7172
organizationId: resolvedOrganizationId,
7273
organizationName,

apps/host-selfhost/src/mcp/auth.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Effect, Layer } from "effect";
22
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";
33

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

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

0 commit comments

Comments
 (0)