History

Address review: exclude OAuth callback from platform reads, skip catalog sync on the platform view, discriminate the principal union

Rhys Sullivan committed 3 months ago

Commit de26d641 parent abbf44112 files · +107 −42Browse files

Unified · Split

12 files
  1. apps/cloud/src/api/protected-api-key-auth.node.test.ts +1 −0
  2. apps/cloud/src/api/protected-jwt-auth.node.test.ts +1 −0
  3. apps/cloud/src/auth/workos-auth-provider.ts +3 −0
  4. apps/host-cloudflare/src/auth/cloudflare-access.ts +2 −0
  5. apps/host-selfhost/src/auth/identity.ts +1 −0
  6. apps/host-selfhost/src/platform-credential.test.ts +17 −3
  7. apps/host-selfhost/src/testing/test-app.ts +2 −0
  8. apps/local/src/identity.ts +1 −0
  9. packages/core/api/src/server/execution-stack-middleware.ts +51 −26
  10. packages/core/api/src/server/execution-stack.ts +7 −6
  11. packages/core/api/src/server/identity.ts +14 −7
  12. packages/core/sdk/src/executor.ts +7 −0
apps/cloud/src/api/protected-api-key-auth.node.test.ts+1−0
@@ -89,6 +89,7 @@ describe("protected API key auth", () => { · 88 unmodified lines
89 );89 );
90 90
91 expect(identity).toEqual({91 expect(identity).toEqual({
92 kind: "member",
92 accountId: "user_123",93 accountId: "user_123",
93 organizationId: "org_123",94 organizationId: "org_123",
94 organizationName: "Org org_123",95 organizationName: "Org org_123",
apps/cloud/src/api/protected-jwt-auth.node.test.ts+1−0
@@ -110,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 109 unmodified lines
110 const identity = yield* run(request(token), config);110 const identity = yield* run(request(token), config);
111 111
112 expect(identity).toEqual({112 expect(identity).toEqual({
113 kind: "member",
113 accountId: "user_123",114 accountId: "user_123",
114 organizationId: "org_123",115 organizationId: "org_123",
115 organizationName: "Org org_123",116 organizationName: "Org org_123",
apps/cloud/src/auth/workos-auth-provider.ts+3−0
@@ -145,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 144 unmodified lines
145 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);145 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
146 146
147 return {147 return {
148 kind: "member",
148 accountId: verified.accountId,149 accountId: verified.accountId,
149 organizationId: org.id,150 organizationId: org.id,
150 organizationName: org.name,151 organizationName: org.name,
@@ -243,6 +244,7 @@ export const resolveBearerAuth = ( · 92 unmodified lines
243 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);244 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY);
244 245
245 return {246 return {
247 kind: "member",
246 accountId: owner.accountId,248 accountId: owner.accountId,
247 organizationId: org.id,249 organizationId: org.id,
248 organizationName: org.name,250 organizationName: org.name,
@@ -315,6 +317,7 @@ export const resolveSessionPrincipal = (request: Request) => · 66 unmodified lines
315 const org = yield* authorizeOrganizationSelector(session.userId, selector);317 const org = yield* authorizeOrganizationSelector(session.userId, selector);
316 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);318 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
317 return {319 return {
320 kind: "member",
318 accountId: session.userId,321 accountId: session.userId,
319 organizationId: org.id,322 organizationId: org.id,
320 organizationName: org.name,323 organizationName: org.name,
apps/host-cloudflare/src/auth/cloudflare-access.ts+2−0
@@ -38,6 +38,7 @@ export const principalFromAccessClaims = ( · 37 unmodified lines
38 const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase());38 const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase());
39 39
40 return {40 return {
41 kind: "member",
41 accountId: sub || email || commonName,42 accountId: sub || email || commonName,
42 organizationId: config.organizationId,43 organizationId: config.organizationId,
43 organizationName: config.organizationName,44 organizationName: config.organizationName,
@@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { · 24 unmodified lines
68 // fixed admin. Only when explicitly enabled (and the instance is otherwise69 // fixed admin. Only when explicitly enabled (and the instance is otherwise
69 // unprotected). Mirrors the local app's single-user model.70 // unprotected). Mirrors the local app's single-user model.
70 const devPrincipal: Principal = {71 const devPrincipal: Principal = {
72 kind: "member",
71 accountId: "dev",73 accountId: "dev",
72 organizationId: config.organizationId,74 organizationId: config.organizationId,
73 organizationName: config.organizationName,75 organizationName: config.organizationName,
apps/host-selfhost/src/auth/identity.ts+1−0
@@ -67,6 +67,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette · 66 unmodified lines
67 // default to the seeded org rather than rejecting with NoOrganization.67 // default to the seeded org rather than rejecting with NoOrganization.
68 const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId;68 const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId;
69 return {69 return {
70 kind: "member" as const,
70 accountId: resolved.user.id,71 accountId: resolved.user.id,
71 organizationId: resolvedOrganizationId,72 organizationId: resolvedOrganizationId,
72 organizationName,73 organizationName,
apps/host-selfhost/src/platform-credential.test.ts+17−3
@@ -127,9 +127,8 @@ test("connection reads answer org-owned rows and never a member's personal ones" · 126 unmodified lines
127 new Request("http://localhost/api/connections", { headers: platformHeaders }),127 new Request("http://localhost/api/connections", { headers: platformHeaders }),
128 );128 );
129 expect(res.status).toBe(200);129 expect(res.status).toBe(200);
130 const raw = await res.text();130 const body = (await res.json()) as ReadonlyArray<{ readonly name: string }>;
131 // oxlint-disable-next-line executor/no-json-parse -- boundary: test asserts on the raw wire body (for the token sweep) and its parsed form together131 const raw = JSON.stringify(body);
132 const body = JSON.parse(raw) as ReadonlyArray<{ readonly name: string }>;
133 expect(132 expect(
134 body.map((connection) => connection.name),133 body.map((connection) => connection.name),
135 "the org-owned connection is visible",134 "the org-owned connection is visible",
@@ -141,6 +140,21 @@ test("connection reads answer org-owned rows and never a member's personal ones" · 5 unmodified lines
141 expect(raw, "no credential material either way").not.toContain("token");140 expect(raw, "no credential material either way").not.toContain("token");
142});141});
143 142
143test("the OAuth callback is refused despite being a GET", async () => {
144 // The one core GET with side effects: completing it would burn an org-owned
145 // in-flight authorization code in an outbound token exchange before the
146 // storage policy could refuse the final write. The safe-request gate excludes
147 // it by name; a platform credential landing here is a clear 403, not a
148 // half-executed flow.
149 const res = await handler(
150 new Request("http://localhost/api/oauth/callback?code=x&state=y", {
151 headers: platformHeaders,
152 }),
153 );
154 expect(res.status, "the callback is not a safe read").toBe(403);
155 expect(await res.text()).toContain("read-only");
156});
157
144test("every non-GET is refused before a handler runs", async () => {158test("every non-GET is refused before a handler runs", async () => {
145 const post = await handler(159 const post = await handler(
146 new Request("http://localhost/api/connections", {160 new Request("http://localhost/api/connections", {
apps/host-selfhost/src/testing/test-app.ts+2−0
@@ -81,6 +81,7 @@ export const singleAdminIdentityLayer = ( · 80 unmodified lines
81 IdentityProvider.of({81 IdentityProvider.of({
82 authenticate: () =>82 authenticate: () =>
83 Effect.succeed<Principal>({83 Effect.succeed<Principal>({
84 kind: "member",
84 accountId: options.userId,85 accountId: options.userId,
85 organizationId: options.organizationId,86 organizationId: options.organizationId,
86 organizationName: options.organizationName,87 organizationName: options.organizationName,
@@ -117,6 +118,7 @@ export const headerIdentityLayer: Layer.Layer<IdentityProvider> = Layer.succeed( · 30 unmodified lines
117 const organizationId = request.headers.get("x-test-org");118 const organizationId = request.headers.get("x-test-org");
118 if (!userId || !organizationId) return Effect.fail(new Unauthorized());119 if (!userId || !organizationId) return Effect.fail(new Unauthorized());
119 return Effect.succeed<Principal>({120 return Effect.succeed<Principal>({
121 kind: "member",
120 accountId: userId,122 accountId: userId,
121 organizationId,123 organizationId,
122 organizationName: `Org ${organizationId}`,124 organizationName: `Org ${organizationId}`,
apps/local/src/identity.ts+1−0
@@ -31,6 +31,7 @@ import { safeEqual } from "./serve-shared"; · 30 unmodified lines
31 * cwd-derived (in `app.ts`), independent of these ids.31 * cwd-derived (in `app.ts`), independent of these ids.
32 */32 */
33export const LOCAL_PRINCIPAL: Principal = {33export const LOCAL_PRINCIPAL: Principal = {
34 kind: "member",
34 accountId: "local",35 accountId: "local",
35 organizationId: "local",36 organizationId: "local",
36 organizationName: "Local",37 organizationName: "Local",
packages/core/api/src/server/execution-stack-middleware.ts+51−26
@@ -35,7 +35,7 @@ · 34 unmodified lines
35// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
36 36
37import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";37import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
38import { Context, Effect, Layer } from "effect";38import { Context, Data, Effect, Layer } from "effect";
39import type * as Cause from "effect/Cause";39import type * as Cause from "effect/Cause";
40 40
41import type { AnyPlugin } from "@executor-js/sdk";41import type { AnyPlugin } from "@executor-js/sdk";
@@ -185,15 +185,16 @@ export const makeExecutionStackMiddleware = < · 143 unmodified lines
185 const webRequest = yield* HttpServerRequest.toWeb(request);185 const webRequest = yield* HttpServerRequest.toWeb(request);
186 const resolved = yield* options.strategy.renderFailure(186 const resolved = yield* options.strategy.renderFailure(
187 options.authenticate(webRequest).pipe(187 options.authenticate(webRequest).pipe(
188 // The PLATFORM branch's method gate lives here, before any188 // The PLATFORM branch's gate lives here, before any handler: an
189 // handler: an org credential is read-only by construction, so a189 // org credential is read-only by construction, so anything that
190 // non-GET request is refused as a typed 403 rather than reaching190 // is not a safe read is refused as a typed 403 rather than
191 // a handler that would bind it to a subject or attempt a write191 // reaching a handler that would bind it to a subject or attempt
192 // (the storage policy would refuse the write anyway — this makes192 // a write (the storage policy would refuse the write anyway —
193 // the refusal a clear response instead of a 500).193 // this makes the refusal a clear response instead of a 500).
194 Effect.filterOrFail(194 Effect.filterOrFail(
195 (principal): principal is ResolvedPrincipal =>195 (principal) =>
196 !isPlatformPrincipal(principal) || webRequest.method === "GET",196 !isPlatformPrincipal(principal) ||
197 isPlatformSafeRequest(webRequest.method, new URL(webRequest.url).pathname),
197 () =>198 () =>
198 new ReadOnlyCredential({199 new ReadOnlyCredential({
199 code: "read_only_credential",200 code: "read_only_credential",
@@ -208,14 +209,14 @@ export const makeExecutionStackMiddleware = < · 8 unmodified lines
208 if (isPlatformPrincipal(resolved)) {209 if (isPlatformPrincipal(resolved)) {
209 // Org credential: the subject-less, write-refusing platform stack.210 // Org credential: the subject-less, write-refusing platform stack.
210 // No `touchSubject` runs, so the credential never mints a phantom211 // No `touchSubject` runs, so the credential never mints a phantom
211 // user row in the very lists the admin plane serves.212 // user row in the very lists the admin plane serves. Neither
213 // `RequestWebOrigin` nor `RequestOrgSlug` is provided: both feed
214 // browser-handoff URL construction, which only interactive member
215 // flows perform, and `makePlatformExecutor` reads neither.
212 const { executor } = yield* makePlatformExecutionStack<TPlugins>(216 const { executor } = yield* makePlatformExecutionStack<TPlugins>(
213 resolved.organizationId,217 resolved.organizationId,
214 ).pipe(218 ).pipe(
215 Effect.provide(options.stackLayer),219 Effect.provide(options.stackLayer),
216 Effect.provideService(RequestWebOrigin, {
217 origin: requestWebOriginFromRequest(webRequest),
218 }),
219 Effect.withSpan("executor.stack.http.resolve_platform"),220 Effect.withSpan("executor.stack.http.resolve_platform"),
220 );221 );
221 return yield* httpEffect.pipe(222 return yield* httpEffect.pipe(
@@ -274,24 +275,48 @@ const isPrincipal = ( · 52 unmodified lines
274): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value);275): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value);
275 276
276/**277/**
277 * The engine the platform branch provides: every read answers "nothing here",278 * Whether a request is a SAFE READ the platform branch may serve. The method
278 * honestly — the platform view owns no executions and pauses none. The279 * check (GET, plus HEAD — the router serves HEAD off the GET route) is
279 * execute/resume members exist only to satisfy the service shape; they sit280 * necessary but NOT sufficient: `GET /oauth/callback` is the one core GET with
280 * behind the middleware's GET-only gate, so reaching one is a wiring bug and281 * side effects — completing it reads an org-owned oauth session, performs an
281 * dies as a defect rather than failing with a typed error a client could282 * outbound authorization-code exchange with the org's client credentials, and
282 * mistake for a product answer.283 * then attempts the connection write. A read-only credential must be refused
284 * BEFORE that exchange burns the in-flight code, not after the storage policy
285 * rejects the final write, so the callback path is excluded by name. It is a
286 * browser-redirect surface — no legitimate machine-credential caller lands on
287 * it with a Bearer header.
288 */
289const isPlatformSafeRequest = (method: string, pathname: string): boolean =>
290 (method === "GET" || method === "HEAD") && !pathname.endsWith("/oauth/callback");
291
292/** Reaching an engine member the platform branch cannot honestly serve is a
293 * wiring bug (the safe-request gate keeps those paths unreachable), so it
294 * dies as a tagged defect rather than failing with a typed error a client
295 * could mistake for a product answer. */
296class PlatformEngineUnavailable extends Data.TaggedError("PlatformEngineUnavailable")<{
297 readonly member: string;
298}> {}
299
300/**
301 * The engine the platform branch provides. The one member a safe read can
302 * actually reach — `getPausedExecution`, via `GET /executions/:id` — answers
303 * null (a 404), honestly: the platform view owns no executions. The paused
304 * counters answer the same empty story for any future reader. Everything else
305 * (execute, resume, the MCP tool description) exists to satisfy the service
306 * shape, sits behind the middleware's safe-request gate, and dies if reached.
283 */307 */
284const readOnlyExecutionEngine: ExecutionEngine<Cause.YieldableError> = {308const readOnlyExecutionEngine: ExecutionEngine<Cause.YieldableError> = {
285 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome309 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
286 execute: () => Effect.die("platform credential: execution engine unavailable"),310 execute: () => Effect.die(new PlatformEngineUnavailable({ member: "execute" })),
287 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome311 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
288 executeWithPause: () => Effect.die("platform credential: execution engine unavailable"),312 executeWithPause: () => Effect.die(new PlatformEngineUnavailable({ member: "executeWithPause" })),
289 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome313 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
290 resume: () => Effect.die("platform credential: execution engine unavailable"),314 resume: () => Effect.die(new PlatformEngineUnavailable({ member: "resume" })),
291 getPausedExecution: () => Effect.succeed(null),315 getPausedExecution: () => Effect.succeed(null),
292 pausedExecutionCount: () => Effect.succeed(0),316 pausedExecutionCount: () => Effect.succeed(0),
293 hasPausedExecutions: () => Effect.succeed(false),317 hasPausedExecutions: () => Effect.succeed(false),
294 getDescription: Effect.succeed(""),318 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: only the MCP tool server reads this, and the MCP plane never serves a platform credential
319 getDescription: Effect.die(new PlatformEngineUnavailable({ member: "getDescription" })),
295};320};
296 321
297const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);322const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
packages/core/api/src/server/execution-stack.ts+7−6
@@ -147,12 +147,13 @@ export const makeExecutionStack = < · 146 unmodified lines
147 147
148// ---------------------------------------------------------------------------148// ---------------------------------------------------------------------------
149// makePlatformExecutionStack — the org-credential sibling: a subject-less,149// makePlatformExecutionStack — the org-credential sibling: a subject-less,
150// write-refusing executor (`makePlatformExecutor`) and NO engine. An org key is150// write-refusing executor (`makePlatformExecutor`) and no REAL engine. An org
151// an observer; the execution engine exists to run code as an acting member, so151// key is an observer; the execution engine exists to run code as an acting
152// rather than building one that would fail on use, the middleware refuses152// member, so no engine is built here. The middleware still provides
153// non-GET requests up front and never provides `ExecutionEngineService` on this153// `ExecutionEngineService` (handlers' service requirements demand one) — as a
154// branch. Read-only handlers never read the engine, so the platform branch154// stub whose reachable reads answer "nothing here" and whose execute/resume
155// serves them unchanged.155// members sit behind the middleware's safe-request gate (see
156// `readOnlyExecutionEngine` in ./execution-stack-middleware.ts).
156// ---------------------------------------------------------------------------157// ---------------------------------------------------------------------------
157 158
158export const makePlatformExecutionStack = <159export const makePlatformExecutionStack = <
packages/core/api/src/server/identity.ts+14−7
@@ -29,6 +29,11 @@ import { Context, Effect, Schema } from "effect"; · 28 unmodified lines
29 * resolver already yielded it) AND `roles` (cloud supplies `[]`).29 * resolver already yielded it) AND `roles` (cloud supplies `[]`).
30 */30 */
31export interface Principal {31export interface Principal {
32 /** Discriminant of {@link ResolvedPrincipal}: an acting member, as opposed
33 * to the org-level `"platform"` credential. Required so every construction
34 * site declares which arm it is, and the union matches on a literal tag
35 * instead of probing for a property's presence. */
36 readonly kind: "member";
32 readonly accountId: string;37 readonly accountId: string;
33 readonly organizationId: string;38 readonly organizationId: string;
34 readonly organizationName: string;39 readonly organizationName: string;
@@ -73,24 +78,26 @@ export interface PlatformPrincipal { · 38 unmodified lines
73export type ResolvedPrincipal = Principal | PlatformPrincipal;78export type ResolvedPrincipal = Principal | PlatformPrincipal;
74 79
75export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal =>80export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal =>
76 "kind" in value && value.kind === "platform";81 value.kind === "platform";
77 82
78/**83/**
79 * The single `AuthContext` every executor-API handler reads. The roles-bearing84 * The single `AuthContext` every executor-API handler reads. The roles-bearing
80 * tag from self-host is the model; cloud now provides `roles: []` on it, which85 * tag from self-host is the model; cloud now provides `roles: []` on it, which
81 * is forward-compatible (cloud handlers never read roles today).86 * is forward-compatible (cloud handlers never read roles today).
82 *87 *
83 * `accountId` is `null` for an org-level platform credential — there is no88 * `accountId` and `email` are `null` for an org-level platform credential —
84 * acting member behind it. Nullable rather than a sentinel so any handler that89 * there is no acting member behind it. Nullable rather than sentinel-valued so
85 * needs a member has to say so (and refuse), instead of silently acting as a90 * any handler that needs a member has to say so (and refuse), instead of
86 * user that does not exist.91 * silently acting as a user that does not exist. (`email: ""` on the member
92 * api-key path is the pre-existing "member with no resolved email" value and
93 * unrelated to this.)
87 */94 */
88export class AuthContext extends Context.Service<95export class AuthContext extends Context.Service<
89 AuthContext,96 AuthContext,
90 {97 {
91 readonly accountId: string | null;98 readonly accountId: string | null;
92 readonly organizationId: string;99 readonly organizationId: string;
93 readonly email: string;100 readonly email: string | null;
94 readonly name: string | null;101 readonly name: string | null;
95 readonly avatarUrl: string | null;102 readonly avatarUrl: string | null;
96 readonly roles: readonly string[];103 readonly roles: readonly string[];
@@ -111,7 +118,7 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser · 14 unmodified lines
111export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({118export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({
112 accountId: null,119 accountId: null,
113 organizationId: principal.organizationId,120 organizationId: principal.organizationId,
114 email: "",121 email: null,
115 name: null,122 name: null,
116 avatarUrl: null,123 avatarUrl: null,
117 roles: [],124 roles: [],
packages/core/sdk/src/executor.ts+7−0
@@ -3585,6 +3585,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3584 unmodified lines
3585 // Best-effort: a failed rebuild leaves the stale-but-working catalog in3585 // Best-effort: a failed rebuild leaves the stale-but-working catalog in
3586 // place and retries on the next read.3586 // place and retries on the next read.
3587 const syncStaleConnectionTools = Effect.gen(function* () {3587 const syncStaleConnectionTools = Effect.gen(function* () {
3588 // The platform view can never persist a rebuilt catalog (writes are
3589 // denied at the storage boundary), so attempting the sync would only
3590 // fire upstream `resolveTools` calls whose results are thrown away —
3591 // network side effects on a read-only credential. Skip it entirely:
3592 // read-only-ness of the platform read path is a stated invariant here,
3593 // not an accident of the best-effort catch below.
3594 if (config.platformView === true) return;
3588 const integrations = yield* core.findMany("integration", {});3595 const integrations = yield* core.findMany("integration", {});
3589 if (integrations.length === 0) return;3596 if (integrations.length === 0) return;
3590 const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));3597 const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));