diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ce77f6cb..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", @@ -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..f9680d86a 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'; @@ -20,21 +21,72 @@ 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'; 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; }; +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. + */ +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 + * (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_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_STUDIO_AUTH_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), @@ -104,7 +156,14 @@ 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 studioAuthKey is provided, create an authDb with the policy plugin + let authDb: ClientContract | undefined; + if (options.studioAuthKey) { + authDb = db.$use(new PolicyPlugin()) as ClientContract; + console.log(colors.gray('Access policy plugin enabled for authorization.')); + } + + startServer(db, schemaModule.schema, options, authDb); } function evaluateUrl(schemaUrl: ConfigExpr) { @@ -198,17 +257,41 @@ async function createDialect(provider: string, databaseUrl: string, outputPath: } } -export function createProxyApp(client: ClientContract, schema: SchemaDef): express.Application { +export function createProxyApp( + client: ClientContract, + schema: SchemaDef, + auth?: { + studioAuthKey: 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 (auth?.studioAuthKey) { + // Apply signature-verification middleware to all authenticated endpoints. + const toleranceSecs = auth.signatureToleranceSecs; + const normalizedKey = normalizePublicKey(auth.studioAuthKey); + app.use(['/api/model', '/api/schema'], createSignatureMiddleware(normalizedKey, toleranceSecs)); + } + app.use( '/api/model', ZenStackMiddleware({ apiHandler: new RPCApiHandler({ schema }), - getClient: () => client, + getClient: (req) => resolveClient(client, auth?.authDb, req), }), ); @@ -219,8 +302,146 @@ 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) { + // 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') { + 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 rejectAuth(res, 'INVALID_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 rejectAuth(res, 'INVALID_TIMESTAMP'); + } + + // 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) { + warnInvalidSignature(); + return rejectAuth(res, 'INVALID_SIGNATURE_FORMAT'); + } + } catch { + warnInvalidSignature(); + return rejectAuth(res, 'INVALID_SIGNATURE_FORMAT'); + } + + return next(); + }; +} + +/** + * Resolves the appropriate client for a request based on the Authorization header. + * + * - 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. + */ +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 authDb; + } + + const token = authHeader.substring(7); + let claim: UserClaim; + try { + 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; + } else { + return authDb.$setAuth(claim.data) as ClientContract; + } +} + +function startServer( + client: ClientContract, + schema: any, + options: Options, + authDb?: ClientContract, +) { + 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 dd5aea7aa..ba458ded5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -263,6 +263,26 @@ 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( + '--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( + new Option( + '--signatureToleranceSecs ', + 'Maximum age (in seconds) of a signed request before it is rejected as a replay. Defaults to 60.', + ) + .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 840412f23..511d4c1e1 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -1,8 +1,81 @@ +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-----`; + +/** Raw base64 DER — the same key without PEM markers. */ +const TEST_PUBLIC_KEY_DER = 'MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs='; + +// ─── 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'); +} + +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', () => { let server: http.Server | undefined; @@ -70,7 +143,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 +240,570 @@ 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 (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 { app } = await createPolicyApp(zmodel); + 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 { 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' }, + }); + expect(r.status).toBe(401); + }); + + it('should allow GET requests with a valid signature', async () => { + 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 }); + + 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, 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' } }); + + 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 { 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({ + 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 { 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({ + 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 { app } = await createPolicyApp(zmodel); + 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 studioAuthKey is not configured', async () => { + // No studioAuthKey — backwards-compatible mode + const client = await createTestClient(zmodel); + 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: 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); + 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, + authDb, + signatureToleranceSecs: 60, + }); + 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_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_STUDIO_AUTH_KEY'] = TEST_PUBLIC_KEY; + 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); + + 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_STUDIO_AUTH_KEY']; + } + }); + }); + + // ─── 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 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 + 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 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 + 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 authDb = client.$use(new PolicyPlugin()); + const app = createProxyApp(client, client.$schema, { + studioAuthKey: TEST_PUBLIC_KEY, + authDb, + 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); + 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); + + // 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 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 + 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 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' }); + 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 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('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(zmodel); + 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(zmodel); + 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(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' } }); + + // 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(zmodel); + 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, app } = await createPolicyApp(fullZModel); + 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..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