From 12bf3f4ca86134cb242199439b3bae482c1d0493 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Sat, 30 May 2026 12:29:38 +0800 Subject: [PATCH 1/6] feat(proxy): add public API key support for request signature verification --- packages/cli/package.json | 3 +- packages/cli/src/actions/proxy.ts | 173 +++++++++- packages/cli/src/index.ts | 12 + packages/cli/test/proxy.test.ts | 554 +++++++++++++++++++++++++++++- pnpm-lock.yaml | 3 + 5 files changed, 737 insertions(+), 8 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ce77f6cb..e1a77e7d7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -72,6 +72,7 @@ "@types/semver": "^7.7.0", "@types/tmp": "catalog:", "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/plugin-policy": "workspace:*", "@zenstackhq/testtools": "workspace:*", "@zenstackhq/tsdown-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", @@ -98,4 +99,4 @@ "node": ">=20" }, "funding": "https://github.com/sponsors/zenstackhq" -} +} \ No newline at end of file diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index 54c29c0ab..d79508331 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -20,6 +20,7 @@ import cors from 'cors'; import express from 'express'; import { createJiti } from 'jiti'; import type { createPool as MysqlCreatePool } from 'mysql2'; +import { verify } from 'node:crypto'; import path from 'node:path'; import type { Pool as PgPoolType } from 'pg'; import { CliError } from '../cli-error'; @@ -32,8 +33,16 @@ type Options = { port?: number; logLevel?: string[]; databaseUrl?: string; + publicAPIKey?: string; + signatureToleranceSecs?: number; }; +/** + * Represents the identity claim embedded in the Authorization header. + * The bearer token is a plain base64-encoded JSON string. + */ +type UserClaim = { type: 'superUser' } | { type: 'user'; data: Record }; + export async function run(options: Options) { const allowedLogLevels = ['error', 'query'] as const; const log = options.logLevel?.filter((level): level is (typeof allowedLogLevels)[number] => @@ -104,7 +113,23 @@ export async function run(options: Options) { throw new CliError(`Failed to connect to the database: ${err instanceof Error ? err.message : String(err)}`); } - startServer(db, schemaModule.schema, options); + // If a publicAPIKey is provided, try to create an authDb with the policy plugin + let authDb: ClientContract | undefined; + if (options.publicAPIKey) { + try { + const { PolicyPlugin } = await import('@zenstackhq/plugin-policy'); + authDb = db.$use(new PolicyPlugin()) as ClientContract; + console.log(colors.gray('Access policy plugin enabled for authorization.')); + } catch { + console.log( + colors.yellow( + 'Warning: @zenstackhq/plugin-policy is not installed. Authorization (per-user access control) will be disabled.', + ), + ); + } + } + + startServer(db, schemaModule.schema, options, authDb); } function evaluateUrl(schemaUrl: ConfigExpr) { @@ -198,17 +223,40 @@ async function createDialect(provider: string, databaseUrl: string, outputPath: } } -export function createProxyApp(client: ClientContract, schema: SchemaDef): express.Application { +export function createProxyApp( + client: ClientContract, + schema: SchemaDef, + options?: { + publicAPIKey?: string; + authDb?: ClientContract; + /** Seconds within which a signed request is considered valid. Defaults to 60. */ + signatureToleranceSecs?: number; + }, +): express.Application { const app = express(); app.use(cors()); - app.use(express.json({ limit: '5mb' })); + app.use( + express.json({ + limit: '5mb', + verify: (req, _res, buf) => { + // Capture the raw body string for use in signature verification. + (req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8'); + }, + }), + ); app.use(express.urlencoded({ extended: true, limit: '5mb' })); + if (options?.publicAPIKey) { + // Apply signature-verification middleware to all authenticated endpoints. + const toleranceSecs = options.signatureToleranceSecs ?? 60; + app.use(['/api/model', '/api/schema'], createSignatureMiddleware(options.publicAPIKey, toleranceSecs)); + } + app.use( '/api/model', ZenStackMiddleware({ apiHandler: new RPCApiHandler({ schema }), - getClient: () => client, + getClient: (req) => resolveClient(client, options?.authDb, req), }), ); @@ -219,8 +267,121 @@ export function createProxyApp(client: ClientContract, schema: Schema return app; } -function startServer(client: ClientContract, schema: any, options: Options) { - const app = createProxyApp(client, schema); +/** + * Creates an Express middleware that verifies the ed25519 signature on every request. + * + * The signature header format is: `t=,v1=` + * + * The signed message is constructed as: + * - GET requests: `[]` + * - Other methods: `[]` + * + * `authorizationToken` is the bearer token value from the `Authorization` header (if present). + */ +function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) { + return (req: express.Request, res: express.Response, next: express.NextFunction) => { + const signatureHeader = req.headers['x-zenstack-signature']; + if (!signatureHeader || typeof signatureHeader !== 'string') { + return res.status(401).json({ message: 'Missing x-zenstack-signature header' }); + } + + const parts = signatureHeader.split(','); + const timestampPart = parts.find((p) => p.startsWith('t=')); + const sigPart = parts.find((p) => p.startsWith('v1=')); + if (!timestampPart || !sigPart) { + return res.status(401).json({ message: 'Invalid x-zenstack-signature format' }); + } + const timestamp = timestampPart.substring(2); + const sig = sigPart.substring(3); + + // Replay-attack prevention: reject requests whose timestamp deviates + // from server time by more than the configured tolerance. + const requestTime = parseInt(timestamp, 10); + const now = Math.floor(Date.now() / 1000); + if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) { + return res.status(401).json({ message: 'Request timestamp is expired or invalid' }); + } + + // Payload: raw query string for GET/DELETE, raw body for other methods. + let payload: string; + if (req.method === 'GET' || req.method === 'DELETE') { + const qMark = req.originalUrl.indexOf('?'); + payload = qMark >= 0 ? req.originalUrl.substring(qMark + 1) : ''; + } else { + payload = (req as express.Request & { rawBody?: string }).rawBody ?? ''; + } + + // authorizationToken is the bearer token value (if present). + const authHeader = req.headers['authorization']; + const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined; + + const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`; + + try { + const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')); + if (!isValid) { + return res.status(401).json({ message: 'Invalid signature' }); + } + } catch { + return res.status(401).json({ message: 'Invalid signature' }); + } + + return next(); + }; +} + +/** + * Resolves the appropriate client for a request based on the Authorization header. + * + * - No publicAPIKey configured (authDb is undefined): always return the base client. + * - SuperUser claim: return the base client (full access, no policy enforcement). + * - Regular user claim: return authDb with the user identity set via $setAuth. + * - No / invalid token: return the base client. + */ +function resolveClient( + client: ClientContract, + authDb: ClientContract | undefined, + req: express.Request, +): ClientContract { + if (!authDb) { + return client; + } + + const authHeader = req.headers['authorization']; + if (!authHeader?.startsWith('Bearer ')) { + return client; + } + + const token = authHeader.substring(7); + let claim: UserClaim; + try { + claim = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as UserClaim; + } catch { + return client; + } + + if (claim.type === 'superUser') { + return client; + } + + if (claim.type === 'user') { + return authDb.$setAuth(claim.data) as ClientContract; + } + + return client; +} + +function startServer( + client: ClientContract, + schema: any, + options: Options, + authDb?: ClientContract, +) { + const app = createProxyApp(client, schema, { + publicAPIKey: options.publicAPIKey, + authDb, + signatureToleranceSecs: options.signatureToleranceSecs, + }); const server = app.listen(options.port, () => { console.log(`ZenStack proxy server is running on port: ${options.port}`); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dd5aea7aa..9d8a13d93 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -263,6 +263,18 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us .addOption(new Option('-o, --output ', 'output directory for `zen generate` command')) .addOption(new Option('-d, --databaseUrl ', 'database connection URL')) .addOption(new Option('-l, --logLevel ', 'Query log levels (e.g., query, error)')) + .addOption( + new Option( + '--publicAPIKey ', + 'PEM-encoded ed25519 public key used to verify request signatures. When provided, all requests to /api/model and /api/schema must include a valid x-zenstack-signature header.', + ), + ) + .addOption( + new Option( + '--signatureToleranceSecs ', + 'Maximum age (in seconds) of a signed request before it is rejected as a replay. Defaults to 60.', + ).argParser((v) => parseInt(v, 10)), + ) .addOption(noVersionCheckOption) .action(proxyAction); diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index 840412f23..e776702f7 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -1,8 +1,65 @@ +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; import { createTestClient } from '@zenstackhq/testtools'; +import { sign } from 'node:crypto'; import http from 'node:http'; import { afterEach, describe, expect, it } from 'vitest'; import { createProxyApp } from '../src/actions/proxy'; +type TestClientOptions = Parameters[1]; + +// ─── Ed25519 key pair for tests ─────────────────────────────────────────────── +const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIHIlHXhk+zc9ziuvrYAnZZgGL36H1GXwfsYchM9dM8gR +-----END PRIVATE KEY-----`; + +const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs= +-----END PUBLIC KEY-----`; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Builds the `x-zenstack-signature` header value for a request. + * The payload is: + * - GET / DELETE: the raw query string (URL-encoded, after `?`) + * - other methods: `JSON.stringify(body)` (the raw request body) + */ +function buildSignatureHeader(options: { + privateKey: string; + method: string; + /** Path + optional query string, e.g. `/api/model/user/findMany?q=%7B%7D` */ + pathWithQuery: string; + body?: unknown; + authorizationToken?: string; + /** Override timestamp (unix seconds as string). Defaults to `now`. */ + timestamp?: string; +}): string { + const timestamp = options.timestamp ?? String(Math.floor(Date.now() / 1000)); + + const method = options.method.toUpperCase(); + let payload: string; + if (method === 'GET' || method === 'DELETE') { + const qMark = options.pathWithQuery.indexOf('?'); + payload = qMark >= 0 ? options.pathWithQuery.substring(qMark + 1) : ''; + } else { + payload = options.body != null ? JSON.stringify(options.body) : ''; + } + + const message = options.authorizationToken + ? `${payload}${timestamp}${options.authorizationToken}` + : `${payload}${timestamp}`; + + const sig = sign(null, Buffer.from(message, 'utf8'), options.privateKey).toString('base64url'); + return `t=${timestamp},v1=${sig}`; +} + +/** Encodes a UserClaim as a plain base64 bearer token. */ +function makeUserToken(claim: { type: 'superUser' } | { type: 'user'; data: Record }): string { + return Buffer.from(JSON.stringify(claim)).toString('base64'); +} + +// ─── Test suite ─────────────────────────────────────────────────────────────── + describe('CLI proxy tests', () => { let server: http.Server | undefined; @@ -70,7 +127,7 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel, { skipValidationForComputedFields: true, omit: { User: { postCount: true } }, - }); + } as TestClientOptions); const app = createProxyApp(client, client.$schema); const baseUrl = await startAt(app); @@ -167,4 +224,499 @@ describe('CLI proxy tests', () => { const user = await userRes.json(); expect(user.data).toMatchObject({ id: 'u1', email: 'alice@example.com' }); }); + + // ─── AuthN: signature verification ───────────────────────────────────────── + + describe('signature verification (publicAPIKey configured)', () => { + const zmodel = ` + model User { + id String @id @default(cuid()) + email String @unique + } + `; + + it('should reject requests missing the signature header with 401', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + const r = await fetch(`${baseUrl}/api/model/user/findMany`); + expect(r.status).toBe(401); + + const schemaR = await fetch(`${baseUrl}/api/schema`); + expect(schemaR.status).toBe(401); + }); + + it('should reject requests with an invalid signature with 401', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + const r = await fetch(`${baseUrl}/api/model/user/findMany`, { + headers: { 'x-zenstack-signature': 't=1234567890,v1=invalidsignature' }, + }); + expect(r.status).toBe(401); + }); + + it('should allow GET requests with a valid signature', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + const path = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery: path }); + + const r = await fetch(`${baseUrl}${path}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('should allow GET request with query params and a valid signature', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + // Pre-seed a record directly via client + await client.user.create({ data: { id: 'u1', email: 'alice@example.com' } }); + + const q = encodeURIComponent(JSON.stringify({ where: { id: 'u1' } })); + const pathWithQuery = `/api/model/user/findUnique?q=${q}`; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.data).toMatchObject({ id: 'u1', email: 'alice@example.com' }); + }); + + it('should allow POST (create) requests with a valid signature', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + const reqBody = { data: { email: 'bob@example.com' } }; + const pathWithQuery = '/api/model/user/create'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'POST', + pathWithQuery, + body: reqBody, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-zenstack-signature': sig }, + body: JSON.stringify(reqBody), + }); + expect(r.status).toBe(201); + const body = await r.json(); + expect(body.data).toMatchObject({ email: 'bob@example.com' }); + }); + + it('should allow PUT (update) requests with a valid signature', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + // Seed a record + await client.user.create({ data: { id: 'u1', email: 'old@example.com' } }); + + const reqBody = { where: { id: 'u1' }, data: { email: 'new@example.com' } }; + const pathWithQuery = '/api/model/user/update'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'PUT', + pathWithQuery, + body: reqBody, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'x-zenstack-signature': sig }, + body: JSON.stringify(reqBody), + }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.data).toMatchObject({ id: 'u1', email: 'new@example.com' }); + }); + + it('should allow signed schema endpoint', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + const pathWithQuery = '/api/schema'; + const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body).toHaveProperty('models'); + }); + + it('should not require signatures when publicAPIKey is not configured', async () => { + const client = await createTestClient(zmodel); + // No publicAPIKey — backwards-compatible mode + const app = createProxyApp(client, client.$schema); + const baseUrl = await startAt(app); + + // No signature header — should still work + const r = await fetch(`${baseUrl}/api/model/user/findMany`); + expect(r.status).toBe(200); + + // Authorization header is silently ignored + const withAuthHeader = await fetch(`${baseUrl}/api/model/user/findMany`, { + headers: { Authorization: `Bearer ${makeUserToken({ type: 'superUser' })}` }, + }); + expect(withAuthHeader.status).toBe(200); + }); + }); + + // ─── AuthN: timestamp / replay-attack prevention ─────────────────────────── + + describe('signature timestamp tolerance', () => { + const zmodel = ` + model User { + id String @id @default(cuid()) + email String @unique + } + `; + + it('should reject a request whose timestamp is older than the tolerance window', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + // Timestamp 120 seconds ago — exceeds default 60-second tolerance + const expiredTimestamp = String(Math.floor(Date.now() / 1000) - 120); + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + timestamp: expiredTimestamp, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(401); + const body = await r.json(); + expect(body.message).toMatch(/expired/i); + }); + + it('should reject a request whose timestamp is too far in the future', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + // Timestamp 120 seconds in the future — exceeds default 60-second tolerance + const futureTimestamp = String(Math.floor(Date.now() / 1000) + 120); + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + timestamp: futureTimestamp, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(401); + const body = await r.json(); + expect(body.message).toMatch(/expired/i); + }); + + it('should accept a request within a custom tolerance window', async () => { + const client = await createTestClient(zmodel); + // Custom tolerance of 300 seconds + const app = createProxyApp(client, client.$schema, { + publicAPIKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 300, + }); + const baseUrl = await startAt(app); + + // Timestamp 120 seconds ago — within the 300-second custom tolerance + const timestamp = String(Math.floor(Date.now() / 1000) - 120); + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + timestamp, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(200); + }); + + it('should reject a request that falls outside a custom tolerance window', async () => { + const client = await createTestClient(zmodel); + // Very tight tolerance of 5 seconds + const app = createProxyApp(client, client.$schema, { + publicAPIKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 5, + }); + const baseUrl = await startAt(app); + + // Timestamp 10 seconds ago — exceeds the 5-second custom tolerance + const timestamp = String(Math.floor(Date.now() / 1000) - 10); + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + timestamp, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(401); + }); + }); + + // ─── AuthN: signed request also carries Authorization header ────────────── + + describe('signature includes Authorization header in the signed message', () => { + const zmodel = ` + model User { + id String @id @default(cuid()) + email String @unique + } + `; + + it('should reject a valid signature if it was produced without the Authorization token', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + // Sign without including the auth token + const pathWithQuery = '/api/model/user/findMany'; + const sigWithoutAuth = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + // authorizationToken intentionally omitted + }); + + const authToken = makeUserToken({ type: 'superUser' }); + + // Send with Authorization header but signature that did NOT cover it + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { + 'x-zenstack-signature': sigWithoutAuth, + Authorization: `Bearer ${authToken}`, + }, + }); + expect(r.status).toBe(401); + }); + + it('should accept a request where the signature covers the Authorization token', async () => { + const client = await createTestClient(zmodel); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const baseUrl = await startAt(app); + + const authToken = makeUserToken({ type: 'superUser' }); + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + authorizationToken: authToken, + }); + + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { + 'x-zenstack-signature': sig, + Authorization: `Bearer ${authToken}`, + }, + }); + expect(r.status).toBe(200); + }); + }); + + // ─── AuthZ: user impersonation via PolicyPlugin ───────────────────────────── + + describe('authorization with policy plugin', () => { + // Users can only read/write their own record. + const zmodel = ` + model User { + id String @id @default(cuid()) + email String @unique + + @@allow('all', auth() != null && auth().id == id) + } + `; + + async function createPolicyApp(extraZmodel?: string) { + const fullZmodel = extraZmodel ? `${zmodel}\n${extraZmodel}` : zmodel; + const client = await createTestClient(fullZmodel); + const authDb = client.$use(new PolicyPlugin()); + return { client, app: createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY, authDb }) }; + } + + async function signedFetch(baseUrl: string, path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? 'GET').toUpperCase(); + const body = init.body ? JSON.parse(init.body as string) : undefined; + const authHeader = (init.headers as Record | undefined)?.['Authorization']; + const authorizationToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : undefined; + + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method, + pathWithQuery: path, + body, + authorizationToken, + }); + return fetch(`${baseUrl}${path}`, { + ...init, + headers: { + ...(init.headers as Record), + 'x-zenstack-signature': sig, + }, + }); + } + + it('superUser can access all records', async () => { + const { client, app } = await createPolicyApp(); + const baseUrl = await startAt(app); + + // Seed two users directly via base client (bypasses policy) + await client.user.create({ data: { id: 'u1', email: 'user1@example.com' } }); + await client.user.create({ data: { id: 'u2', email: 'user2@example.com' } }); + + const authToken = makeUserToken({ type: 'superUser' }); + const pathWithQuery = '/api/model/user/findMany'; + const r = await signedFetch(baseUrl, pathWithQuery, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + expect(r.status).toBe(200); + const body = await r.json(); + // SuperUser bypasses policy — sees all records + expect(body.data).toHaveLength(2); + }); + + it('regular user can only access their own record', async () => { + const { client, app } = await createPolicyApp(); + const baseUrl = await startAt(app); + + // Seed two users + await client.user.create({ data: { id: 'u1', email: 'user1@example.com' } }); + await client.user.create({ data: { id: 'u2', email: 'user2@example.com' } }); + + // Authenticated as u1 + const authToken = makeUserToken({ type: 'user', data: { id: 'u1' } }); + const pathWithQuery = '/api/model/user/findMany'; + const r = await signedFetch(baseUrl, pathWithQuery, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + expect(r.status).toBe(200); + const body = await r.json(); + // Policy restricts to own record only + expect(body.data).toHaveLength(1); + expect(body.data[0]).toMatchObject({ id: 'u1' }); + }); + + it("regular user cannot update another user's record", async () => { + const { client, app } = await createPolicyApp(); + const baseUrl = await startAt(app); + + await client.user.create({ data: { id: 'u1', email: 'user1@example.com' } }); + await client.user.create({ data: { id: 'u2', email: 'user2@example.com' } }); + + // Authenticated as u2 trying to update u1 + const reqBody = { where: { id: 'u1' }, data: { email: 'hacked@example.com' } }; + const authToken = makeUserToken({ type: 'user', data: { id: 'u2' } }); + const pathWithQuery = '/api/model/user/update'; + const r = await signedFetch(baseUrl, pathWithQuery, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` }, + body: JSON.stringify(reqBody), + }); + // Policy should reject this — the policy plugin returns 404 (not found) + // rather than 403 to avoid leaking that the record exists. + expect([403, 404]).toContain(r.status); + }); + + it('superUser can create records on behalf of others', async () => { + const { client: _client, app } = await createPolicyApp(); + const baseUrl = await startAt(app); + + const reqBody = { data: { id: 'u1', email: 'user1@example.com' } }; + const authToken = makeUserToken({ type: 'superUser' }); + const pathWithQuery = '/api/model/user/create'; + const r = await signedFetch(baseUrl, pathWithQuery, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` }, + body: JSON.stringify(reqBody), + }); + expect(r.status).toBe(201); + const body = await r.json(); + expect(body.data).toMatchObject({ id: 'u1', email: 'user1@example.com' }); + }); + + it('sequential transaction respects user-scoped policy', async () => { + const fullZmodel = ` + model User { + id String @id @default(cuid()) + email String @unique + posts Post[] + + @@allow('all', auth() != null && auth().id == id) + } + model Post { + id String @id @default(cuid()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? + + @@allow('all', auth() != null && auth().id == authorId) + } + `; + const client = await createTestClient(fullZmodel); + const authDb = client.$use(new PolicyPlugin()); + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY, authDb }); + const baseUrl = await startAt(app); + + // Seed users + await client.user.create({ data: { id: 'u1', email: 'user1@example.com' } }); + await client.user.create({ data: { id: 'u2', email: 'user2@example.com' } }); + + // Transaction as u1: create own post and read own posts + const txBody = [ + { model: 'Post', op: 'create', args: { data: { id: 'p1', title: 'Post by u1', authorId: 'u1' } } }, + { model: 'Post', op: 'findMany', args: {} }, + ]; + const authToken = makeUserToken({ type: 'user', data: { id: 'u1' } }); + const pathWithQuery = '/api/model/$transaction/sequential'; + const r = await signedFetch(baseUrl, pathWithQuery, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` }, + body: JSON.stringify(txBody), + }); + expect(r.status).toBe(200); + const body = await r.json(); + expect(Array.isArray(body.data)).toBe(true); + // Created post + expect(body.data[0]).toMatchObject({ id: 'p1', title: 'Post by u1' }); + // findMany respects policy — u1 sees only their posts + expect(body.data[1]).toHaveLength(1); + expect(body.data[1][0]).toMatchObject({ id: 'p1' }); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f22e8e26..9c463b209 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,9 @@ importers: '@zenstackhq/eslint-config': specifier: workspace:* version: link:../config/eslint-config + '@zenstackhq/plugin-policy': + specifier: workspace:* + version: link:../plugins/policy '@zenstackhq/testtools': specifier: workspace:* version: link:../testtools From 5644d3bdcc45150617f30173d74665cdfce43e98 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Sat, 30 May 2026 12:50:47 +0800 Subject: [PATCH 2/6] refactor: make the `zenstackhq/plugin-policy` as the dependencies of zenstack CLI project --- packages/cli/package.json | 2 +- packages/cli/src/actions/proxy.ts | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e1a77e7d7..6764d423a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,6 +45,7 @@ "@zenstackhq/orm": "workspace:*", "@zenstackhq/schema": "workspace:*", "@zenstackhq/sdk": "workspace:*", + "@zenstackhq/plugin-policy": "workspace:*", "@zenstackhq/server": "workspace:*", "chokidar": "^5.0.0", "colors": "1.4.0", @@ -72,7 +73,6 @@ "@types/semver": "^7.7.0", "@types/tmp": "catalog:", "@zenstackhq/eslint-config": "workspace:*", - "@zenstackhq/plugin-policy": "workspace:*", "@zenstackhq/testtools": "workspace:*", "@zenstackhq/tsdown-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index d79508331..df5b4c64c 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -12,6 +12,7 @@ import { MysqlDialect } from '@zenstackhq/orm/dialects/mysql'; import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres'; import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; import type { SchemaDef } from '@zenstackhq/orm/schema'; +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; import { RPCApiHandler } from '@zenstackhq/server/api'; import { ZenStackMiddleware } from '@zenstackhq/server/express'; import type BetterSqlite3 from 'better-sqlite3'; @@ -113,20 +114,11 @@ export async function run(options: Options) { throw new CliError(`Failed to connect to the database: ${err instanceof Error ? err.message : String(err)}`); } - // If a publicAPIKey is provided, try to create an authDb with the policy plugin + // If a publicAPIKey is provided, create an authDb with the policy plugin let authDb: ClientContract | undefined; if (options.publicAPIKey) { - try { - const { PolicyPlugin } = await import('@zenstackhq/plugin-policy'); - authDb = db.$use(new PolicyPlugin()) as ClientContract; - console.log(colors.gray('Access policy plugin enabled for authorization.')); - } catch { - console.log( - colors.yellow( - 'Warning: @zenstackhq/plugin-policy is not installed. Authorization (per-user access control) will be disabled.', - ), - ); - } + authDb = db.$use(new PolicyPlugin()) as ClientContract; + console.log(colors.gray('Access policy plugin enabled for authorization.')); } startServer(db, schemaModule.schema, options, authDb); From 4c4c30ae5e8b41090cbd5df66950aaa7c6e81888 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Sun, 31 May 2026 19:00:35 +0800 Subject: [PATCH 3/6] feat(proxy): enhance public key handling and add tests for key formats --- packages/cli/src/actions/proxy.ts | 47 +++++++++++++++++++++++++++- packages/cli/src/index.ts | 2 +- packages/cli/test/proxy.test.ts | 52 +++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index df5b4c64c..8c0302d1b 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -44,7 +44,32 @@ type Options = { */ type UserClaim = { type: 'superUser' } | { type: 'user'; data: Record }; +/** + * Accepts a public key in either PEM format or as a raw base64 / base64url DER string + * (without the `-----BEGIN PUBLIC KEY-----` markers) and always returns a PEM string. + */ +function normalizePublicKey(key: string): string { + key = key.trim(); + if (key.startsWith('-----BEGIN PUBLIC KEY-----')) { + return key; + } + // Convert base64url → standard base64, then wrap in PEM markers. + const b64 = key.replace(/-/g, '+').replace(/_/g, '/'); + return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`; +} + export async function run(options: Options) { + // Resolve public key: CLI arg takes precedence, then ZENSTACK_PUBLIC_KEY env var. + options = { ...options, publicAPIKey: options.publicAPIKey ?? process.env['ZENSTACK_PUBLIC_KEY'] }; + if (!options.publicAPIKey) { + console.warn( + colors.yellow( + 'Warning: This proxy has no authentication. Do not expose it to the public network.\n' + + 'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_PUBLIC_KEY environment variable.', + ), + ); + } + const allowedLogLevels = ['error', 'query'] as const; const log = options.logLevel?.filter((level): level is (typeof allowedLogLevels)[number] => allowedLogLevels.includes(level as any), @@ -241,7 +266,8 @@ export function createProxyApp( if (options?.publicAPIKey) { // Apply signature-verification middleware to all authenticated endpoints. const toleranceSecs = options.signatureToleranceSecs ?? 60; - app.use(['/api/model', '/api/schema'], createSignatureMiddleware(options.publicAPIKey, toleranceSecs)); + const normalizedKey = normalizePublicKey(options.publicAPIKey); + app.use(['/api/model', '/api/schema'], createSignatureMiddleware(normalizedKey, toleranceSecs)); } app.use( @@ -271,6 +297,23 @@ export function createProxyApp( * `authorizationToken` is the bearer token value from the `Authorization` header (if present). */ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) { + // Throttle invalid-signature warnings to at most once per 60 seconds. + let lastInvalidSigWarnAt = 0; + const WARN_THROTTLE_SECS = 60; + + function warnInvalidSignature() { + const now = Math.floor(Date.now() / 1000); + if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) { + lastInvalidSigWarnAt = now; + console.warn( + colors.yellow( + 'Warning: Received a request with an invalid signature. ' + + 'Please double-check whether you have the correct public API key configured.', + ), + ); + } + } + return (req: express.Request, res: express.Response, next: express.NextFunction) => { const signatureHeader = req.headers['x-zenstack-signature']; if (!signatureHeader || typeof signatureHeader !== 'string') { @@ -312,9 +355,11 @@ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) try { const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')); if (!isValid) { + warnInvalidSignature(); return res.status(401).json({ message: 'Invalid signature' }); } } catch { + warnInvalidSignature(); return res.status(401).json({ message: 'Invalid signature' }); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9d8a13d93..d00bc963f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -266,7 +266,7 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us .addOption( new Option( '--publicAPIKey ', - 'PEM-encoded ed25519 public key used to verify request signatures. When provided, all requests to /api/model and /api/schema must include a valid x-zenstack-signature header.', + 'public key used to verify request signatures. Can also be set via the ZENSTACK_PUBLIC_KEY environment variable. ', ), ) .addOption( diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index e776702f7..edf2526a7 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -16,6 +16,9 @@ const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs= -----END PUBLIC KEY-----`; +/** Raw base64 DER — the same key without PEM markers. */ +const TEST_PUBLIC_KEY_DER = 'MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs='; + // ─── Helpers ────────────────────────────────────────────────────────────────── /** @@ -383,6 +386,55 @@ describe('CLI proxy tests', () => { }); }); + // ─── AuthN: public key format ────────────────────────────────────────────── + + describe('public key format', () => { + const zmodel = ` + model User { + id String @id @default(cuid()) + email String @unique + } + `; + + it('should accept a raw base64 DER key (without PEM markers)', async () => { + const client = await createTestClient(zmodel); + // Pass the key as raw base64 DER — no PEM markers + const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY_DER }); + const baseUrl = await startAt(app); + + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery }); + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(200); + }); + + it('should accept a key supplied via ZENSTACK_PUBLIC_KEY env variable', async () => { + const client = await createTestClient(zmodel); + // createProxyApp receives the already-resolved key (as run() would pass it), + // so we simulate env var resolution by passing the PEM directly. + process.env['ZENSTACK_PUBLIC_KEY'] = TEST_PUBLIC_KEY; + try { + // No publicAPIKey option — would normally fall back to env var via run(); + // here we verify the middleware still works when the resolved key is provided. + const app = createProxyApp(client, client.$schema, { + publicAPIKey: process.env['ZENSTACK_PUBLIC_KEY'], + }); + const baseUrl = await startAt(app); + + const pathWithQuery = '/api/model/user/findMany'; + const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery }); + const r = await fetch(`${baseUrl}${pathWithQuery}`, { + headers: { 'x-zenstack-signature': sig }, + }); + expect(r.status).toBe(200); + } finally { + delete process.env['ZENSTACK_PUBLIC_KEY']; + } + }); + }); + // ─── AuthN: timestamp / replay-attack prevention ─────────────────────────── describe('signature timestamp tolerance', () => { From d8268cdde4d8ddd173b37b6353a1afbb07771b55 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Thu, 4 Jun 2026 16:02:43 +0800 Subject: [PATCH 4/6] refactor(proxy): replace publicAPIKey with studioAuthKey for authentication in proxy --- packages/cli/src/actions/proxy.ts | 24 ++++++++-------- packages/cli/src/index.ts | 4 +-- packages/cli/test/proxy.test.ts | 48 +++++++++++++++---------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index 8c0302d1b..97ab51d70 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -34,7 +34,7 @@ type Options = { port?: number; logLevel?: string[]; databaseUrl?: string; - publicAPIKey?: string; + studioAuthKey?: string; signatureToleranceSecs?: number; }; @@ -59,13 +59,13 @@ function normalizePublicKey(key: string): string { } export async function run(options: Options) { - // Resolve public key: CLI arg takes precedence, then ZENSTACK_PUBLIC_KEY env var. - options = { ...options, publicAPIKey: options.publicAPIKey ?? process.env['ZENSTACK_PUBLIC_KEY'] }; - if (!options.publicAPIKey) { + // Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var. + options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] }; + if (!options.studioAuthKey) { console.warn( colors.yellow( 'Warning: This proxy has no authentication. Do not expose it to the public network.\n' + - 'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_PUBLIC_KEY environment variable.', + 'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_STUDIO_AUTH_KEY environment variable.', ), ); } @@ -139,9 +139,9 @@ export async function run(options: Options) { throw new CliError(`Failed to connect to the database: ${err instanceof Error ? err.message : String(err)}`); } - // If a publicAPIKey is provided, create an authDb with the policy plugin + // If a studioAuthKey is provided, create an authDb with the policy plugin let authDb: ClientContract | undefined; - if (options.publicAPIKey) { + if (options.studioAuthKey) { authDb = db.$use(new PolicyPlugin()) as ClientContract; console.log(colors.gray('Access policy plugin enabled for authorization.')); } @@ -244,7 +244,7 @@ export function createProxyApp( client: ClientContract, schema: SchemaDef, options?: { - publicAPIKey?: string; + studioAuthKey?: string; authDb?: ClientContract; /** Seconds within which a signed request is considered valid. Defaults to 60. */ signatureToleranceSecs?: number; @@ -263,10 +263,10 @@ export function createProxyApp( ); app.use(express.urlencoded({ extended: true, limit: '5mb' })); - if (options?.publicAPIKey) { + if (options?.studioAuthKey) { // Apply signature-verification middleware to all authenticated endpoints. const toleranceSecs = options.signatureToleranceSecs ?? 60; - const normalizedKey = normalizePublicKey(options.publicAPIKey); + const normalizedKey = normalizePublicKey(options.studioAuthKey); app.use(['/api/model', '/api/schema'], createSignatureMiddleware(normalizedKey, toleranceSecs)); } @@ -370,7 +370,7 @@ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) /** * Resolves the appropriate client for a request based on the Authorization header. * - * - No publicAPIKey configured (authDb is undefined): always return the base client. + * - No studioAuthKey configured (authDb is undefined): always return the base client. * - SuperUser claim: return the base client (full access, no policy enforcement). * - Regular user claim: return authDb with the user identity set via $setAuth. * - No / invalid token: return the base client. @@ -415,7 +415,7 @@ function startServer( authDb?: ClientContract, ) { const app = createProxyApp(client, schema, { - publicAPIKey: options.publicAPIKey, + studioAuthKey: options.studioAuthKey, authDb, signatureToleranceSecs: options.signatureToleranceSecs, }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d00bc963f..fe3d7fbac 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -265,8 +265,8 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us .addOption(new Option('-l, --logLevel ', 'Query log levels (e.g., query, error)')) .addOption( new Option( - '--publicAPIKey ', - 'public key used to verify request signatures. Can also be set via the ZENSTACK_PUBLIC_KEY environment variable. ', + '--studioAuthKey ', + 'Authentication key from ZenStack Studio. When set, the proxy will only accept requests signed by your Studio project.\nCan also be set via the ZENSTACK_STUDIO_AUTH_KEY environment variable. ', ), ) .addOption( diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index edf2526a7..56583bf3c 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -230,7 +230,7 @@ describe('CLI proxy tests', () => { // ─── AuthN: signature verification ───────────────────────────────────────── - describe('signature verification (publicAPIKey configured)', () => { + describe('signature verification (studioAuthKey configured)', () => { const zmodel = ` model User { id String @id @default(cuid()) @@ -240,7 +240,7 @@ describe('CLI proxy tests', () => { it('should reject requests missing the signature header with 401', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); const r = await fetch(`${baseUrl}/api/model/user/findMany`); @@ -252,7 +252,7 @@ describe('CLI proxy tests', () => { it('should reject requests with an invalid signature with 401', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); const r = await fetch(`${baseUrl}/api/model/user/findMany`, { @@ -263,7 +263,7 @@ describe('CLI proxy tests', () => { it('should allow GET requests with a valid signature', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); const path = '/api/model/user/findMany'; @@ -279,7 +279,7 @@ describe('CLI proxy tests', () => { it('should allow GET request with query params and a valid signature', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); // Pre-seed a record directly via client @@ -303,7 +303,7 @@ describe('CLI proxy tests', () => { it('should allow POST (create) requests with a valid signature', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); const reqBody = { data: { email: 'bob@example.com' } }; @@ -327,7 +327,7 @@ describe('CLI proxy tests', () => { it('should allow PUT (update) requests with a valid signature', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); // Seed a record @@ -354,7 +354,7 @@ describe('CLI proxy tests', () => { it('should allow signed schema endpoint', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); const pathWithQuery = '/api/schema'; @@ -368,9 +368,9 @@ describe('CLI proxy tests', () => { expect(body).toHaveProperty('models'); }); - it('should not require signatures when publicAPIKey is not configured', async () => { + it('should not require signatures when studioAuthKey is not configured', async () => { const client = await createTestClient(zmodel); - // No publicAPIKey — backwards-compatible mode + // No studioAuthKey — backwards-compatible mode const app = createProxyApp(client, client.$schema); const baseUrl = await startAt(app); @@ -399,7 +399,7 @@ describe('CLI proxy tests', () => { it('should accept a raw base64 DER key (without PEM markers)', async () => { const client = await createTestClient(zmodel); // Pass the key as raw base64 DER — no PEM markers - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY_DER }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY_DER }); const baseUrl = await startAt(app); const pathWithQuery = '/api/model/user/findMany'; @@ -410,16 +410,16 @@ describe('CLI proxy tests', () => { expect(r.status).toBe(200); }); - it('should accept a key supplied via ZENSTACK_PUBLIC_KEY env variable', async () => { + it('should accept a key supplied via ZENSTACK_STUDIO_AUTH_KEY env variable', async () => { const client = await createTestClient(zmodel); // createProxyApp receives the already-resolved key (as run() would pass it), // so we simulate env var resolution by passing the PEM directly. - process.env['ZENSTACK_PUBLIC_KEY'] = TEST_PUBLIC_KEY; + process.env['ZENSTACK_STUDIO_AUTH_KEY'] = TEST_PUBLIC_KEY; try { - // No publicAPIKey option — would normally fall back to env var via run(); + // No studioAuthKey option — would normally fall back to env var via run(); // here we verify the middleware still works when the resolved key is provided. const app = createProxyApp(client, client.$schema, { - publicAPIKey: process.env['ZENSTACK_PUBLIC_KEY'], + studioAuthKey: process.env['ZENSTACK_STUDIO_AUTH_KEY'], }); const baseUrl = await startAt(app); @@ -430,7 +430,7 @@ describe('CLI proxy tests', () => { }); expect(r.status).toBe(200); } finally { - delete process.env['ZENSTACK_PUBLIC_KEY']; + delete process.env['ZENSTACK_STUDIO_AUTH_KEY']; } }); }); @@ -447,7 +447,7 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is older than the tolerance window', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); // Timestamp 120 seconds ago — exceeds default 60-second tolerance @@ -470,7 +470,7 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is too far in the future', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); // Timestamp 120 seconds in the future — exceeds default 60-second tolerance @@ -495,7 +495,7 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); // Custom tolerance of 300 seconds const app = createProxyApp(client, client.$schema, { - publicAPIKey: TEST_PUBLIC_KEY, + studioAuthKey: TEST_PUBLIC_KEY, signatureToleranceSecs: 300, }); const baseUrl = await startAt(app); @@ -520,7 +520,7 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); // Very tight tolerance of 5 seconds const app = createProxyApp(client, client.$schema, { - publicAPIKey: TEST_PUBLIC_KEY, + studioAuthKey: TEST_PUBLIC_KEY, signatureToleranceSecs: 5, }); const baseUrl = await startAt(app); @@ -554,7 +554,7 @@ describe('CLI proxy tests', () => { it('should reject a valid signature if it was produced without the Authorization token', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); // Sign without including the auth token @@ -580,7 +580,7 @@ describe('CLI proxy tests', () => { it('should accept a request where the signature covers the Authorization token', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); const baseUrl = await startAt(app); const authToken = makeUserToken({ type: 'superUser' }); @@ -619,7 +619,7 @@ describe('CLI proxy tests', () => { const fullZmodel = extraZmodel ? `${zmodel}\n${extraZmodel}` : zmodel; const client = await createTestClient(fullZmodel); const authDb = client.$use(new PolicyPlugin()); - return { client, app: createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY, authDb }) }; + return { client, app: createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY, authDb }) }; } async function signedFetch(baseUrl: string, path: string, init: RequestInit = {}): Promise { @@ -742,7 +742,7 @@ describe('CLI proxy tests', () => { `; const client = await createTestClient(fullZmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY, authDb }); + const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY, authDb }); const baseUrl = await startAt(app); // Seed users From 6b12c5765fd27cf267242a9a95c4d0911724c51a Mon Sep 17 00:00:00 2001 From: jiasheng Date: Thu, 4 Jun 2026 16:08:11 +0800 Subject: [PATCH 5/6] fix: update plugin-policy dependency in pnpm-lock.yaml --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c463b209..60785c877 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: '@zenstackhq/orm': specifier: workspace:* version: link:../orm + '@zenstackhq/plugin-policy': + specifier: workspace:* + version: link:../plugins/policy '@zenstackhq/schema': specifier: workspace:* version: link:../schema @@ -304,9 +307,6 @@ importers: '@zenstackhq/eslint-config': specifier: workspace:* version: link:../config/eslint-config - '@zenstackhq/plugin-policy': - specifier: workspace:* - version: link:../plugins/policy '@zenstackhq/testtools': specifier: workspace:* version: link:../testtools From ef4984bfbb7dee00bf8942d29a1f3db3d0db8292 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Sat, 6 Jun 2026 13:25:09 +0800 Subject: [PATCH 6/6] refactor(proxy): enforce required fields and improve error handling for authentication --- packages/cli/src/actions/proxy.ts | 83 ++++++++++++-------- packages/cli/src/index.ts | 10 ++- packages/cli/test/proxy.test.ts | 123 +++++++++++++++++++----------- 3 files changed, 141 insertions(+), 75 deletions(-) diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index 97ab51d70..f9680d86a 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -27,22 +27,39 @@ import type { Pool as PgPoolType } from 'pg'; import { CliError } from '../cli-error'; import { getVersion } from '../utils/version-utils'; import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils'; +import { z } from 'zod'; type Options = { output?: string; schema?: string; - port?: number; + port: number; logLevel?: string[]; databaseUrl?: string; studioAuthKey?: string; - signatureToleranceSecs?: number; + signatureToleranceSecs: number; }; +export const ProxyAuthError = { + MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header', + INVALID_TIMESTAMP: 'Request timestamp is expired or invalid', + INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format', +} as const; + +type ProxyAuthErrorCode = keyof typeof ProxyAuthError; + +function rejectAuth(res: express.Response, code: ProxyAuthErrorCode) { + return res.status(401).json({ code, message: ProxyAuthError[code] }); +} /** * Represents the identity claim embedded in the Authorization header. * The bearer token is a plain base64-encoded JSON string. */ -type UserClaim = { type: 'superUser' } | { type: 'user'; data: Record }; +const UserClaimSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('superUser') }), + z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }), +]); + +type UserClaim = z.infer; /** * Accepts a public key in either PEM format or as a raw base64 / base64url DER string @@ -243,11 +260,11 @@ async function createDialect(provider: string, databaseUrl: string, outputPath: export function createProxyApp( client: ClientContract, schema: SchemaDef, - options?: { - studioAuthKey?: string; - authDb?: ClientContract; + auth?: { + studioAuthKey: string; + authDb: ClientContract; /** Seconds within which a signed request is considered valid. Defaults to 60. */ - signatureToleranceSecs?: number; + signatureToleranceSecs: number; }, ): express.Application { const app = express(); @@ -263,10 +280,10 @@ export function createProxyApp( ); app.use(express.urlencoded({ extended: true, limit: '5mb' })); - if (options?.studioAuthKey) { + if (auth?.studioAuthKey) { // Apply signature-verification middleware to all authenticated endpoints. - const toleranceSecs = options.signatureToleranceSecs ?? 60; - const normalizedKey = normalizePublicKey(options.studioAuthKey); + const toleranceSecs = auth.signatureToleranceSecs; + const normalizedKey = normalizePublicKey(auth.studioAuthKey); app.use(['/api/model', '/api/schema'], createSignatureMiddleware(normalizedKey, toleranceSecs)); } @@ -274,7 +291,7 @@ export function createProxyApp( '/api/model', ZenStackMiddleware({ apiHandler: new RPCApiHandler({ schema }), - getClient: (req) => resolveClient(client, options?.authDb, req), + getClient: (req) => resolveClient(client, auth?.authDb, req), }), ); @@ -317,14 +334,14 @@ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) return (req: express.Request, res: express.Response, next: express.NextFunction) => { const signatureHeader = req.headers['x-zenstack-signature']; if (!signatureHeader || typeof signatureHeader !== 'string') { - return res.status(401).json({ message: 'Missing x-zenstack-signature header' }); + return rejectAuth(res, 'MISSING_SIGNATURE_HEADER'); } const parts = signatureHeader.split(','); const timestampPart = parts.find((p) => p.startsWith('t=')); const sigPart = parts.find((p) => p.startsWith('v1=')); if (!timestampPart || !sigPart) { - return res.status(401).json({ message: 'Invalid x-zenstack-signature format' }); + return rejectAuth(res, 'INVALID_SIGNATURE_FORMAT'); } const timestamp = timestampPart.substring(2); const sig = sigPart.substring(3); @@ -334,7 +351,7 @@ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) const requestTime = parseInt(timestamp, 10); const now = Math.floor(Date.now() / 1000); if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) { - return res.status(401).json({ message: 'Request timestamp is expired or invalid' }); + return rejectAuth(res, 'INVALID_TIMESTAMP'); } // Payload: raw query string for GET/DELETE, raw body for other methods. @@ -356,11 +373,11 @@ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')); if (!isValid) { warnInvalidSignature(); - return res.status(401).json({ message: 'Invalid signature' }); + return rejectAuth(res, 'INVALID_SIGNATURE_FORMAT'); } } catch { warnInvalidSignature(); - return res.status(401).json({ message: 'Invalid signature' }); + return rejectAuth(res, 'INVALID_SIGNATURE_FORMAT'); } return next(); @@ -386,26 +403,26 @@ function resolveClient( const authHeader = req.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) { - return client; + return authDb; } const token = authHeader.substring(7); let claim: UserClaim; try { - claim = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as UserClaim; - } catch { - return client; + claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8'))); + } catch (err) { + console.error( + colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`), + ); + return authDb; } if (claim.type === 'superUser') { + // SuperUser has full access without policy enforcement, so we return the base client directly. return client; - } - - if (claim.type === 'user') { + } else { return authDb.$setAuth(claim.data) as ClientContract; } - - return client; } function startServer( @@ -414,11 +431,17 @@ function startServer( options: Options, authDb?: ClientContract, ) { - const app = createProxyApp(client, schema, { - studioAuthKey: options.studioAuthKey, - authDb, - signatureToleranceSecs: options.signatureToleranceSecs, - }); + const app = createProxyApp( + client, + schema, + options.studioAuthKey + ? { + studioAuthKey: options.studioAuthKey, + authDb: authDb!, + signatureToleranceSecs: options.signatureToleranceSecs, + } + : undefined, + ); const server = app.listen(options.port, () => { console.log(`ZenStack proxy server is running on port: ${options.port}`); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fe3d7fbac..ba458ded5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -273,7 +273,15 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us new Option( '--signatureToleranceSecs ', 'Maximum age (in seconds) of a signed request before it is rejected as a replay. Defaults to 60.', - ).argParser((v) => parseInt(v, 10)), + ) + .default(60) + .argParser((v) => { + const parsed = parseInt(v, 10); + if (isNaN(parsed) || parsed < 0) { + throw new CliError(`--signatureToleranceSecs must be a positive integer, got: ${v}`); + } + return parsed; + }), ) .addOption(noVersionCheckOption) .action(proxyAction); diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index 56583bf3c..511d4c1e1 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -61,6 +61,19 @@ function makeUserToken(claim: { type: 'superUser' } | { type: 'user'; data: Reco return Buffer.from(JSON.stringify(claim)).toString('base64'); } +async function createPolicyApp(zmodel: string) { + const client = await createTestClient(zmodel); + const authDb = client.$use(new PolicyPlugin()); + return { + client, + app: createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY, + authDb, + signatureToleranceSecs: 60, + }), + }; +} + // ─── Test suite ─────────────────────────────────────────────────────────────── describe('CLI proxy tests', () => { @@ -230,19 +243,19 @@ describe('CLI proxy tests', () => { // ─── AuthN: signature verification ───────────────────────────────────────── - describe('signature verification (studioAuthKey configured)', () => { + describe('signature verification (studioAuthKey configured)', async () => { const zmodel = ` model User { id String @id @default(cuid()) email String @unique + + @@allow('all', true) } `; it('should reject requests missing the signature header with 401', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - const r = await fetch(`${baseUrl}/api/model/user/findMany`); expect(r.status).toBe(401); @@ -251,10 +264,8 @@ describe('CLI proxy tests', () => { }); it('should reject requests with an invalid signature with 401', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - const r = await fetch(`${baseUrl}/api/model/user/findMany`, { headers: { 'x-zenstack-signature': 't=1234567890,v1=invalidsignature' }, }); @@ -262,10 +273,8 @@ describe('CLI proxy tests', () => { }); it('should allow GET requests with a valid signature', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - const path = '/api/model/user/findMany'; const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery: path }); @@ -278,10 +287,8 @@ describe('CLI proxy tests', () => { }); it('should allow GET request with query params and a valid signature', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { client, app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - // Pre-seed a record directly via client await client.user.create({ data: { id: 'u1', email: 'alice@example.com' } }); @@ -302,10 +309,8 @@ describe('CLI proxy tests', () => { }); it('should allow POST (create) requests with a valid signature', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - const reqBody = { data: { email: 'bob@example.com' } }; const pathWithQuery = '/api/model/user/create'; const sig = buildSignatureHeader({ @@ -326,13 +331,10 @@ describe('CLI proxy tests', () => { }); it('should allow PUT (update) requests with a valid signature', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { app, client } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - // Seed a record await client.user.create({ data: { id: 'u1', email: 'old@example.com' } }); - const reqBody = { where: { id: 'u1' }, data: { email: 'new@example.com' } }; const pathWithQuery = '/api/model/user/update'; const sig = buildSignatureHeader({ @@ -353,10 +355,8 @@ describe('CLI proxy tests', () => { }); it('should allow signed schema endpoint', async () => { - const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const { app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); - const pathWithQuery = '/api/schema'; const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery }); @@ -369,8 +369,8 @@ describe('CLI proxy tests', () => { }); it('should not require signatures when studioAuthKey is not configured', async () => { - const client = await createTestClient(zmodel); // No studioAuthKey — backwards-compatible mode + const client = await createTestClient(zmodel); const app = createProxyApp(client, client.$schema); const baseUrl = await startAt(app); @@ -398,8 +398,13 @@ describe('CLI proxy tests', () => { it('should accept a raw base64 DER key (without PEM markers)', async () => { const client = await createTestClient(zmodel); + const authDb = client.$use(new PolicyPlugin()); // Pass the key as raw base64 DER — no PEM markers - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY_DER }); + const app = createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY_DER, + authDb, + signatureToleranceSecs: 60, + }); const baseUrl = await startAt(app); const pathWithQuery = '/api/model/user/findMany'; @@ -418,8 +423,11 @@ describe('CLI proxy tests', () => { try { // No studioAuthKey option — would normally fall back to env var via run(); // here we verify the middleware still works when the resolved key is provided. + const authDb = client.$use(new PolicyPlugin()); const app = createProxyApp(client, client.$schema, { studioAuthKey: process.env['ZENSTACK_STUDIO_AUTH_KEY'], + authDb, + signatureToleranceSecs: 60, }); const baseUrl = await startAt(app); @@ -447,7 +455,12 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is older than the tolerance window', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const authDb = client.$use(new PolicyPlugin()); + const app = createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY, + authDb, + signatureToleranceSecs: 60, + }); const baseUrl = await startAt(app); // Timestamp 120 seconds ago — exceeds default 60-second tolerance @@ -470,7 +483,12 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is too far in the future', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const authDb = client.$use(new PolicyPlugin()); + const app = createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY, + authDb, + signatureToleranceSecs: 60, + }); const baseUrl = await startAt(app); // Timestamp 120 seconds in the future — exceeds default 60-second tolerance @@ -494,8 +512,10 @@ describe('CLI proxy tests', () => { it('should accept a request within a custom tolerance window', async () => { const client = await createTestClient(zmodel); // Custom tolerance of 300 seconds + const authDb = client.$use(new PolicyPlugin()); const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY, + authDb, signatureToleranceSecs: 300, }); const baseUrl = await startAt(app); @@ -518,9 +538,11 @@ describe('CLI proxy tests', () => { it('should reject a request that falls outside a custom tolerance window', async () => { const client = await createTestClient(zmodel); + const authDb = client.$use(new PolicyPlugin()); // Very tight tolerance of 5 seconds const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY, + authDb, signatureToleranceSecs: 5, }); const baseUrl = await startAt(app); @@ -554,7 +576,12 @@ describe('CLI proxy tests', () => { it('should reject a valid signature if it was produced without the Authorization token', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const authDb = client.$use(new PolicyPlugin()); + const app = createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY, + authDb, + signatureToleranceSecs: 60, + }); const baseUrl = await startAt(app); // Sign without including the auth token @@ -580,7 +607,12 @@ describe('CLI proxy tests', () => { it('should accept a request where the signature covers the Authorization token', async () => { const client = await createTestClient(zmodel); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY }); + const authDb = client.$use(new PolicyPlugin()); + const app = createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY, + authDb, + signatureToleranceSecs: 60, + }); const baseUrl = await startAt(app); const authToken = makeUserToken({ type: 'superUser' }); @@ -615,13 +647,6 @@ describe('CLI proxy tests', () => { } `; - async function createPolicyApp(extraZmodel?: string) { - const fullZmodel = extraZmodel ? `${zmodel}\n${extraZmodel}` : zmodel; - const client = await createTestClient(fullZmodel); - const authDb = client.$use(new PolicyPlugin()); - return { client, app: createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY, authDb }) }; - } - async function signedFetch(baseUrl: string, path: string, init: RequestInit = {}): Promise { const method = (init.method ?? 'GET').toUpperCase(); const body = init.body ? JSON.parse(init.body as string) : undefined; @@ -644,8 +669,20 @@ describe('CLI proxy tests', () => { }); } + it('anonymous request should not bypass access policy', async () => { + const { client, app } = await createPolicyApp(zmodel); + const baseUrl = await startAt(app); + await client.user.create({ data: { id: 'u1', email: 'user1@example.com' } }); + await client.user.create({ data: { id: 'u2', email: 'user2@example.com' } }); + const pathWithQuery = '/api/model/user/findMany'; + const r = await signedFetch(baseUrl, pathWithQuery); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.data).toHaveLength(0); + }); + it('superUser can access all records', async () => { - const { client, app } = await createPolicyApp(); + const { client, app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); // Seed two users directly via base client (bypasses policy) @@ -664,7 +701,7 @@ describe('CLI proxy tests', () => { }); it('regular user can only access their own record', async () => { - const { client, app } = await createPolicyApp(); + const { client, app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); // Seed two users @@ -685,7 +722,7 @@ describe('CLI proxy tests', () => { }); it("regular user cannot update another user's record", async () => { - const { client, app } = await createPolicyApp(); + const { client, app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); await client.user.create({ data: { id: 'u1', email: 'user1@example.com' } }); @@ -706,7 +743,7 @@ describe('CLI proxy tests', () => { }); it('superUser can create records on behalf of others', async () => { - const { client: _client, app } = await createPolicyApp(); + const { client: _client, app } = await createPolicyApp(zmodel); const baseUrl = await startAt(app); const reqBody = { data: { id: 'u1', email: 'user1@example.com' } }; @@ -723,7 +760,7 @@ describe('CLI proxy tests', () => { }); it('sequential transaction respects user-scoped policy', async () => { - const fullZmodel = ` + const fullZModel = ` model User { id String @id @default(cuid()) email String @unique @@ -740,9 +777,7 @@ describe('CLI proxy tests', () => { @@allow('all', auth() != null && auth().id == authorId) } `; - const client = await createTestClient(fullZmodel); - const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, { studioAuthKey: TEST_PUBLIC_KEY, authDb }); + const { client, app } = await createPolicyApp(fullZModel); const baseUrl = await startAt(app); // Seed users