diff --git a/packages/cli/deno-runtime/main.ts b/packages/cli/deno-runtime/main.ts index d6c6874a..d888697c 100644 --- a/packages/cli/deno-runtime/main.ts +++ b/packages/cli/deno-runtime/main.ts @@ -25,9 +25,8 @@ if (!functionPath) { // Store the original Deno.serve const originalServe = Deno.serve.bind(Deno); -// Patch Deno.serve to inject our port and add onListen callback -// @ts-expect-error - We're intentionally overriding Deno.serve -Deno.serve = ( +// Patch Deno.serve to inject our port and add onListen callback. +const patchedServe = ( optionsOrHandler: | Deno.ServeOptions | Deno.ServeHandler @@ -60,6 +59,15 @@ Deno.serve = ( return originalServe({ ...options, port, onListen }); }; +// Deno 2.8 exposes `Deno.serve` as a getter-only property, so a plain +// `Deno.serve = ...` assignment throws. Use defineProperty to override it +// (works on both the old writable property and the new accessor). +Object.defineProperty(Deno, "serve", { + value: patchedServe, + writable: true, + configurable: true, +}); + console.log(`[${functionName}] Starting function from ${functionPath}`); // Dynamically import the user's function diff --git a/packages/cli/src/cli/dev/dev-server/auth/tokens.ts b/packages/cli/src/cli/dev/dev-server/auth/tokens.ts new file mode 100644 index 00000000..b1f259e4 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/auth/tokens.ts @@ -0,0 +1,29 @@ +import jwt from "jsonwebtoken"; + +const LOCAL_DEV_SECRET = "LOCAL_DEV_SECRET"; + +/** + * Sentinel identity used for service-role (`asServiceRole`) requests in dev. + * In production Base44 injects a privileged service token; locally we mint a + * JWT for this subject and grant it full access (see `checkRLS`). + */ +export const SERVICE_ROLE_EMAIL = "server@server.com"; + +export const createJwtToken = (email: string) => { + return jwt.sign({ sub: email }, LOCAL_DEV_SECRET, { + expiresIn: "360d", + }); +}; + +/** + * Mints the service-role JWT injected as `Base44-Service-Authorization` so + * `asServiceRole` works locally regardless of how the caller is authenticated. + */ +const createServiceToken = () => createJwtToken(SERVICE_ROLE_EMAIL); + +export const createServiceAuthorizationHeader = () => + `Bearer ${createServiceToken()}`; + +/** True when a JWT subject identifies the service-role principal. */ +export const isServiceSubject = (subject: string): boolean => + subject === SERVICE_ROLE_EMAIL; diff --git a/packages/cli/src/cli/dev/dev-server/db/rls.ts b/packages/cli/src/cli/dev/dev-server/db/rls.ts index 5e72700b..717852ff 100644 --- a/packages/cli/src/cli/dev/dev-server/db/rls.ts +++ b/packages/cli/src/cli/dev/dev-server/db/rls.ts @@ -153,6 +153,7 @@ export function checkRLS( record: Record, user: Record | undefined, ): boolean { + if (user?.is_service === true) return true; if (rule === undefined) return true; if (typeof rule === "boolean") return rule; if (!user) return false; @@ -187,7 +188,7 @@ export function applyFLS( const result: Record = {}; for (const [key, value] of Object.entries(record)) { const rule = schema.properties[key]?.rls?.[operation]; - if (!rule || checkRLS(rule, record, user)) { + if (rule === undefined || checkRLS(rule, record, user)) { result[key] = value; } } diff --git a/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts b/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts index c62975c7..d71151b1 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts @@ -1,11 +1,11 @@ import { randomInt } from "node:crypto"; import type { Request } from "express"; import { json, Router } from "express"; -import jwt from "jsonwebtoken"; import { nanoid } from "nanoid"; import * as z from "zod"; import { theme } from "@/cli/utils/theme.js"; import type { DevLogger } from "../../createDevLogger.js"; +import { createJwtToken } from "../auth/tokens.js"; import { type Database, PRIVATE_USER_COLLECTION, @@ -13,19 +13,12 @@ import { } from "../db/database.js"; import { getNowISOTimestamp } from "../utils.js"; -const LOCAL_DEV_SECRET = "LOCAL_DEV_SECRET"; const TEN_MINUTES = 10 * 60 * 1000; const generateCode = () => { return randomInt(100000, 1000000).toString(); }; -const createJwtToken = (email: string) => { - return jwt.sign({ sub: email }, LOCAL_DEV_SECRET, { - expiresIn: "360d", - }); -}; - const LoginBody = z.object({ email: z.email(), password: z.string() }); const VerifyOtpBody = z.object({ email: z.email(), otp_code: z.string() }); diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts index 08f1e5e4..3d5909cc 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts @@ -1,6 +1,10 @@ import type { Document } from "@seald-io/nedb"; import type { Request } from "express"; import jwt, { type JwtPayload } from "jsonwebtoken"; +import { + isServiceSubject, + SERVICE_ROLE_EMAIL, +} from "@/cli/dev/dev-server/auth/tokens.js"; import { type Database, USER_COLLECTION, @@ -9,9 +13,18 @@ import { export type UserDocument = Document<{ email: string; id: string; + is_service?: boolean; role: "admin" | "user"; }>; +const SERVICE_USER: UserDocument = { + _id: "service-role", + id: "service-role", + email: SERVICE_ROLE_EMAIL, + role: "admin", + is_service: true, +}; + type CurrentUserLookupResult = | { ok: true; user: UserDocument } | { ok: false; reason: "missing" | "invalid" | "not_found" }; @@ -43,6 +56,10 @@ export async function resolveCurrentUser( return { ok: false, reason: "invalid" }; } + if (isServiceSubject(subject)) { + return { ok: true, user: SERVICE_USER }; + } + const currentUser = await db .getCollection(USER_COLLECTION) ?.findOneAsync({ email: subject }); diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts index 5cadd9d1..473235d5 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts @@ -175,7 +175,7 @@ export async function createEntityRoutes( await queryEntity(collection, req.query), ); - if (schema.rls?.read && schema.rls.read !== true) { + if (schema.rls?.read !== undefined && schema.rls.read !== true) { results = results.filter((doc) => checkRLS(schema.rls!.read, doc, currentUser), ); @@ -392,7 +392,7 @@ export async function createEntityRoutes( // When RLS has a condition, find matching records and only delete allowed ones if (rlsDelete !== undefined && rlsDelete !== true) { - if (rlsDelete === false) { + if (rlsDelete === false && currentUser?.is_service !== true) { res.status(403).json({ error: "Permission denied" }); return; } diff --git a/packages/cli/src/cli/dev/dev-server/routes/functions.ts b/packages/cli/src/cli/dev/dev-server/routes/functions.ts index 17a534b9..cc4bc554 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/functions.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/functions.ts @@ -4,6 +4,7 @@ import type { Request, Response } from "express"; import { Router } from "express"; import { createProxyMiddleware } from "http-proxy-middleware"; import type { DevLogger } from "@/cli/dev/createDevLogger.js"; +import { createServiceAuthorizationHeader } from "@/cli/dev/dev-server/auth/tokens.js"; import type { FunctionManager } from "@/cli/dev/dev-server/function-manager.js"; export function createFunctionRouter( @@ -19,14 +20,17 @@ export function createFunctionRouter( on: { proxyReq: (proxyReq, req) => { const xAppId = req.headers["x-app-id"]; - const authorization = req.headers.authorization; if (xAppId) { proxyReq.setHeader("Base44-App-Id", xAppId as string); } - if (authorization) { - proxyReq.setHeader("Base44-Service-Authorization", authorization); - } + // In production, Base44 always injects a service role token when forwarding + // to functions. Replicate that here so asServiceRole works even for + // unauthenticated callers (e.g. public-facing subscribe forms). + proxyReq.setHeader( + "Base44-Service-Authorization", + createServiceAuthorizationHeader(), + ); proxyReq.setHeader( "Base44-Api-Url", `${(req as unknown as Request).protocol}://${req.headers.host}`, diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 6dbd36ad..3b928229 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -71,4 +71,4 @@ async function runCLI(options?: RunCLIOptions): Promise { } } -export { runCLI, createProgram, CLIExitError }; +export { CLIExitError, createProgram, runCLI }; diff --git a/packages/cli/tests/cli/dev-rls.spec.ts b/packages/cli/tests/cli/dev-rls.spec.ts new file mode 100644 index 00000000..99f3ce77 --- /dev/null +++ b/packages/cli/tests/cli/dev-rls.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { applyFLS, checkRLS } from "@/cli/dev/dev-server/db/rls.js"; +import type { Entity } from "@/core/resources/entity/schema.js"; + +const serviceUser = { + email: "server@server.com", + id: "service-role", + is_service: true, + role: "admin", +}; + +describe("dev RLS", () => { + it("allows service users to bypass explicit false RLS rules", () => { + expect(checkRLS(false, {}, serviceUser)).toBe(true); + }); + + it("treats explicit false FLS as deny for normal users and allow for service users", () => { + const schema: Entity = { + name: "PrivateNote", + type: "object", + properties: { + title: { type: "string" }, + secret: { + type: "string", + rls: { + read: false, + }, + }, + }, + source: { type: "project" }, + }; + const record = { title: "Visible", secret: "Hidden" }; + + expect(applyFLS(record, schema, undefined, "read")).toEqual({ + title: "Visible", + }); + expect(applyFLS(record, schema, serviceUser, "read")).toEqual(record); + }); +}); diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 38d7d731..7696b5a5 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -1,10 +1,21 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import jwt from "jsonwebtoken"; import { outdent } from "outdent"; import { describe, expect, it } from "vitest"; +import { + createServiceAuthorizationHeader, + SERVICE_ROLE_EMAIL, +} from "@/cli/dev/dev-server/auth/tokens.js"; import { waitForDevServer } from "./testkit/dev-utils.js"; import { fixture, setupCLITests } from "./testkit/index.js"; +const expectServiceAuthorization = (value: unknown) => { + expect(value).toEqual(expect.stringMatching(/^Bearer \S+$/)); + const token = (value as string).replace("Bearer ", ""); + expect(jwt.decode(token)?.sub).toBe(SERVICE_ROLE_EMAIL); +}; + describe("dev command", () => { const t = setupCLITests(); @@ -27,7 +38,7 @@ describe("dev command", () => { t.expectResult(result).toSucceed(); }); - it("forwards the service token header from Authorization to local functions", async () => { + it("forwards caller Authorization and injects a service JWT to local functions", async () => { await t.givenLoggedInWithProject(fixture("full-project")); await writeFile( @@ -40,12 +51,14 @@ describe("dev command", () => { "index.ts", ), outdent` - Deno.serve((req: Request) => - Response.json({ + Deno.serve((req: Request) => { + return new Response(JSON.stringify({ authorization: req.headers.get("authorization"), serviceAuthorization: req.headers.get("base44-service-authorization"), - }), - ); + }), { + headers: { "Content-Type": "application/json" }, + }); + }); `, ); @@ -63,9 +76,149 @@ describe("dev command", () => { ); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - authorization: "Bearer test-app-token", - serviceAuthorization: "Bearer test-app-token", + const body = (await response.json()) as Record; + expect(body.authorization).toBe("Bearer test-app-token"); + expectServiceAuthorization(body.serviceAuthorization); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("injects a synthetic service token for unauthenticated function calls", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + + await writeFile( + join( + t.getTempDir(), + "project", + "base44", + "functions", + "hello", + "index.ts", + ), + outdent` + Deno.serve((req: Request) => { + return new Response(JSON.stringify({ + authorization: req.headers.get("authorization"), + serviceAuthorization: req.headers.get("base44-service-authorization"), + }), { + headers: { "Content-Type": "application/json" }, + }); + }); + `, + ); + + const handle = await t.runLive("dev"); + const devServerUrl = await waitForDevServer(handle); + + // Call the function with no Authorization header (unauthenticated caller, + // e.g. a public subscribe form). The dev server must still inject a + // Base44-Service-Authorization so that asServiceRole works inside the function. + const response = await fetch( + `${devServerUrl}/api/apps/${t.api.appId}/functions/hello`, + { + headers: { + "X-App-Id": t.api.appId, + }, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.authorization).toBeNull(); + expectServiceAuthorization(body.serviceAuthorization); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("allows service-role JWTs to bypass denied entity create RLS", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const entitiesDir = join(t.getTempDir(), "project", "base44", "entities"); + await mkdir(entitiesDir, { recursive: true }); + await writeFile( + join(entitiesDir, "private-note.jsonc"), + outdent` + { + "name": "PrivateNote", + "type": "object", + "properties": { + "title": { "type": "string" } + }, + "rls": { + "create": false, + "read": false + } + } + `, + ); + + const handle = await t.runLive("dev"); + const devServerUrl = await waitForDevServer(handle); + const url = `${devServerUrl}/api/apps/${t.api.appId}/entities/PrivateNote`; + + const unauthenticatedResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-App-Id": t.api.appId, + }, + body: JSON.stringify({ title: "Unauthenticated" }), + }); + + expect(unauthenticatedResponse.status).toBe(403); + + const serviceResponse = await fetch(url, { + method: "POST", + headers: { + Authorization: createServiceAuthorizationHeader(), + "Content-Type": "application/json", + "X-App-Id": t.api.appId, + }, + body: JSON.stringify({ title: "Service role" }), + }); + + expect(serviceResponse.status).toBe(201); + const body = (await serviceResponse.json()) as Record; + expect(body.title).toBe("Service role"); + expect(body.created_by).toBe(SERVICE_ROLE_EMAIL); + + const unauthenticatedListResponse = await fetch(url, { + headers: { + "X-App-Id": t.api.appId, + }, + }); + expect(unauthenticatedListResponse.status).toBe(200); + await expect(unauthenticatedListResponse.json()).resolves.toEqual([]); + + const serviceListResponse = await fetch(url, { + headers: { + Authorization: createServiceAuthorizationHeader(), + "X-App-Id": t.api.appId, + }, + }); + expect(serviceListResponse.status).toBe(200); + const serviceListBody = (await serviceListResponse.json()) as Record< + string, + unknown + >[]; + expect(serviceListBody).toHaveLength(1); + expect(serviceListBody[0].title).toBe("Service role"); + + const serviceDeleteResponse = await fetch(url, { + method: "DELETE", + headers: { + Authorization: createServiceAuthorizationHeader(), + "Content-Type": "application/json", + "X-App-Id": t.api.appId, + }, + body: JSON.stringify({}), + }); + expect(serviceDeleteResponse.status).toBe(200); + await expect(serviceDeleteResponse.json()).resolves.toMatchObject({ + deleted: 1, + success: true, }); const result = await handle.stop();