From 941bdc3e2fed8932842d71e06aa00d24f0222b17 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 23 Mar 2026 17:09:25 +0100 Subject: [PATCH 01/25] Added logic to replace html-characters. --- src/frontend/render.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/frontend/render.ts b/src/frontend/render.ts index 3d3d3bbb..696e20fb 100644 --- a/src/frontend/render.ts +++ b/src/frontend/render.ts @@ -3,7 +3,12 @@ import type { ChangesResponse, ChangesRow } from '../shared/view-types.js'; import { renderResultsPlots } from './plots.js'; export function filterCommitMessage(msg: string): string { - const result = msg.replace(/Signed-off-by:.*?\n/g, ''); + const result = msg.replace(/Signed-off-by:.*?\n/g, '') + .replace(/&/g, "&") // must go first! + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); return result.trim(); } From 28101f0a8afa275c3b0e45c7bab1ce67967d24b1 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 23 Mar 2026 17:13:13 +0100 Subject: [PATCH 02/25] Added logic to replace html-characters. --- src/frontend/render.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/render.ts b/src/frontend/render.ts index 696e20fb..5ecc1424 100644 --- a/src/frontend/render.ts +++ b/src/frontend/render.ts @@ -4,7 +4,7 @@ import { renderResultsPlots } from './plots.js'; export function filterCommitMessage(msg: string): string { const result = msg.replace(/Signed-off-by:.*?\n/g, '') - .replace(/&/g, "&") // must go first! + .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) From 3b5ad717048613364c45e96ba2d846073e6f9631 Mon Sep 17 00:00:00 2001 From: Stefan Marr Date: Wed, 8 Apr 2026 13:57:56 +0200 Subject: [PATCH 03/25] Add test for filtering sign off lines Signed-off-by: Stefan Marr --- tests/frontend/render.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/frontend/render.test.ts diff --git a/tests/frontend/render.test.ts b/tests/frontend/render.test.ts new file mode 100644 index 00000000..dadf8ae7 --- /dev/null +++ b/tests/frontend/render.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from '@jest/globals'; +import { filterCommitMessage } from '../../src/frontend/render.js'; + +describe('filterCommitMessage(msg: string)', () => { + describe('removal of sign off lines', () => { + it('should remove a sign off line', () => { + const msg = `This is a message\n\nSigned-off-by: J.D. \n`; + const expected = `This is a message`; + expect(filterCommitMessage(msg)).toBe(expected); + }); + + it('should remove multiple sign off lines', () => { + const msg = `This is a message + + Signed-off-by: J.D. \n + Signed-off-by: A.B. \n`; + const expected = `This is a message`; + expect(filterCommitMessage(msg)).toBe(expected); + }); + }); +}); From ba2557dcfa894a4c87a623afc5b49c44afe4f93a Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Wed, 15 Apr 2026 17:07:30 +0200 Subject: [PATCH 04/25] Added test cases to test the escaping of html --- src/frontend/render.ts | 10 +++++----- tests/frontend/render.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/frontend/render.ts b/src/frontend/render.ts index 5ecc1424..03a105ce 100644 --- a/src/frontend/render.ts +++ b/src/frontend/render.ts @@ -4,11 +4,11 @@ import { renderResultsPlots } from './plots.js'; export function filterCommitMessage(msg: string): string { const result = msg.replace(/Signed-off-by:.*?\n/g, '') - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); return result.trim(); } diff --git a/tests/frontend/render.test.ts b/tests/frontend/render.test.ts index dadf8ae7..5a30c828 100644 --- a/tests/frontend/render.test.ts +++ b/tests/frontend/render.test.ts @@ -18,4 +18,26 @@ describe('filterCommitMessage(msg: string)', () => { expect(filterCommitMessage(msg)).toBe(expected); }); }); + + describe('escaping of html characters', () => { + it('should escape html characters <, >', () => { + const msg = `This is a message with characters`; + const expected = `This is a message with <html> characters`; + expect(filterCommitMessage(msg)).toBe(expected); + }); + + it('should escape html characters ", \' and &', () => { + const msg = `Message with ", ' and &`; + const expected = `Message with ", ' and &`; + expect(filterCommitMessage(msg)).toBe(expected); + }); + }); + + describe('normal messages', () => { + it('should leave a normal message unchanged', () => { + const msg = `Normal message with no special handling needed.`; + const expected = `Normal message with no special handling needed.`; + expect(filterCommitMessage(msg)).toBe(expected); + }); + }); }); From 166d47098bfe1f00eabd6265a776928585dc8c38 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Thu, 23 Apr 2026 11:12:17 +0200 Subject: [PATCH 05/25] Added Database changes. --- package-lock.json | 166 ++++++ package.json | 6 +- src/backend/auth/auth-db.ts | 50 ++ src/backend/auth/auth-middleware.ts | 41 ++ src/backend/auth/auth-routes.ts | 114 ++++ src/backend/compare/compare.ts | 39 +- src/backend/db/database-with-pool.ts | 33 +- src/backend/db/db.sql | 200 +++++++ src/backend/db/db.ts | 12 + .../db/schema-updates/migration.014.sql | 212 +++++++ src/backend/main/main.ts | 15 +- src/backend/project/data-export.ts | 4 +- src/backend/project/project.ts | 28 +- src/backend/timeline/timeline.ts | 26 +- src/index.ts | 57 +- tests/backend/db/auth-db.test.ts | 212 +++++++ tests/backend/db/db-testing.ts | 13 + tests/backend/db/db.test.ts | 7 + tests/backend/db/rls.test.ts | 533 ++++++++++++++++++ 19 files changed, 1706 insertions(+), 62 deletions(-) create mode 100644 src/backend/auth/auth-db.ts create mode 100644 src/backend/auth/auth-middleware.ts create mode 100644 src/backend/auth/auth-routes.ts create mode 100644 src/backend/db/schema-updates/migration.014.sql create mode 100644 tests/backend/db/auth-db.test.ts create mode 100644 tests/backend/db/rls.test.ts diff --git a/package-lock.json b/package-lock.json index 1abc26fa..64463837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,14 @@ "@octokit/auth-app": "8.1.2", "@octokit/rest": "22.0.1", "@sgratzl/chartjs-chart-boxplot": "4.4.5", + "bcrypt": "^6.0.0", "canvas": "3.2.1", "chart.js": "4.5.1", "chartjs-plugin-annotation": "3.1.0", "decimal.js": "10.6.0", "ejs": "3.1.10", "join-images": "1.1.5", + "jsonwebtoken": "^9.0.3", "koa": "3.1.1", "koa-body": "7.0.1", "pg": "8.16.3", @@ -30,8 +32,10 @@ }, "devDependencies": { "@octokit/types": "16.0.0", + "@types/bcrypt": "^6.0.0", "@types/ejs": "3.1.5", "@types/jquery": "3.5.33", + "@types/jsonwebtoken": "^9.0.10", "@types/koa": "3.0.1", "@types/koa__router": "12.0.5", "@types/pg": "8.16.0", @@ -2322,6 +2326,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2471,6 +2485,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -2512,6 +2537,13 @@ "@types/koa": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", @@ -3461,6 +3493,29 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -3596,6 +3651,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4270,6 +4331,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6450,6 +6520,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -6595,6 +6708,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6609,6 +6758,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6845,6 +7000,17 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index 99191bf0..f92203f8 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,20 @@ "license": "MIT", "type": "module", "dependencies": { + "@koa/router": "14.0.0", "@octokit/auth-app": "8.1.2", "@octokit/rest": "22.0.1", "@sgratzl/chartjs-chart-boxplot": "4.4.5", + "bcrypt": "^6.0.0", "canvas": "3.2.1", "chart.js": "4.5.1", "chartjs-plugin-annotation": "3.1.0", "decimal.js": "10.6.0", "ejs": "3.1.10", "join-images": "1.1.5", + "jsonwebtoken": "^9.0.3", "koa": "3.1.1", "koa-body": "7.0.1", - "@koa/router": "14.0.0", "pg": "8.16.3", "promisify-child-process": "4.1.2", "sharp": "0.34.5", @@ -39,8 +41,10 @@ }, "devDependencies": { "@octokit/types": "16.0.0", + "@types/bcrypt": "^6.0.0", "@types/ejs": "3.1.5", "@types/jquery": "3.5.33", + "@types/jsonwebtoken": "^9.0.10", "@types/koa": "3.0.1", "@types/koa__router": "12.0.5", "@types/pg": "8.16.0", diff --git a/src/backend/auth/auth-db.ts b/src/backend/auth/auth-db.ts new file mode 100644 index 00000000..748d035b --- /dev/null +++ b/src/backend/auth/auth-db.ts @@ -0,0 +1,50 @@ +import type { Database } from '../db/db.js'; + +export interface AppUser { + id: number; + username: string; + email: string; + password_hash: string; + created_at: Date; + is_active: boolean; +} + +export async function getUserByUsername( + db: Database, + username: string +): Promise { + const result = await db.query({ + name: 'getUserByUsername', + text: 'SELECT * FROM appuser WHERE username = $1', + values: [username] + }); + return result.rows[0] ?? null; +} + +export async function getUserByEmail( + db: Database, + email: string +): Promise { + const result = await db.query({ + name: 'getUserByEmail', + text: 'SELECT * FROM appuser WHERE email = $1', + values: [email] + }); + return result.rows[0] ?? null; +} + +export async function createUser( + db: Database, + username: string, + email: string, + passwordHash: string +): Promise { + const result = await db.query({ + name: 'createUser', + text: `INSERT INTO appuser (username, email, password_hash) + VALUES ($1, $2, $3) + RETURNING *`, + values: [username, email, passwordHash] + }); + return result.rows[0]; +} diff --git a/src/backend/auth/auth-middleware.ts b/src/backend/auth/auth-middleware.ts new file mode 100644 index 00000000..320fa6e3 --- /dev/null +++ b/src/backend/auth/auth-middleware.ts @@ -0,0 +1,41 @@ +import { Next, ParameterizedContext } from 'koa'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || ''; + +if (!JWT_SECRET) { + console.warn( + '[auth] WARNING: JWT_SECRET environment variable is not set. ' + + 'Authentication will not work correctly.' + ); +} + +export interface AuthState { + userId: number; + username: string; +} + +export async function requireAuth( + ctx: ParameterizedContext, + next: Next +): Promise { + const header = ctx.headers.authorization; + if (!header?.startsWith('Bearer ')) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Authentication required' }; + return; + } + + try { + const token = header.slice(7); + const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; + ctx.state.userId = Number(payload.sub); + ctx.state.username = payload.username as string; + await next(); + } catch { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid or expired token' }; + } +} diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts new file mode 100644 index 00000000..bee7c5e3 --- /dev/null +++ b/src/backend/auth/auth-routes.ts @@ -0,0 +1,114 @@ +import { ParameterizedContext } from 'koa'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +import type { Database } from '../db/db.js'; +import { + createUser, + getUserByEmail, + getUserByUsername +} from './auth-db.js'; + +const JWT_SECRET = process.env.JWT_SECRET || ''; +const BCRYPT_ROUNDS = 12; + +export async function register( + ctx: ParameterizedContext, + db: Database +): Promise { + const body = ctx.request.body as any; + const { username, email, password } = body ?? {}; + + if (!username || !email || !password) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'username, email, and password are required' }; + return; + } + + if (typeof username !== 'string' || username.length > 100) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'username must be a string of at most 100 characters' }; + return; + } + + if (typeof email !== 'string' || email.length > 255) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'email must be a string of at most 255 characters' }; + return; + } + + if (typeof password !== 'string' || password.length < 8) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'password must be at least 8 characters' }; + return; + } + + const existingByUsername = await getUserByUsername(db, username); + if (existingByUsername) { + ctx.status = 409; + ctx.type = 'json'; + ctx.body = { error: 'Username already taken' }; + return; + } + + const existingByEmail = await getUserByEmail(db, email); + if (existingByEmail) { + ctx.status = 409; + ctx.type = 'json'; + ctx.body = { error: 'Email already registered' }; + return; + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const user = await createUser(db, username, email, passwordHash); + + ctx.status = 201; + ctx.type = 'json'; + ctx.body = { userId: user.id, username: user.username }; +} + +export async function login( + ctx: ParameterizedContext, + db: Database +): Promise { + const body = ctx.request.body as any; + const { username, password } = body ?? {}; + + if (!username || !password) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'username and password are required' }; + return; + } + + const user = await getUserByUsername(db, username); + + if (!user || !user.is_active) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid credentials' }; + return; + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid credentials' }; + return; + } + + const token = jwt.sign( + { sub: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: '24h' } + ); + + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { token }; +} diff --git a/src/backend/compare/compare.ts b/src/backend/compare/compare.ts index c042cbdb..877b4236 100644 --- a/src/backend/compare/compare.ts +++ b/src/backend/compare/compare.ts @@ -31,7 +31,9 @@ export async function getProfileAsJson( const start = startRequest(); - ctx.body = await getProfile(runId, ctx.params.commitId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getProfile(runId, ctx.params.commitId, db) + ); if (ctx.body === undefined) { ctx.status = 404; ctx.body = {}; @@ -85,12 +87,14 @@ export async function getMeasurementsAsJson( const start = startRequest(); - ctx.body = await getMeasurements( - ctx.params.projectSlug, - runId, - ctx.params.baseId, - ctx.params.changeId, - db + ctx.body = await db.withUserContext(ctx.state.userId, () => + getMeasurements( + ctx.params.projectSlug, + runId, + ctx.params.baseId, + ctx.params.changeId, + db + ) ); completeRequestAndHandlePromise(start, db, 'get-measurements'); @@ -174,9 +178,8 @@ export async function getTimelineDataAsJson( db: Database ): Promise { const timelineRequest = (ctx.request.body); - const result = await db.getTimelineData( - ctx.params.projectName, - timelineRequest + const result = await db.withUserContext(ctx.state.userId, () => + db.getTimelineData(ctx.params.projectName, timelineRequest) ); if (result === null) { ctx.body = { error: 'Requested data was not found' }; @@ -195,7 +198,9 @@ export async function redirectToNewCompareUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectByName(ctx.params.project); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectByName(ctx.params.project) + ); if (project) { ctx.redirect( `/${project.slug}/compare/${ctx.params.baseline}..${ctx.params.change}` @@ -211,11 +216,13 @@ export async function renderComparePage( ): Promise { const start = startRequest(); - const data = await renderCompare( - ctx.params.baseline, - ctx.params.change, - ctx.params.projectSlug, - db + const data = await db.withUserContext(ctx.state.userId, () => + renderCompare( + ctx.params.baseline, + ctx.params.change, + ctx.params.projectSlug, + db + ) ); ctx.body = data.content; ctx.type = 'html'; diff --git a/src/backend/db/database-with-pool.ts b/src/backend/db/database-with-pool.ts index cdcd50fe..f3f1d0b9 100644 --- a/src/backend/db/database-with-pool.ts +++ b/src/backend/db/database-with-pool.ts @@ -1,8 +1,13 @@ -import pg, { PoolConfig, QueryConfig, QueryResult, QueryResultRow } from 'pg'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +// eslint-disable-next-line max-len +import pg, { PoolClient, PoolConfig, QueryConfig, QueryResult, QueryResultRow } from 'pg'; import { Database } from './db.js'; import { BatchingTimelineUpdater } from '../timeline/timeline-calc.js'; +const userContextStorage = new AsyncLocalStorage<{ client: PoolClient }>(); + export class DatabaseWithPool extends Database { private pool: pg.Pool; @@ -23,9 +28,35 @@ export class DatabaseWithPool extends Database { public async query( queryConfig: QueryConfig ): Promise> { + const context = userContextStorage.getStore(); + if (context) { + return context.client.query(queryConfig); + } return this.pool.query(queryConfig); } + public async withUserContext( + userId: number | null, + fn: () => Promise + ): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await client.query('SET LOCAL ROLE rdb_app'); + if (userId !== null) { + await client.query(`SET LOCAL app.current_user_id = '${userId}'`); + } + const result = await userContextStorage.run({ client }, fn); + await client.query('COMMIT'); + return result; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } + public async close(): Promise { await super.close(); this.statsValid.invalidateAndNew(); diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index 52a7b3ad..be7ba024 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -200,6 +200,206 @@ CREATE TABLE Timeline ( foreign key (criterion) references Criterion (id) ); +-- ============================================================ +-- 1. ENUM type for membership roles +-- ============================================================ +CREATE TYPE project_role AS ENUM ('view', 'edit', 'owner'); + +-- ============================================================ +-- 2. Application user table +-- ============================================================ +CREATE TABLE appuser ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, -- bcrypt hash, always 60 chars + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_active BOOLEAN NOT NULL DEFAULT true +); + +-- ============================================================ +-- 3. Project membership (user <-> project with role) +-- ============================================================ +CREATE TABLE ProjectMembership ( -- TODO: Test what gets deleted when deleting a user + userId INTEGER NOT NULL REFERENCES appuser(id) ON DELETE CASCADE, + projectId INTEGER NOT NULL REFERENCES Project(id) ON DELETE CASCADE, + role project_role NOT NULL DEFAULT 'view', + PRIMARY KEY (userId, projectId) +); + +CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); + +-- ============================================================ +-- 4. Dedicated non-superuser DB role for the application. +-- The backend will SET LOCAL ROLE rdb_app inside each +-- user-facing transaction so that RLS policies fire even +-- when the pool connects as the DB owner / superuser. +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'rdb_app') THEN +CREATE ROLE rdb_app LOGIN; +END IF; +END$$; + +-- ============================================================ +-- 5. Enable Row Level Security on all relevant tables. +-- FORCE ensures the table owner / superuser is also filtered +-- when SET LOCAL ROLE rdb_app is in effect. +-- ============================================================ +ALTER TABLE Project ENABLE ROW LEVEL SECURITY; +ALTER TABLE Experiment ENABLE ROW LEVEL SECURITY; +ALTER TABLE Trial ENABLE ROW LEVEL SECURITY; +ALTER TABLE Run ENABLE ROW LEVEL SECURITY; +ALTER TABLE Measurement ENABLE ROW LEVEL SECURITY; +ALTER TABLE Timeline ENABLE ROW LEVEL SECURITY; +ALTER TABLE Source ENABLE ROW LEVEL SECURITY; +ALTER TABLE ProfileData ENABLE ROW LEVEL SECURITY; + +ALTER TABLE Project FORCE ROW LEVEL SECURITY; +ALTER TABLE Experiment FORCE ROW LEVEL SECURITY; +ALTER TABLE Trial FORCE ROW LEVEL SECURITY; +ALTER TABLE Run FORCE ROW LEVEL SECURITY; +ALTER TABLE Measurement FORCE ROW LEVEL SECURITY; +ALTER TABLE Timeline FORCE ROW LEVEL SECURITY; +ALTER TABLE Source FORCE ROW LEVEL SECURITY; +ALTER TABLE ProfileData FORCE ROW LEVEL SECURITY; + +-- ============================================================ +-- 6. Helper function to read the session-local user ID. +-- Returns NULL when not set (allows bypass during migration). +-- SECURITY DEFINER so rdb_app can call current_setting. +-- ============================================================ +CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS INTEGER + LANGUAGE sql STABLE SECURITY DEFINER AS +$$ +SELECT NULLIF(current_setting('app.current_user_id', true), '')::INTEGER; +$$; + +-- ============================================================ +-- 7. RLS policies +-- +-- The `app_current_user_id() IS NULL` clause is a temporary +-- bypass so that existing routes (not yet protected by auth +-- middleware) continue to work during the migration period. +-- Remove this clause once all routes enforce authentication. +-- +-- Machine-to-machine endpoints (PUT /rebenchdb/results) run +-- as the pool superuser without SET ROLE, so they bypass +-- RLS entirely and are unaffected by these policies. +-- ============================================================ + +-- Project: direct membership check +CREATE POLICY project_access ON Project + FOR ALL USING ( -- all operations + app_current_user_id() IS NULL -- bypass + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Project.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Experiment: linked to Project via projectId +CREATE POLICY experiment_access ON Experiment + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Experiment.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Trial: Experiment.projectId +CREATE POLICY trial_access ON Trial + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Experiment e ON e.id = Trial.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Run: not directly project-scoped; visible if any accessible +-- Trial references it through Measurement. +CREATE POLICY run_access ON Run + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Measurement m + JOIN Trial t ON t.id = m.trialId + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE m.runId = Run.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Measurement: Trial -> Experiment -> Project +CREATE POLICY measurement_access ON Measurement + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Measurement.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Timeline: same join path as Measurement +CREATE POLICY timeline_access ON Timeline + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Timeline.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Source: shared across projects; visible if any accessible +-- Trial references it. +CREATE POLICY source_access ON Source + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Trial t + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE t.sourceId = Source.id + AND pm.userId = app_current_user_id() + ) + ); + +-- ProfileData: same join path as Measurement +CREATE POLICY profiledata_access ON ProfileData + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = ProfileData.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- ============================================================ +-- 8. Grants for rdb_app so RLS policies can be tested and enforced. +-- rdb_app needs SELECT (and write) on all tables so that +-- SET LOCAL ROLE rdb_app does not produce permission errors +-- before the RLS filter is applied. +-- ============================================================ +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rdb_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rdb_app; + -- Used by ReBenchDB's perf-tracker, for self-performance tracking CREATE PROCEDURE recordAdditionalMeasurement( aRunId smallint, diff --git a/src/backend/db/db.ts b/src/backend/db/db.ts index 1b5a15d7..06dd16b4 100644 --- a/src/backend/db/db.ts +++ b/src/backend/db/db.ts @@ -166,6 +166,18 @@ export abstract class Database { queryConfig: QueryConfig ): Promise>; + /** + * Run `fn` inside a transaction with RLS enforced for the given user. + * Temporarily sets ROLE to `rdb_app` (non-superuser) and + * `app.current_user_id` so PostgreSQL RLS policies fire. + * Pass `userId = null` to run without a user context (RLS bypass via NULL + * check in policies — use only for internal/background operations). + */ + public abstract withUserContext( + userId: number | null, + fn: () => Promise + ): Promise; + public clearCache(): void { this.runs.clear(); this.sources.clear(); diff --git a/src/backend/db/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql new file mode 100644 index 00000000..ca6f4254 --- /dev/null +++ b/src/backend/db/schema-updates/migration.014.sql @@ -0,0 +1,212 @@ +-- migration.014.sql +-- Adds user authentication and project-level Row Level Security. +-- +-- Post-migration manual steps required (run as superuser): +-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rdb_app; +-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rdb_app; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public +-- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rdb_app; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public +-- GRANT USAGE, SELECT ON SEQUENCES TO rdb_app; +-- -- Set a password for rdb_app if it will be used for direct connections: +-- -- ALTER ROLE rdb_app WITH PASSWORD '...'; + +BEGIN; + +-- ============================================================ +-- 1. ENUM type for membership roles +-- ============================================================ +CREATE TYPE project_role AS ENUM ('view', 'edit', 'owner'); + +-- ============================================================ +-- 2. Application user table (local auth, no SSO) +-- ============================================================ +CREATE TABLE appuser ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, -- bcrypt hash, always 60 chars + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_active BOOLEAN NOT NULL DEFAULT true +); + +-- ============================================================ +-- 3. Project membership (user <-> project with role) +-- ============================================================ +CREATE TABLE ProjectMembership ( + userId INTEGER NOT NULL REFERENCES appuser(id) ON DELETE CASCADE, + projectId INTEGER NOT NULL REFERENCES Project(id) ON DELETE CASCADE, + role project_role NOT NULL DEFAULT 'view', + PRIMARY KEY (userId, projectId) +); + +CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); + +-- ============================================================ +-- 4. Dedicated non-superuser DB role for the application. +-- The backend will SET LOCAL ROLE rdb_app inside each +-- user-facing transaction so that RLS policies fire even +-- when the pool connects as the DB owner / superuser. +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'rdb_app') THEN + CREATE ROLE rdb_app LOGIN; + END IF; +END$$; + +-- ============================================================ +-- 5. Enable Row Level Security on all relevant tables. +-- FORCE ensures the table owner / superuser is also filtered +-- when SET LOCAL ROLE rdb_app is in effect. +-- ============================================================ +ALTER TABLE Project ENABLE ROW LEVEL SECURITY; +ALTER TABLE Experiment ENABLE ROW LEVEL SECURITY; +ALTER TABLE Trial ENABLE ROW LEVEL SECURITY; +ALTER TABLE Run ENABLE ROW LEVEL SECURITY; +ALTER TABLE Measurement ENABLE ROW LEVEL SECURITY; +ALTER TABLE Timeline ENABLE ROW LEVEL SECURITY; +ALTER TABLE Source ENABLE ROW LEVEL SECURITY; +ALTER TABLE ProfileData ENABLE ROW LEVEL SECURITY; + +ALTER TABLE Project FORCE ROW LEVEL SECURITY; +ALTER TABLE Experiment FORCE ROW LEVEL SECURITY; +ALTER TABLE Trial FORCE ROW LEVEL SECURITY; +ALTER TABLE Run FORCE ROW LEVEL SECURITY; +ALTER TABLE Measurement FORCE ROW LEVEL SECURITY; +ALTER TABLE Timeline FORCE ROW LEVEL SECURITY; +ALTER TABLE Source FORCE ROW LEVEL SECURITY; +ALTER TABLE ProfileData FORCE ROW LEVEL SECURITY; + +-- ============================================================ +-- 6. Helper function to read the session-local user ID. +-- Returns NULL when not set (allows bypass during migration). +-- SECURITY DEFINER so rdb_app can call current_setting. +-- ============================================================ +CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS INTEGER + LANGUAGE sql STABLE SECURITY DEFINER AS +$$ + SELECT NULLIF(current_setting('app.current_user_id', true), '')::INTEGER; +$$; + +-- ============================================================ +-- 7. RLS policies +-- +-- The `app_current_user_id() IS NULL` clause is a temporary +-- bypass so that existing routes (not yet protected by auth +-- middleware) continue to work during the migration period. +-- Remove this clause once all routes enforce authentication. +-- +-- Machine-to-machine endpoints (PUT /rebenchdb/results) run +-- as the pool superuser without SET ROLE, so they bypass +-- RLS entirely and are unaffected by these policies. +-- ============================================================ + +-- Project: direct membership check +CREATE POLICY project_access ON Project + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Project.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Experiment: linked to Project via projectId +CREATE POLICY experiment_access ON Experiment + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Experiment.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Trial: Experiment.projectId +CREATE POLICY trial_access ON Trial + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Experiment e ON e.id = Trial.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Run: not directly project-scoped; visible if any accessible +-- Trial references it through Measurement. +CREATE POLICY run_access ON Run + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Measurement m + JOIN Trial t ON t.id = m.trialId + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE m.runId = Run.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Measurement: Trial -> Experiment -> Project +CREATE POLICY measurement_access ON Measurement + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Measurement.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Timeline: same join path as Measurement +CREATE POLICY timeline_access ON Timeline + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Timeline.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Source: shared across projects; visible if any accessible +-- Trial references it. +CREATE POLICY source_access ON Source + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Trial t + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE t.sourceId = Source.id + AND pm.userId = app_current_user_id() + ) + ); + +-- ProfileData: same join path as Measurement +CREATE POLICY profiledata_access ON ProfileData + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = ProfileData.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- ============================================================ +-- 8. Schema version bump +-- ============================================================ +INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); + +COMMIT; diff --git a/src/backend/main/main.ts b/src/backend/main/main.ts index 606fc696..e12d420a 100644 --- a/src/backend/main/main.ts +++ b/src/backend/main/main.ts @@ -25,7 +25,9 @@ export async function renderMainPage( ctx: ParameterizedContext, db: Database ): Promise { - const projects = await db.getAllProjects(); + const projects = await db.withUserContext(ctx.state.userId, () => + db.getAllProjects() + ); ctx.body = mainTpl({ rebenchVersion, projects, @@ -47,7 +49,9 @@ export async function getLast100MeasurementsAsJson( } const start = startRequest(); - ctx.body = await getLast100Measurements(projectId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getLast100Measurements(projectId, db) + ); completeRequestAndHandlePromise(start, db, 'get-results'); } @@ -125,7 +129,8 @@ export async function getSiteStatsAsJson( ctx: ParameterizedContext, db: Database ): Promise { - ctx.body = await getStatistics(db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getStatistics(db)); ctx.type = 'application/json'; } @@ -183,7 +188,9 @@ export async function getChangesAsJson( return; } - ctx.body = await getChanges(projectId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getChanges(projectId, db) + ); } export async function getChanges( diff --git a/src/backend/project/data-export.ts b/src/backend/project/data-export.ts index 522e13f7..928f74bf 100644 --- a/src/backend/project/data-export.ts +++ b/src/backend/project/data-export.ts @@ -107,7 +107,9 @@ export async function getAvailableDataAsJson( return; } - ctx.body = await getDataOverview(projectId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getDataOverview(projectId, db) + ); } export async function getDataOverview( diff --git a/src/backend/project/project.ts b/src/backend/project/project.ts index 1ff239bb..489ba3fb 100644 --- a/src/backend/project/project.ts +++ b/src/backend/project/project.ts @@ -20,7 +20,9 @@ export async function renderProjectPage( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectBySlug(ctx.params.projectSlug); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectBySlug(ctx.params.projectSlug) + ); if (project) { ctx.body = projectHtml({ ...project, rebenchVersion }); ctx.type = 'html'; @@ -33,9 +35,8 @@ export async function getSourceAsJson( ctx: ParameterizedContext, db: Database ): Promise { - const result = await db.getSourceById( - ctx.params.projectSlug, - ctx.params.sourceId + const result = await db.withUserContext(ctx.state.userId, () => + db.getSourceById(ctx.params.projectSlug, ctx.params.sourceId) ); if (result !== null) { @@ -57,7 +58,9 @@ export async function redirectToNewProjectDataUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProject(Number(ctx.params.projectId)); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProject(Number(ctx.params.projectId)) + ); if (project) { ctx.redirect(`/${project.slug}/data`); } else { @@ -75,7 +78,9 @@ export async function renderProjectDataPage( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectBySlug(ctx.params.projectSlug); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectBySlug(ctx.params.projectSlug) + ); if (project) { ctx.body = projectDataTpl({ project, rebenchVersion }); ctx.type = 'html'; @@ -91,7 +96,9 @@ export async function redirectToNewProjectDataExportUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectByExpId(Number(ctx.params.expId)); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectByExpId(Number(ctx.params.expId)) + ); if (project) { ctx.redirect(`/${project.slug}/data/${ctx.params.expId}`); } else { @@ -114,11 +121,8 @@ export async function renderDataExport( : 'csv'; const expId = ctx.params.expIdAndExtension.replace(`.${format}.gz`, ''); - const data = await getExpData( - ctx.params.projectSlug, - Number(expId), - db, - format + const data = await db.withUserContext(ctx.state.userId, () => + getExpData(ctx.params.projectSlug, Number(expId), db, format) ); if (data.preparingData) { diff --git a/src/backend/timeline/timeline.ts b/src/backend/timeline/timeline.ts index a2098cfe..c9556042 100644 --- a/src/backend/timeline/timeline.ts +++ b/src/backend/timeline/timeline.ts @@ -33,7 +33,9 @@ export async function getTimelineAsJson( return; } - ctx.body = await db.getTimelineForRun(projectId, runId); + ctx.body = await db.withUserContext(ctx.state.userId, () => + db.getTimelineForRun(projectId, runId) + ); if (ctx.body === null) { ctx.status = 500; } @@ -46,7 +48,9 @@ export async function redirectToNewTimelineUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProject(Number(ctx.params.projectId)); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProject(Number(ctx.params.projectId)) + ); if (project) { ctx.redirect(`/${project.slug}/timeline`); } else { @@ -58,14 +62,20 @@ export async function renderTimeline( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectBySlug(ctx.params.projectSlug); + const [project, benchmarks] = await db.withUserContext( + ctx.state.userId, + async () => { + const project = await db.getProjectBySlug(ctx.params.projectSlug); + if (!project) return [null, null] as const; + return [ + project, + await getLatestBenchmarksForTimelineView(project.id, db) + ] as const; + } + ); if (project) { - ctx.body = timelineTpl({ - rebenchVersion, - project, - benchmarks: await getLatestBenchmarksForTimelineView(project.id, db) - }); + ctx.body = timelineTpl({ rebenchVersion, project, benchmarks }); ctx.type = 'html'; } else { respondProjectNotFound(ctx, ctx.params.projectSlug); diff --git a/src/index.ts b/src/index.ts index 5322cccd..1b535bc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,8 @@ import { acceptResultData, reportResultApiVersion } from './backend/rebench/results.js'; +import { requireAuth } from './backend/auth/auth-middleware.js'; +import { login, register } from './backend/auth/auth-routes.js'; import { setTimeout } from 'node:timers/promises'; import { reportConnectionRefused } from './shared/errors.js'; @@ -68,7 +70,7 @@ export const db = new DatabaseWithPool( cacheInvalidationDelay ); -router.get('/', async (ctx) => { +router.get('/', requireAuth, async (ctx) => { return renderMainPage(ctx, db); }); @@ -90,13 +92,22 @@ Disallow: /rebenchdb* ctx.type = 'text'; }); -router.get('/:projectSlug', async (ctx) => renderProjectPage(ctx, db)); -router.get('/:projectSlug/source/:sourceId', async (ctx) => +router.post('/auth/register', koaBody(), async (ctx) => register(ctx, db)); +router.post('/auth/login', koaBody(), async (ctx) => login(ctx, db)); + +router.get('/:projectSlug', requireAuth, async (ctx) => + renderProjectPage(ctx, db) +); +router.get('/:projectSlug/source/:sourceId', requireAuth, async (ctx) => getSourceAsJson(ctx, db) ); -router.get('/:projectSlug/timeline', async (ctx) => renderTimeline(ctx, db)); -router.get('/:projectSlug/data', async (ctx) => renderProjectDataPage(ctx, db)); -router.get('/:projectSlug/data/:expIdAndExtension', async (ctx) => { +router.get('/:projectSlug/timeline', requireAuth, async (ctx) => + renderTimeline(ctx, db) +); +router.get('/:projectSlug/data', requireAuth, async (ctx) => + renderProjectDataPage(ctx, db) +); +router.get('/:projectSlug/data/:expIdAndExtension', requireAuth, async (ctx) => { if ( ctx.header['X-Purpose'] === 'preview' || ctx.header['Purpose'] === 'prefetch' || @@ -108,55 +119,63 @@ router.get('/:projectSlug/data/:expIdAndExtension', async (ctx) => { } return renderDataExport(ctx, db); }); -router.get('/:projectSlug/compare/:baseline..:change', async (ctx) => +router.get('/:projectSlug/compare/:baseline..:change', requireAuth, async (ctx) => renderComparePage(ctx, db) ); // DEPRECATED: remove for 1.0 -router.get('/timeline/:projectId', async (ctx) => +router.get('/timeline/:projectId', requireAuth, async (ctx) => redirectToNewTimelineUrl(ctx, db) ); -router.get('/project/:projectId', async (ctx) => +router.get('/project/:projectId', requireAuth, async (ctx) => redirectToNewProjectDataUrl(ctx, db) ); -router.get('/rebenchdb/get-exp-data/:expId', async (ctx) => +router.get('/rebenchdb/get-exp-data/:expId', requireAuth, async (ctx) => redirectToNewProjectDataExportUrl(ctx, db) ); -router.get('/compare/:project/:baseline/:change', async (ctx) => +router.get('/compare/:project/:baseline/:change', requireAuth, async (ctx) => redirectToNewCompareUrl(ctx, db) ); // todo: rename this to say that this endpoint gets the last 100 measurements // for the project -router.get('/rebenchdb/dash/:projectId/results', async (ctx) => +router.get('/rebenchdb/dash/:projectId/results', requireAuth, async (ctx) => getLast100MeasurementsAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/timeline/:runId', async (ctx) => +router.get('/rebenchdb/dash/:projectId/timeline/:runId', requireAuth, async (ctx) => getTimelineAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/profiles/:runId/:commitId', + requireAuth, async (ctx) => getProfileAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/measurements/:runId/:baseId/:changeId', + requireAuth, async (ctx) => getMeasurementsAsJson(ctx, db) ); -router.get('/rebenchdb/stats', async (ctx) => getSiteStatsAsJson(ctx, db)); -router.get('/rebenchdb/dash/:projectId/changes', async (ctx) => +router.get('/rebenchdb/stats', requireAuth, async (ctx) => + getSiteStatsAsJson(ctx, db) +); +router.get('/rebenchdb/dash/:projectId/changes', requireAuth, async (ctx) => getChangesAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/data-overview', async (ctx) => +router.get('/rebenchdb/dash/:projectId/data-overview', requireAuth, async (ctx) => getAvailableDataAsJson(ctx, db) ); -router.post('/rebenchdb/dash/:projectName/timelines', koaBody(), async (ctx) => - getTimelineDataAsJson(ctx, db) +router.post( + '/rebenchdb/dash/:projectName/timelines', + requireAuth, + koaBody(), + async (ctx) => getTimelineDataAsJson(ctx, db) ); -router.get('/admin/perform-timeline-update', async (ctx) => +router.get('/admin/perform-timeline-update', requireAuth, async (ctx) => submitTimelineUpdateJobs(ctx, db) ); router.post( '/admin/refresh/:project/:baseline/:change', + requireAuth, koaBody({ urlencoded: true }), deleteCachedReport ); diff --git a/tests/backend/db/auth-db.test.ts b/tests/backend/db/auth-db.test.ts new file mode 100644 index 00000000..a6bacc40 --- /dev/null +++ b/tests/backend/db/auth-db.test.ts @@ -0,0 +1,212 @@ +import { + describe, + expect, + beforeAll, + afterAll, + afterEach, + it +} from '@jest/globals'; + +import { + TestDatabase, + createAndInitializeDB, + closeMainDb +} from './db-testing.js'; + +import { + createUser, + getUserByUsername, + getUserByEmail +} from '../../../src/backend/auth/auth-db.js'; + +describe('appuser table operations', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('auth_db'); + }); + + afterAll(async () => { + return db.close(); + }); + + afterEach(async () => { + return db.rollback(); + }); + + it('should create a user and return all fields', async () => { + const user = await createUser(db, 'alice', 'alice@example.com', 'hash_abc'); + + expect(user.username).toEqual('alice'); + expect(user.email).toEqual('alice@example.com'); + expect(user.password_hash).toEqual('hash_abc'); + expect(user.id).toBeGreaterThan(0); + expect(user.is_active).toEqual(true); + expect(user.created_at).toBeInstanceOf(Date); + }); + + it('should return null when looking up a non-existent username', async () => { + const user = await getUserByUsername(db, 'nobody'); + expect(user).toBeNull(); + }); + + it('should return null when looking up a non-existent email', async () => { + const user = await getUserByEmail(db, 'nobody@example.com'); + expect(user).toBeNull(); + }); + + it('should retrieve a user by username after creation', async () => { + await createUser(db, 'bob', 'bob@example.com', 'hash_bob'); + + const user = await getUserByUsername(db, 'bob'); + + expect(user).not.toBeNull(); + expect(user!.username).toEqual('bob'); + expect(user!.email).toEqual('bob@example.com'); + expect(user!.password_hash).toEqual('hash_bob'); + }); + + it('should retrieve a user by email after creation', async () => { + await createUser(db, 'carol', 'carol@example.com', 'hash_carol'); + + const user = await getUserByEmail(db, 'carol@example.com'); + + expect(user).not.toBeNull(); + expect(user!.username).toEqual('carol'); + expect(user!.email).toEqual('carol@example.com'); + }); + + it('should reject a duplicate username', async () => { + await createUser(db, 'dave', 'dave@example.com', 'hash_dave'); + + await expect( + createUser(db, 'dave', 'other@example.com', 'hash_other') + ).rejects.toThrow(); + }); + + it('should reject a duplicate email', async () => { + await createUser(db, 'eve', 'shared@example.com', 'hash_eve'); + + await expect( + createUser(db, 'other', 'shared@example.com', 'hash_other') + ).rejects.toThrow(); + }); +}); + +describe('ProjectMembership table operations', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('auth_membership'); + }); + + afterAll(async () => { + return db.close(); + }); + + afterEach(async () => { + return db.rollback(); + }); + + async function createTestProject(db: TestDatabase, name: string): Promise { + const result = await db.query<{ id: number }>({ + text: `INSERT INTO Project (name, slug) VALUES ($1, $2) RETURNING id`, + values: [name, name.toLowerCase().replace(/\s+/g, '-')] + }); + return result.rows[0].id; + } + + it('should create a project membership with view role', async () => { + const user = await createUser(db, 'frank', 'frank@example.com', 'hash_frank'); + const projectId = await createTestProject(db, 'Test Project'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [user.id, projectId] + }); + + const result = await db.query({ + text: `SELECT * FROM ProjectMembership WHERE userId = $1`, + values: [user.id] + }); + + expect(result.rowCount).toEqual(1); + expect(result.rows[0].role).toEqual('view'); + expect(result.rows[0].userid).toEqual(user.id); + expect(result.rows[0].projectid).toEqual(projectId); + }); + + it('should enforce the (userId, projectId) primary key constraint', async () => { + const user = await createUser(db, 'grace', 'grace@example.com', 'hash_grace'); + const projectId = await createTestProject(db, 'Another Project'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [user.id, projectId] + }); + + await expect( + db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'edit')`, + values: [user.id, projectId] + }) + ).rejects.toThrow(); + }); + + it('should allow a user to have memberships in multiple projects', async () => { + const user = await createUser(db, 'henry', 'henry@example.com', 'hash_henry'); + const projectId1 = await createTestProject(db, 'Project Alpha'); + const projectId2 = await createTestProject(db, 'Project Beta'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'owner')`, + values: [user.id, projectId1] + }); + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [user.id, projectId2] + }); + + const result = await db.query({ + text: `SELECT * FROM ProjectMembership WHERE userId = $1 + ORDER BY projectId`, + values: [user.id] + }); + + expect(result.rowCount).toEqual(2); + expect(result.rows[0].role).toEqual('owner'); + expect(result.rows[1].role).toEqual('view'); + }); + + it('should cascade delete memberships when the user is deleted', async () => { + const user = await createUser(db, 'ivan', 'ivan@example.com', 'hash_ivan'); + const projectId = await createTestProject(db, 'Ivan Project'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'edit')`, + values: [user.id, projectId] + }); + + await db.query({ + text: `DELETE FROM appuser WHERE id = $1`, + values: [user.id] + }); + + const result = await db.query({ + text: `SELECT * FROM ProjectMembership WHERE userId = $1`, + values: [user.id] + }); + + expect(result.rowCount).toEqual(0); + }); +}); + +afterAll(async () => { + return closeMainDb(); +}); diff --git a/tests/backend/db/db-testing.ts b/tests/backend/db/db-testing.ts index c35ee578..3ea8e3a0 100644 --- a/tests/backend/db/db-testing.ts +++ b/tests/backend/db/db-testing.ts @@ -85,6 +85,19 @@ export class TestDatabase extends Database { } } + public async withUserContext( + userId: number | null, + fn: () => Promise + ): Promise { + await this.query({ text: 'SET LOCAL ROLE rdb_app' }); + if (userId !== null) { + await this.query({ + text: `SET LOCAL app.current_user_id = '${userId}'` + }); + } + return fn(); + } + public async rollback(): Promise { this.clearCache(); diff --git a/tests/backend/db/db.test.ts b/tests/backend/db/db.test.ts index 096fc626..e487c063 100644 --- a/tests/backend/db/db.test.ts +++ b/tests/backend/db/db.test.ts @@ -278,6 +278,13 @@ describe('createValueBatchForInsertion()', () => { ): Promise> { return null; } + + public async withUserContext( + _userId: number | null, + fn: () => Promise + ): Promise { + return fn(); + } } const run1 = { id: 1 } as Run; diff --git a/tests/backend/db/rls.test.ts b/tests/backend/db/rls.test.ts new file mode 100644 index 00000000..d9f15962 --- /dev/null +++ b/tests/backend/db/rls.test.ts @@ -0,0 +1,533 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it +} from '@jest/globals'; + +import { + closeMainDb, + createAndInitializeDB, + TestDatabase +} from './db-testing.js'; + +import { createUser } from '../../../src/backend/auth/auth-db.js'; + +// ─── fixture helpers ──────────────────────────────────────────────────────── + +async function createProject(db: TestDatabase, name: string): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Project (name, slug) VALUES ($1, $2) RETURNING id`, + values: [name, name.toLowerCase().replace(/\s+/g, '-')] + }); + return r.rows[0].id; +} + +async function addMembership( + db: TestDatabase, + userId: number, + projectId: number +): Promise { + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [userId, projectId] + }); +} + +async function createExperiment( + db: TestDatabase, + projectId: number, + name: string +): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Experiment (name, projectId) VALUES ($1, $2) RETURNING id`, + values: [name, projectId] + }); + return r.rows[0].id; +} + +async function createEnvironment(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Environment (hostname) VALUES ('test-host') RETURNING id` + }); + return r.rows[0].id; +} + +async function createSource(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Source (commitId) VALUES ('abc123') RETURNING id` + }); + return r.rows[0].id; +} + +async function createTrial( + db: TestDatabase, + expId: number, + envId: number, + sourceId: number +): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Trial ( + manualRun, startTime, expId, username, envId, sourceId) + VALUES (false, now(), $1, 'tester', $2, $3) RETURNING id`, + values: [expId, envId, sourceId] + }); + return r.rows[0].id; +} + +async function createRun(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Run + (benchmark, suite, executor, cmdline, maxInvocationTime, minIterationTime) + VALUES ('bench', 'suite', 'exec', 'cmd', 1000, 10) RETURNING id` + }); + return r.rows[0].id; +} + +async function createCriterion(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Criterion (name, unit) VALUES ('total', 'ms') + ON CONFLICT (name, unit) DO UPDATE SET name = EXCLUDED.name + RETURNING id` + }); + return r.rows[0].id; +} + +async function insertMeasurement( + db: TestDatabase, + runId: number, + trialId: number, + criterionId: number, + invocation = 1 +): Promise { + await db.query({ + text: `INSERT INTO Measurement (runId, trialId, criterion, invocation, values) + VALUES ($1, $2, $3, $4, '{1.0}')`, + values: [runId, trialId, criterionId, invocation] + }); +} + +async function insertTimeline( + db: TestDatabase, + runId: number, + trialId: number, + criterionId: number +): Promise { + await db.query({ + text: `INSERT INTO Timeline + (runId, trialId, criterion, numSamples, + minVal, maxVal, sdVal, mean, median, bci95low, bci95up) + VALUES ($1, $2, $3, 1, 1.0, 2.0, 0.1, 1.5, 1.5, 1.2, 1.8)`, + values: [runId, trialId, criterionId] + }); +} + +async function insertProfileData( + db: TestDatabase, + runId: number, + trialId: number +): Promise { + await db.query({ + text: `INSERT INTO ProfileData (runId, trialId, invocation, numIterations, value) + VALUES ($1, $2, 1, 10, 'profile')`, + values: [runId, trialId] + }); +} + +// ─── Project ──────────────────────────────────────────────────────────────── + +describe('RLS policy: Project table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_project'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide a project from a user with no membership', async () => { + const user = await createUser(db, 'alice', 'alice@test.com', 'hash'); + const projectId = await createProject(db, 'Secret Project'); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Project' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(projectId); + }); + + it('should show a project to a user who is a member', async () => { + const user = await createUser(db, 'alice', 'alice@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Project' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(projectId); + }); + + it('should bypass restrictions when no user context is set', async () => { + const projectId = await createProject(db, 'Any Project'); + + const result = await db.query({ text: 'SELECT id FROM Project' }); + + expect(result.rows.map((r) => r.id)).toContain(projectId); + }); +}); + +// ─── Experiment ───────────────────────────────────────────────────────────── + +describe('RLS policy: Experiment table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_experiment'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide an experiment from a non-member', async () => { + const user = await createUser(db, 'bob', 'bob@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Bench Exp'); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Experiment' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(expId); + }); + + it('should show an experiment to a project member', async () => { + const user = await createUser(db, 'bob', 'bob@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Bench Exp'); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Experiment' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(expId); + }); +}); + +// ─── Trial ────────────────────────────────────────────────────────────────── + +describe('RLS policy: Trial table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_trial'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide a trial from a non-member', async () => { + const user = await createUser(db, 'carol', 'carol@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Trial' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(trialId); + }); + + it('should show a trial to a project member', async () => { + const user = await createUser(db, 'carol', 'carol@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Trial' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(trialId); + }); +}); + +// ─── Source ───────────────────────────────────────────────────────────────── + +describe('RLS policy: Source table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_source'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide a source when no accessible trial references it', async () => { + const user = await createUser(db, 'dave', 'dave@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + await createTrial(db, expId, envId, sourceId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Source' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(sourceId); + }); + + it('should show a source to a user who can access a referencing trial', async () => { + const user = await createUser(db, 'dave', 'dave@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + await createTrial(db, expId, envId, sourceId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Source' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(sourceId); + }); +}); + +// ─── Measurement and Run ──────────────────────────────────────────────────── + +describe('RLS policy: Measurement and Run tables', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_measurement_run'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + async function buildChain(projectId: number) { + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + const criterionId = await createCriterion(db); + await insertMeasurement(db, runId, trialId, criterionId); + return { trialId, runId }; + } + + it('should hide measurements from a non-member', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const { trialId } = await buildChain(projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Measurement' }) + ); + + expect(result.rows.map((r) => r.trialid)).not.toContain(trialId); + }); + + it('should show measurements to a project member', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const { trialId } = await buildChain(projectId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Measurement' }) + ); + + expect(result.rows.map((r) => r.trialid)).toContain(trialId); + }); + + it('should hide a run from a non-member with no accessible measurements', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const { runId } = await buildChain(projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Run' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(runId); + }); + + it('should show a run to a member whose measurements reference it', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const { runId } = await buildChain(projectId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Run' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(runId); + }); +}); + +// ─── Timeline ─────────────────────────────────────────────────────────────── + +describe('RLS policy: Timeline table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_timeline'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide timeline rows from a non-member', async () => { + const user = await createUser(db, 'frank', 'frank@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + const criterionId = await createCriterion(db); + await insertTimeline(db, runId, trialId, criterionId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Timeline' }) + ); + + expect(result.rows.map((r) => r.trialid)).not.toContain(trialId); + }); + + it('should show timeline rows to a project member', async () => { + const user = await createUser(db, 'frank', 'frank@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + const criterionId = await createCriterion(db); + await insertTimeline(db, runId, trialId, criterionId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Timeline' }) + ); + + expect(result.rows.map((r) => r.trialid)).toContain(trialId); + }); +}); + +// ─── ProfileData ─────────────────────────────────────────────────────────── + +describe('RLS policy: ProfileData table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_profiledata'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide profile data from a non-member', async () => { + const user = await createUser(db, 'grace', 'grace@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + await insertProfileData(db, runId, trialId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM ProfileData' }) + ); + + expect(result.rows.map((r) => r.trialid)).not.toContain(trialId); + }); + + it('should show profile data to a project member', async () => { + const user = await createUser(db, 'grace', 'grace@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + await insertProfileData(db, runId, trialId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM ProfileData' }) + ); + + expect(result.rows.map((r) => r.trialid)).toContain(trialId); + }); +}); + +// ─── Cross-project isolation ──────────────────────────────────────────────── + +describe('RLS policy: cross-project isolation', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_cross_project'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should only expose data from projects the user is a member of', async () => { + const user = await createUser(db, 'henry', 'henry@test.com', 'hash'); + + const ownedProjectId = await createProject(db, 'Owned Project'); + const otherProjectId = await createProject(db, 'Other Project'); + + const ownedExpId = await createExperiment(db, ownedProjectId, 'Owned Exp'); + const otherExpId = await createExperiment(db, otherProjectId, 'Other Exp'); + + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const ownedTrialId = await createTrial(db, ownedExpId, envId, sourceId); + const otherTrialId = await createTrial(db, otherExpId, envId, sourceId); + + await addMembership(db, user.id, ownedProjectId); + + const projectIds = await db.withUserContext(user.id, async () => { + const r = await db.query({ text: 'SELECT id FROM Project' }); + return r.rows.map((row) => row.id); + }); + expect(projectIds).toContain(ownedProjectId); + expect(projectIds).not.toContain(otherProjectId); + + const expIds = await db.withUserContext(user.id, async () => { + const r = await db.query({ text: 'SELECT id FROM Experiment' }); + return r.rows.map((row) => row.id); + }); + expect(expIds).toContain(ownedExpId); + expect(expIds).not.toContain(otherExpId); + + const trialIds = await db.withUserContext(user.id, async () => { + const r = await db.query({ text: 'SELECT id FROM Trial' }); + return r.rows.map((row) => row.id); + }); + expect(trialIds).toContain(ownedTrialId); + expect(trialIds).not.toContain(otherTrialId); + }); +}); + +afterAll(async () => closeMainDb()); From 425d203efcce5e8156a67019c230b1a4f1fe7527 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:13:16 +0200 Subject: [PATCH 06/25] Added Appuser table Added ProjectMembership table Added RLS policies Added new rdb_app role --- src/backend/db/db.sql | 200 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index 52a7b3ad..be7ba024 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -200,6 +200,206 @@ CREATE TABLE Timeline ( foreign key (criterion) references Criterion (id) ); +-- ============================================================ +-- 1. ENUM type for membership roles +-- ============================================================ +CREATE TYPE project_role AS ENUM ('view', 'edit', 'owner'); + +-- ============================================================ +-- 2. Application user table +-- ============================================================ +CREATE TABLE appuser ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, -- bcrypt hash, always 60 chars + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_active BOOLEAN NOT NULL DEFAULT true +); + +-- ============================================================ +-- 3. Project membership (user <-> project with role) +-- ============================================================ +CREATE TABLE ProjectMembership ( -- TODO: Test what gets deleted when deleting a user + userId INTEGER NOT NULL REFERENCES appuser(id) ON DELETE CASCADE, + projectId INTEGER NOT NULL REFERENCES Project(id) ON DELETE CASCADE, + role project_role NOT NULL DEFAULT 'view', + PRIMARY KEY (userId, projectId) +); + +CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); + +-- ============================================================ +-- 4. Dedicated non-superuser DB role for the application. +-- The backend will SET LOCAL ROLE rdb_app inside each +-- user-facing transaction so that RLS policies fire even +-- when the pool connects as the DB owner / superuser. +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'rdb_app') THEN +CREATE ROLE rdb_app LOGIN; +END IF; +END$$; + +-- ============================================================ +-- 5. Enable Row Level Security on all relevant tables. +-- FORCE ensures the table owner / superuser is also filtered +-- when SET LOCAL ROLE rdb_app is in effect. +-- ============================================================ +ALTER TABLE Project ENABLE ROW LEVEL SECURITY; +ALTER TABLE Experiment ENABLE ROW LEVEL SECURITY; +ALTER TABLE Trial ENABLE ROW LEVEL SECURITY; +ALTER TABLE Run ENABLE ROW LEVEL SECURITY; +ALTER TABLE Measurement ENABLE ROW LEVEL SECURITY; +ALTER TABLE Timeline ENABLE ROW LEVEL SECURITY; +ALTER TABLE Source ENABLE ROW LEVEL SECURITY; +ALTER TABLE ProfileData ENABLE ROW LEVEL SECURITY; + +ALTER TABLE Project FORCE ROW LEVEL SECURITY; +ALTER TABLE Experiment FORCE ROW LEVEL SECURITY; +ALTER TABLE Trial FORCE ROW LEVEL SECURITY; +ALTER TABLE Run FORCE ROW LEVEL SECURITY; +ALTER TABLE Measurement FORCE ROW LEVEL SECURITY; +ALTER TABLE Timeline FORCE ROW LEVEL SECURITY; +ALTER TABLE Source FORCE ROW LEVEL SECURITY; +ALTER TABLE ProfileData FORCE ROW LEVEL SECURITY; + +-- ============================================================ +-- 6. Helper function to read the session-local user ID. +-- Returns NULL when not set (allows bypass during migration). +-- SECURITY DEFINER so rdb_app can call current_setting. +-- ============================================================ +CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS INTEGER + LANGUAGE sql STABLE SECURITY DEFINER AS +$$ +SELECT NULLIF(current_setting('app.current_user_id', true), '')::INTEGER; +$$; + +-- ============================================================ +-- 7. RLS policies +-- +-- The `app_current_user_id() IS NULL` clause is a temporary +-- bypass so that existing routes (not yet protected by auth +-- middleware) continue to work during the migration period. +-- Remove this clause once all routes enforce authentication. +-- +-- Machine-to-machine endpoints (PUT /rebenchdb/results) run +-- as the pool superuser without SET ROLE, so they bypass +-- RLS entirely and are unaffected by these policies. +-- ============================================================ + +-- Project: direct membership check +CREATE POLICY project_access ON Project + FOR ALL USING ( -- all operations + app_current_user_id() IS NULL -- bypass + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Project.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Experiment: linked to Project via projectId +CREATE POLICY experiment_access ON Experiment + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Experiment.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Trial: Experiment.projectId +CREATE POLICY trial_access ON Trial + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Experiment e ON e.id = Trial.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Run: not directly project-scoped; visible if any accessible +-- Trial references it through Measurement. +CREATE POLICY run_access ON Run + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Measurement m + JOIN Trial t ON t.id = m.trialId + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE m.runId = Run.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Measurement: Trial -> Experiment -> Project +CREATE POLICY measurement_access ON Measurement + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Measurement.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Timeline: same join path as Measurement +CREATE POLICY timeline_access ON Timeline + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Timeline.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Source: shared across projects; visible if any accessible +-- Trial references it. +CREATE POLICY source_access ON Source + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Trial t + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE t.sourceId = Source.id + AND pm.userId = app_current_user_id() + ) + ); + +-- ProfileData: same join path as Measurement +CREATE POLICY profiledata_access ON ProfileData + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = ProfileData.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- ============================================================ +-- 8. Grants for rdb_app so RLS policies can be tested and enforced. +-- rdb_app needs SELECT (and write) on all tables so that +-- SET LOCAL ROLE rdb_app does not produce permission errors +-- before the RLS filter is applied. +-- ============================================================ +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rdb_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rdb_app; + -- Used by ReBenchDB's perf-tracker, for self-performance tracking CREATE PROCEDURE recordAdditionalMeasurement( aRunId smallint, From 2c0e1f03ee2754cdb14358af48252fa254ffd76c Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:13:36 +0200 Subject: [PATCH 07/25] Added Appuser table Added ProjectMembership table Added RLS policies Added new rdb_app role --- .../db/schema-updates/migration.014.sql | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 src/backend/db/schema-updates/migration.014.sql diff --git a/src/backend/db/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql new file mode 100644 index 00000000..ca6f4254 --- /dev/null +++ b/src/backend/db/schema-updates/migration.014.sql @@ -0,0 +1,212 @@ +-- migration.014.sql +-- Adds user authentication and project-level Row Level Security. +-- +-- Post-migration manual steps required (run as superuser): +-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rdb_app; +-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rdb_app; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public +-- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rdb_app; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public +-- GRANT USAGE, SELECT ON SEQUENCES TO rdb_app; +-- -- Set a password for rdb_app if it will be used for direct connections: +-- -- ALTER ROLE rdb_app WITH PASSWORD '...'; + +BEGIN; + +-- ============================================================ +-- 1. ENUM type for membership roles +-- ============================================================ +CREATE TYPE project_role AS ENUM ('view', 'edit', 'owner'); + +-- ============================================================ +-- 2. Application user table (local auth, no SSO) +-- ============================================================ +CREATE TABLE appuser ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, -- bcrypt hash, always 60 chars + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_active BOOLEAN NOT NULL DEFAULT true +); + +-- ============================================================ +-- 3. Project membership (user <-> project with role) +-- ============================================================ +CREATE TABLE ProjectMembership ( + userId INTEGER NOT NULL REFERENCES appuser(id) ON DELETE CASCADE, + projectId INTEGER NOT NULL REFERENCES Project(id) ON DELETE CASCADE, + role project_role NOT NULL DEFAULT 'view', + PRIMARY KEY (userId, projectId) +); + +CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); + +-- ============================================================ +-- 4. Dedicated non-superuser DB role for the application. +-- The backend will SET LOCAL ROLE rdb_app inside each +-- user-facing transaction so that RLS policies fire even +-- when the pool connects as the DB owner / superuser. +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'rdb_app') THEN + CREATE ROLE rdb_app LOGIN; + END IF; +END$$; + +-- ============================================================ +-- 5. Enable Row Level Security on all relevant tables. +-- FORCE ensures the table owner / superuser is also filtered +-- when SET LOCAL ROLE rdb_app is in effect. +-- ============================================================ +ALTER TABLE Project ENABLE ROW LEVEL SECURITY; +ALTER TABLE Experiment ENABLE ROW LEVEL SECURITY; +ALTER TABLE Trial ENABLE ROW LEVEL SECURITY; +ALTER TABLE Run ENABLE ROW LEVEL SECURITY; +ALTER TABLE Measurement ENABLE ROW LEVEL SECURITY; +ALTER TABLE Timeline ENABLE ROW LEVEL SECURITY; +ALTER TABLE Source ENABLE ROW LEVEL SECURITY; +ALTER TABLE ProfileData ENABLE ROW LEVEL SECURITY; + +ALTER TABLE Project FORCE ROW LEVEL SECURITY; +ALTER TABLE Experiment FORCE ROW LEVEL SECURITY; +ALTER TABLE Trial FORCE ROW LEVEL SECURITY; +ALTER TABLE Run FORCE ROW LEVEL SECURITY; +ALTER TABLE Measurement FORCE ROW LEVEL SECURITY; +ALTER TABLE Timeline FORCE ROW LEVEL SECURITY; +ALTER TABLE Source FORCE ROW LEVEL SECURITY; +ALTER TABLE ProfileData FORCE ROW LEVEL SECURITY; + +-- ============================================================ +-- 6. Helper function to read the session-local user ID. +-- Returns NULL when not set (allows bypass during migration). +-- SECURITY DEFINER so rdb_app can call current_setting. +-- ============================================================ +CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS INTEGER + LANGUAGE sql STABLE SECURITY DEFINER AS +$$ + SELECT NULLIF(current_setting('app.current_user_id', true), '')::INTEGER; +$$; + +-- ============================================================ +-- 7. RLS policies +-- +-- The `app_current_user_id() IS NULL` clause is a temporary +-- bypass so that existing routes (not yet protected by auth +-- middleware) continue to work during the migration period. +-- Remove this clause once all routes enforce authentication. +-- +-- Machine-to-machine endpoints (PUT /rebenchdb/results) run +-- as the pool superuser without SET ROLE, so they bypass +-- RLS entirely and are unaffected by these policies. +-- ============================================================ + +-- Project: direct membership check +CREATE POLICY project_access ON Project + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Project.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Experiment: linked to Project via projectId +CREATE POLICY experiment_access ON Experiment + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Experiment.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Trial: Experiment.projectId +CREATE POLICY trial_access ON Trial + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Experiment e ON e.id = Trial.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Run: not directly project-scoped; visible if any accessible +-- Trial references it through Measurement. +CREATE POLICY run_access ON Run + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Measurement m + JOIN Trial t ON t.id = m.trialId + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE m.runId = Run.id + AND pm.userId = app_current_user_id() + ) + ); + +-- Measurement: Trial -> Experiment -> Project +CREATE POLICY measurement_access ON Measurement + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Measurement.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Timeline: same join path as Measurement +CREATE POLICY timeline_access ON Timeline + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Timeline.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- Source: shared across projects; visible if any accessible +-- Trial references it. +CREATE POLICY source_access ON Source + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM Trial t + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE t.sourceId = Source.id + AND pm.userId = app_current_user_id() + ) + ); + +-- ProfileData: same join path as Measurement +CREATE POLICY profiledata_access ON ProfileData + FOR ALL USING ( + app_current_user_id() IS NULL + OR EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = ProfileData.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +-- ============================================================ +-- 8. Schema version bump +-- ============================================================ +INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); + +COMMIT; From 71d0c9eedee00bb81bfe0f89e820be74d853e087 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:14:20 +0200 Subject: [PATCH 08/25] Added jwt, bcrypt, --- package-lock.json | 166 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 1abc26fa..64463837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,14 @@ "@octokit/auth-app": "8.1.2", "@octokit/rest": "22.0.1", "@sgratzl/chartjs-chart-boxplot": "4.4.5", + "bcrypt": "^6.0.0", "canvas": "3.2.1", "chart.js": "4.5.1", "chartjs-plugin-annotation": "3.1.0", "decimal.js": "10.6.0", "ejs": "3.1.10", "join-images": "1.1.5", + "jsonwebtoken": "^9.0.3", "koa": "3.1.1", "koa-body": "7.0.1", "pg": "8.16.3", @@ -30,8 +32,10 @@ }, "devDependencies": { "@octokit/types": "16.0.0", + "@types/bcrypt": "^6.0.0", "@types/ejs": "3.1.5", "@types/jquery": "3.5.33", + "@types/jsonwebtoken": "^9.0.10", "@types/koa": "3.0.1", "@types/koa__router": "12.0.5", "@types/pg": "8.16.0", @@ -2322,6 +2326,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2471,6 +2485,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -2512,6 +2537,13 @@ "@types/koa": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", @@ -3461,6 +3493,29 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -3596,6 +3651,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4270,6 +4331,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6450,6 +6520,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -6595,6 +6708,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6609,6 +6758,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6845,6 +7000,17 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index 99191bf0..f92203f8 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,20 @@ "license": "MIT", "type": "module", "dependencies": { + "@koa/router": "14.0.0", "@octokit/auth-app": "8.1.2", "@octokit/rest": "22.0.1", "@sgratzl/chartjs-chart-boxplot": "4.4.5", + "bcrypt": "^6.0.0", "canvas": "3.2.1", "chart.js": "4.5.1", "chartjs-plugin-annotation": "3.1.0", "decimal.js": "10.6.0", "ejs": "3.1.10", "join-images": "1.1.5", + "jsonwebtoken": "^9.0.3", "koa": "3.1.1", "koa-body": "7.0.1", - "@koa/router": "14.0.0", "pg": "8.16.3", "promisify-child-process": "4.1.2", "sharp": "0.34.5", @@ -39,8 +41,10 @@ }, "devDependencies": { "@octokit/types": "16.0.0", + "@types/bcrypt": "^6.0.0", "@types/ejs": "3.1.5", "@types/jquery": "3.5.33", + "@types/jsonwebtoken": "^9.0.10", "@types/koa": "3.0.1", "@types/koa__router": "12.0.5", "@types/pg": "8.16.0", From fb248b1febfe0bc7ea5d9eaf3c0998bda8deaeb8 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:15:22 +0200 Subject: [PATCH 09/25] Implemented method withUserContext --- tests/backend/db/db.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/backend/db/db.test.ts b/tests/backend/db/db.test.ts index 096fc626..e487c063 100644 --- a/tests/backend/db/db.test.ts +++ b/tests/backend/db/db.test.ts @@ -278,6 +278,13 @@ describe('createValueBatchForInsertion()', () => { ): Promise> { return null; } + + public async withUserContext( + _userId: number | null, + fn: () => Promise + ): Promise { + return fn(); + } } const run1 = { id: 1 } as Run; From c00dc3a666f112827cd1bcbd0b2c2a12a4f7f140 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:16:14 +0200 Subject: [PATCH 10/25] Created Test-Cases for User and Project Membership --- tests/backend/db/auth-db.test.ts | 212 +++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/backend/db/auth-db.test.ts diff --git a/tests/backend/db/auth-db.test.ts b/tests/backend/db/auth-db.test.ts new file mode 100644 index 00000000..a6bacc40 --- /dev/null +++ b/tests/backend/db/auth-db.test.ts @@ -0,0 +1,212 @@ +import { + describe, + expect, + beforeAll, + afterAll, + afterEach, + it +} from '@jest/globals'; + +import { + TestDatabase, + createAndInitializeDB, + closeMainDb +} from './db-testing.js'; + +import { + createUser, + getUserByUsername, + getUserByEmail +} from '../../../src/backend/auth/auth-db.js'; + +describe('appuser table operations', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('auth_db'); + }); + + afterAll(async () => { + return db.close(); + }); + + afterEach(async () => { + return db.rollback(); + }); + + it('should create a user and return all fields', async () => { + const user = await createUser(db, 'alice', 'alice@example.com', 'hash_abc'); + + expect(user.username).toEqual('alice'); + expect(user.email).toEqual('alice@example.com'); + expect(user.password_hash).toEqual('hash_abc'); + expect(user.id).toBeGreaterThan(0); + expect(user.is_active).toEqual(true); + expect(user.created_at).toBeInstanceOf(Date); + }); + + it('should return null when looking up a non-existent username', async () => { + const user = await getUserByUsername(db, 'nobody'); + expect(user).toBeNull(); + }); + + it('should return null when looking up a non-existent email', async () => { + const user = await getUserByEmail(db, 'nobody@example.com'); + expect(user).toBeNull(); + }); + + it('should retrieve a user by username after creation', async () => { + await createUser(db, 'bob', 'bob@example.com', 'hash_bob'); + + const user = await getUserByUsername(db, 'bob'); + + expect(user).not.toBeNull(); + expect(user!.username).toEqual('bob'); + expect(user!.email).toEqual('bob@example.com'); + expect(user!.password_hash).toEqual('hash_bob'); + }); + + it('should retrieve a user by email after creation', async () => { + await createUser(db, 'carol', 'carol@example.com', 'hash_carol'); + + const user = await getUserByEmail(db, 'carol@example.com'); + + expect(user).not.toBeNull(); + expect(user!.username).toEqual('carol'); + expect(user!.email).toEqual('carol@example.com'); + }); + + it('should reject a duplicate username', async () => { + await createUser(db, 'dave', 'dave@example.com', 'hash_dave'); + + await expect( + createUser(db, 'dave', 'other@example.com', 'hash_other') + ).rejects.toThrow(); + }); + + it('should reject a duplicate email', async () => { + await createUser(db, 'eve', 'shared@example.com', 'hash_eve'); + + await expect( + createUser(db, 'other', 'shared@example.com', 'hash_other') + ).rejects.toThrow(); + }); +}); + +describe('ProjectMembership table operations', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('auth_membership'); + }); + + afterAll(async () => { + return db.close(); + }); + + afterEach(async () => { + return db.rollback(); + }); + + async function createTestProject(db: TestDatabase, name: string): Promise { + const result = await db.query<{ id: number }>({ + text: `INSERT INTO Project (name, slug) VALUES ($1, $2) RETURNING id`, + values: [name, name.toLowerCase().replace(/\s+/g, '-')] + }); + return result.rows[0].id; + } + + it('should create a project membership with view role', async () => { + const user = await createUser(db, 'frank', 'frank@example.com', 'hash_frank'); + const projectId = await createTestProject(db, 'Test Project'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [user.id, projectId] + }); + + const result = await db.query({ + text: `SELECT * FROM ProjectMembership WHERE userId = $1`, + values: [user.id] + }); + + expect(result.rowCount).toEqual(1); + expect(result.rows[0].role).toEqual('view'); + expect(result.rows[0].userid).toEqual(user.id); + expect(result.rows[0].projectid).toEqual(projectId); + }); + + it('should enforce the (userId, projectId) primary key constraint', async () => { + const user = await createUser(db, 'grace', 'grace@example.com', 'hash_grace'); + const projectId = await createTestProject(db, 'Another Project'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [user.id, projectId] + }); + + await expect( + db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'edit')`, + values: [user.id, projectId] + }) + ).rejects.toThrow(); + }); + + it('should allow a user to have memberships in multiple projects', async () => { + const user = await createUser(db, 'henry', 'henry@example.com', 'hash_henry'); + const projectId1 = await createTestProject(db, 'Project Alpha'); + const projectId2 = await createTestProject(db, 'Project Beta'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'owner')`, + values: [user.id, projectId1] + }); + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [user.id, projectId2] + }); + + const result = await db.query({ + text: `SELECT * FROM ProjectMembership WHERE userId = $1 + ORDER BY projectId`, + values: [user.id] + }); + + expect(result.rowCount).toEqual(2); + expect(result.rows[0].role).toEqual('owner'); + expect(result.rows[1].role).toEqual('view'); + }); + + it('should cascade delete memberships when the user is deleted', async () => { + const user = await createUser(db, 'ivan', 'ivan@example.com', 'hash_ivan'); + const projectId = await createTestProject(db, 'Ivan Project'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'edit')`, + values: [user.id, projectId] + }); + + await db.query({ + text: `DELETE FROM appuser WHERE id = $1`, + values: [user.id] + }); + + const result = await db.query({ + text: `SELECT * FROM ProjectMembership WHERE userId = $1`, + values: [user.id] + }); + + expect(result.rowCount).toEqual(0); + }); +}); + +afterAll(async () => { + return closeMainDb(); +}); From b5f5fe7357be3d65909f41a10daedee18a1e7c2e Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:16:39 +0200 Subject: [PATCH 11/25] Created Test-Cases RLS-policies --- tests/backend/db/rls.test.ts | 533 +++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 tests/backend/db/rls.test.ts diff --git a/tests/backend/db/rls.test.ts b/tests/backend/db/rls.test.ts new file mode 100644 index 00000000..d9f15962 --- /dev/null +++ b/tests/backend/db/rls.test.ts @@ -0,0 +1,533 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it +} from '@jest/globals'; + +import { + closeMainDb, + createAndInitializeDB, + TestDatabase +} from './db-testing.js'; + +import { createUser } from '../../../src/backend/auth/auth-db.js'; + +// ─── fixture helpers ──────────────────────────────────────────────────────── + +async function createProject(db: TestDatabase, name: string): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Project (name, slug) VALUES ($1, $2) RETURNING id`, + values: [name, name.toLowerCase().replace(/\s+/g, '-')] + }); + return r.rows[0].id; +} + +async function addMembership( + db: TestDatabase, + userId: number, + projectId: number +): Promise { + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'view')`, + values: [userId, projectId] + }); +} + +async function createExperiment( + db: TestDatabase, + projectId: number, + name: string +): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Experiment (name, projectId) VALUES ($1, $2) RETURNING id`, + values: [name, projectId] + }); + return r.rows[0].id; +} + +async function createEnvironment(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Environment (hostname) VALUES ('test-host') RETURNING id` + }); + return r.rows[0].id; +} + +async function createSource(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Source (commitId) VALUES ('abc123') RETURNING id` + }); + return r.rows[0].id; +} + +async function createTrial( + db: TestDatabase, + expId: number, + envId: number, + sourceId: number +): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Trial ( + manualRun, startTime, expId, username, envId, sourceId) + VALUES (false, now(), $1, 'tester', $2, $3) RETURNING id`, + values: [expId, envId, sourceId] + }); + return r.rows[0].id; +} + +async function createRun(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Run + (benchmark, suite, executor, cmdline, maxInvocationTime, minIterationTime) + VALUES ('bench', 'suite', 'exec', 'cmd', 1000, 10) RETURNING id` + }); + return r.rows[0].id; +} + +async function createCriterion(db: TestDatabase): Promise { + const r = await db.query<{ id: number }>({ + text: `INSERT INTO Criterion (name, unit) VALUES ('total', 'ms') + ON CONFLICT (name, unit) DO UPDATE SET name = EXCLUDED.name + RETURNING id` + }); + return r.rows[0].id; +} + +async function insertMeasurement( + db: TestDatabase, + runId: number, + trialId: number, + criterionId: number, + invocation = 1 +): Promise { + await db.query({ + text: `INSERT INTO Measurement (runId, trialId, criterion, invocation, values) + VALUES ($1, $2, $3, $4, '{1.0}')`, + values: [runId, trialId, criterionId, invocation] + }); +} + +async function insertTimeline( + db: TestDatabase, + runId: number, + trialId: number, + criterionId: number +): Promise { + await db.query({ + text: `INSERT INTO Timeline + (runId, trialId, criterion, numSamples, + minVal, maxVal, sdVal, mean, median, bci95low, bci95up) + VALUES ($1, $2, $3, 1, 1.0, 2.0, 0.1, 1.5, 1.5, 1.2, 1.8)`, + values: [runId, trialId, criterionId] + }); +} + +async function insertProfileData( + db: TestDatabase, + runId: number, + trialId: number +): Promise { + await db.query({ + text: `INSERT INTO ProfileData (runId, trialId, invocation, numIterations, value) + VALUES ($1, $2, 1, 10, 'profile')`, + values: [runId, trialId] + }); +} + +// ─── Project ──────────────────────────────────────────────────────────────── + +describe('RLS policy: Project table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_project'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide a project from a user with no membership', async () => { + const user = await createUser(db, 'alice', 'alice@test.com', 'hash'); + const projectId = await createProject(db, 'Secret Project'); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Project' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(projectId); + }); + + it('should show a project to a user who is a member', async () => { + const user = await createUser(db, 'alice', 'alice@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Project' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(projectId); + }); + + it('should bypass restrictions when no user context is set', async () => { + const projectId = await createProject(db, 'Any Project'); + + const result = await db.query({ text: 'SELECT id FROM Project' }); + + expect(result.rows.map((r) => r.id)).toContain(projectId); + }); +}); + +// ─── Experiment ───────────────────────────────────────────────────────────── + +describe('RLS policy: Experiment table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_experiment'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide an experiment from a non-member', async () => { + const user = await createUser(db, 'bob', 'bob@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Bench Exp'); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Experiment' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(expId); + }); + + it('should show an experiment to a project member', async () => { + const user = await createUser(db, 'bob', 'bob@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Bench Exp'); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Experiment' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(expId); + }); +}); + +// ─── Trial ────────────────────────────────────────────────────────────────── + +describe('RLS policy: Trial table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_trial'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide a trial from a non-member', async () => { + const user = await createUser(db, 'carol', 'carol@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Trial' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(trialId); + }); + + it('should show a trial to a project member', async () => { + const user = await createUser(db, 'carol', 'carol@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Trial' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(trialId); + }); +}); + +// ─── Source ───────────────────────────────────────────────────────────────── + +describe('RLS policy: Source table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_source'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide a source when no accessible trial references it', async () => { + const user = await createUser(db, 'dave', 'dave@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + await createTrial(db, expId, envId, sourceId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Source' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(sourceId); + }); + + it('should show a source to a user who can access a referencing trial', async () => { + const user = await createUser(db, 'dave', 'dave@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + await createTrial(db, expId, envId, sourceId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Source' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(sourceId); + }); +}); + +// ─── Measurement and Run ──────────────────────────────────────────────────── + +describe('RLS policy: Measurement and Run tables', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_measurement_run'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + async function buildChain(projectId: number) { + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + const criterionId = await createCriterion(db); + await insertMeasurement(db, runId, trialId, criterionId); + return { trialId, runId }; + } + + it('should hide measurements from a non-member', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const { trialId } = await buildChain(projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Measurement' }) + ); + + expect(result.rows.map((r) => r.trialid)).not.toContain(trialId); + }); + + it('should show measurements to a project member', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const { trialId } = await buildChain(projectId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Measurement' }) + ); + + expect(result.rows.map((r) => r.trialid)).toContain(trialId); + }); + + it('should hide a run from a non-member with no accessible measurements', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const { runId } = await buildChain(projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Run' }) + ); + + expect(result.rows.map((r) => r.id)).not.toContain(runId); + }); + + it('should show a run to a member whose measurements reference it', async () => { + const user = await createUser(db, 'eve', 'eve@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const { runId } = await buildChain(projectId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT id FROM Run' }) + ); + + expect(result.rows.map((r) => r.id)).toContain(runId); + }); +}); + +// ─── Timeline ─────────────────────────────────────────────────────────────── + +describe('RLS policy: Timeline table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_timeline'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide timeline rows from a non-member', async () => { + const user = await createUser(db, 'frank', 'frank@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + const criterionId = await createCriterion(db); + await insertTimeline(db, runId, trialId, criterionId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Timeline' }) + ); + + expect(result.rows.map((r) => r.trialid)).not.toContain(trialId); + }); + + it('should show timeline rows to a project member', async () => { + const user = await createUser(db, 'frank', 'frank@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + const criterionId = await createCriterion(db); + await insertTimeline(db, runId, trialId, criterionId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM Timeline' }) + ); + + expect(result.rows.map((r) => r.trialid)).toContain(trialId); + }); +}); + +// ─── ProfileData ─────────────────────────────────────────────────────────── + +describe('RLS policy: ProfileData table', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_profiledata'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should hide profile data from a non-member', async () => { + const user = await createUser(db, 'grace', 'grace@test.com', 'hash'); + const projectId = await createProject(db, 'Private Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + await insertProfileData(db, runId, trialId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM ProfileData' }) + ); + + expect(result.rows.map((r) => r.trialid)).not.toContain(trialId); + }); + + it('should show profile data to a project member', async () => { + const user = await createUser(db, 'grace', 'grace@test.com', 'hash'); + const projectId = await createProject(db, 'My Project'); + const expId = await createExperiment(db, projectId, 'Exp'); + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const trialId = await createTrial(db, expId, envId, sourceId); + const runId = await createRun(db); + await insertProfileData(db, runId, trialId); + await addMembership(db, user.id, projectId); + + const result = await db.withUserContext(user.id, () => + db.query({ text: 'SELECT trialId FROM ProfileData' }) + ); + + expect(result.rows.map((r) => r.trialid)).toContain(trialId); + }); +}); + +// ─── Cross-project isolation ──────────────────────────────────────────────── + +describe('RLS policy: cross-project isolation', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('rls_cross_project'); + }); + + afterAll(async () => db.close()); + afterEach(async () => db.rollback()); + + it('should only expose data from projects the user is a member of', async () => { + const user = await createUser(db, 'henry', 'henry@test.com', 'hash'); + + const ownedProjectId = await createProject(db, 'Owned Project'); + const otherProjectId = await createProject(db, 'Other Project'); + + const ownedExpId = await createExperiment(db, ownedProjectId, 'Owned Exp'); + const otherExpId = await createExperiment(db, otherProjectId, 'Other Exp'); + + const envId = await createEnvironment(db); + const sourceId = await createSource(db); + const ownedTrialId = await createTrial(db, ownedExpId, envId, sourceId); + const otherTrialId = await createTrial(db, otherExpId, envId, sourceId); + + await addMembership(db, user.id, ownedProjectId); + + const projectIds = await db.withUserContext(user.id, async () => { + const r = await db.query({ text: 'SELECT id FROM Project' }); + return r.rows.map((row) => row.id); + }); + expect(projectIds).toContain(ownedProjectId); + expect(projectIds).not.toContain(otherProjectId); + + const expIds = await db.withUserContext(user.id, async () => { + const r = await db.query({ text: 'SELECT id FROM Experiment' }); + return r.rows.map((row) => row.id); + }); + expect(expIds).toContain(ownedExpId); + expect(expIds).not.toContain(otherExpId); + + const trialIds = await db.withUserContext(user.id, async () => { + const r = await db.query({ text: 'SELECT id FROM Trial' }); + return r.rows.map((row) => row.id); + }); + expect(trialIds).toContain(ownedTrialId); + expect(trialIds).not.toContain(otherTrialId); + }); +}); + +afterAll(async () => closeMainDb()); From 21a4d05be55c2e86df5dfb1bfc52e0f67c08bed2 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:18:03 +0200 Subject: [PATCH 12/25] Added db.withUserContext to appropriate methods --- src/backend/main/main.ts | 15 +++++++++++---- src/backend/project/project.ts | 28 ++++++++++++++++------------ src/backend/timeline/timeline.ts | 26 ++++++++++++++++++-------- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/backend/main/main.ts b/src/backend/main/main.ts index 606fc696..e12d420a 100644 --- a/src/backend/main/main.ts +++ b/src/backend/main/main.ts @@ -25,7 +25,9 @@ export async function renderMainPage( ctx: ParameterizedContext, db: Database ): Promise { - const projects = await db.getAllProjects(); + const projects = await db.withUserContext(ctx.state.userId, () => + db.getAllProjects() + ); ctx.body = mainTpl({ rebenchVersion, projects, @@ -47,7 +49,9 @@ export async function getLast100MeasurementsAsJson( } const start = startRequest(); - ctx.body = await getLast100Measurements(projectId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getLast100Measurements(projectId, db) + ); completeRequestAndHandlePromise(start, db, 'get-results'); } @@ -125,7 +129,8 @@ export async function getSiteStatsAsJson( ctx: ParameterizedContext, db: Database ): Promise { - ctx.body = await getStatistics(db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getStatistics(db)); ctx.type = 'application/json'; } @@ -183,7 +188,9 @@ export async function getChangesAsJson( return; } - ctx.body = await getChanges(projectId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getChanges(projectId, db) + ); } export async function getChanges( diff --git a/src/backend/project/project.ts b/src/backend/project/project.ts index 1ff239bb..489ba3fb 100644 --- a/src/backend/project/project.ts +++ b/src/backend/project/project.ts @@ -20,7 +20,9 @@ export async function renderProjectPage( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectBySlug(ctx.params.projectSlug); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectBySlug(ctx.params.projectSlug) + ); if (project) { ctx.body = projectHtml({ ...project, rebenchVersion }); ctx.type = 'html'; @@ -33,9 +35,8 @@ export async function getSourceAsJson( ctx: ParameterizedContext, db: Database ): Promise { - const result = await db.getSourceById( - ctx.params.projectSlug, - ctx.params.sourceId + const result = await db.withUserContext(ctx.state.userId, () => + db.getSourceById(ctx.params.projectSlug, ctx.params.sourceId) ); if (result !== null) { @@ -57,7 +58,9 @@ export async function redirectToNewProjectDataUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProject(Number(ctx.params.projectId)); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProject(Number(ctx.params.projectId)) + ); if (project) { ctx.redirect(`/${project.slug}/data`); } else { @@ -75,7 +78,9 @@ export async function renderProjectDataPage( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectBySlug(ctx.params.projectSlug); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectBySlug(ctx.params.projectSlug) + ); if (project) { ctx.body = projectDataTpl({ project, rebenchVersion }); ctx.type = 'html'; @@ -91,7 +96,9 @@ export async function redirectToNewProjectDataExportUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectByExpId(Number(ctx.params.expId)); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectByExpId(Number(ctx.params.expId)) + ); if (project) { ctx.redirect(`/${project.slug}/data/${ctx.params.expId}`); } else { @@ -114,11 +121,8 @@ export async function renderDataExport( : 'csv'; const expId = ctx.params.expIdAndExtension.replace(`.${format}.gz`, ''); - const data = await getExpData( - ctx.params.projectSlug, - Number(expId), - db, - format + const data = await db.withUserContext(ctx.state.userId, () => + getExpData(ctx.params.projectSlug, Number(expId), db, format) ); if (data.preparingData) { diff --git a/src/backend/timeline/timeline.ts b/src/backend/timeline/timeline.ts index a2098cfe..c9556042 100644 --- a/src/backend/timeline/timeline.ts +++ b/src/backend/timeline/timeline.ts @@ -33,7 +33,9 @@ export async function getTimelineAsJson( return; } - ctx.body = await db.getTimelineForRun(projectId, runId); + ctx.body = await db.withUserContext(ctx.state.userId, () => + db.getTimelineForRun(projectId, runId) + ); if (ctx.body === null) { ctx.status = 500; } @@ -46,7 +48,9 @@ export async function redirectToNewTimelineUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProject(Number(ctx.params.projectId)); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProject(Number(ctx.params.projectId)) + ); if (project) { ctx.redirect(`/${project.slug}/timeline`); } else { @@ -58,14 +62,20 @@ export async function renderTimeline( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectBySlug(ctx.params.projectSlug); + const [project, benchmarks] = await db.withUserContext( + ctx.state.userId, + async () => { + const project = await db.getProjectBySlug(ctx.params.projectSlug); + if (!project) return [null, null] as const; + return [ + project, + await getLatestBenchmarksForTimelineView(project.id, db) + ] as const; + } + ); if (project) { - ctx.body = timelineTpl({ - rebenchVersion, - project, - benchmarks: await getLatestBenchmarksForTimelineView(project.id, db) - }); + ctx.body = timelineTpl({ rebenchVersion, project, benchmarks }); ctx.type = 'html'; } else { respondProjectNotFound(ctx, ctx.params.projectSlug); From e88b71bf7a28fd0da8fe27825b33994d4fa45e34 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:18:50 +0200 Subject: [PATCH 13/25] Added requireAuth to appropriate routes --- src/index.ts | 57 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5322cccd..1b535bc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,8 @@ import { acceptResultData, reportResultApiVersion } from './backend/rebench/results.js'; +import { requireAuth } from './backend/auth/auth-middleware.js'; +import { login, register } from './backend/auth/auth-routes.js'; import { setTimeout } from 'node:timers/promises'; import { reportConnectionRefused } from './shared/errors.js'; @@ -68,7 +70,7 @@ export const db = new DatabaseWithPool( cacheInvalidationDelay ); -router.get('/', async (ctx) => { +router.get('/', requireAuth, async (ctx) => { return renderMainPage(ctx, db); }); @@ -90,13 +92,22 @@ Disallow: /rebenchdb* ctx.type = 'text'; }); -router.get('/:projectSlug', async (ctx) => renderProjectPage(ctx, db)); -router.get('/:projectSlug/source/:sourceId', async (ctx) => +router.post('/auth/register', koaBody(), async (ctx) => register(ctx, db)); +router.post('/auth/login', koaBody(), async (ctx) => login(ctx, db)); + +router.get('/:projectSlug', requireAuth, async (ctx) => + renderProjectPage(ctx, db) +); +router.get('/:projectSlug/source/:sourceId', requireAuth, async (ctx) => getSourceAsJson(ctx, db) ); -router.get('/:projectSlug/timeline', async (ctx) => renderTimeline(ctx, db)); -router.get('/:projectSlug/data', async (ctx) => renderProjectDataPage(ctx, db)); -router.get('/:projectSlug/data/:expIdAndExtension', async (ctx) => { +router.get('/:projectSlug/timeline', requireAuth, async (ctx) => + renderTimeline(ctx, db) +); +router.get('/:projectSlug/data', requireAuth, async (ctx) => + renderProjectDataPage(ctx, db) +); +router.get('/:projectSlug/data/:expIdAndExtension', requireAuth, async (ctx) => { if ( ctx.header['X-Purpose'] === 'preview' || ctx.header['Purpose'] === 'prefetch' || @@ -108,55 +119,63 @@ router.get('/:projectSlug/data/:expIdAndExtension', async (ctx) => { } return renderDataExport(ctx, db); }); -router.get('/:projectSlug/compare/:baseline..:change', async (ctx) => +router.get('/:projectSlug/compare/:baseline..:change', requireAuth, async (ctx) => renderComparePage(ctx, db) ); // DEPRECATED: remove for 1.0 -router.get('/timeline/:projectId', async (ctx) => +router.get('/timeline/:projectId', requireAuth, async (ctx) => redirectToNewTimelineUrl(ctx, db) ); -router.get('/project/:projectId', async (ctx) => +router.get('/project/:projectId', requireAuth, async (ctx) => redirectToNewProjectDataUrl(ctx, db) ); -router.get('/rebenchdb/get-exp-data/:expId', async (ctx) => +router.get('/rebenchdb/get-exp-data/:expId', requireAuth, async (ctx) => redirectToNewProjectDataExportUrl(ctx, db) ); -router.get('/compare/:project/:baseline/:change', async (ctx) => +router.get('/compare/:project/:baseline/:change', requireAuth, async (ctx) => redirectToNewCompareUrl(ctx, db) ); // todo: rename this to say that this endpoint gets the last 100 measurements // for the project -router.get('/rebenchdb/dash/:projectId/results', async (ctx) => +router.get('/rebenchdb/dash/:projectId/results', requireAuth, async (ctx) => getLast100MeasurementsAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/timeline/:runId', async (ctx) => +router.get('/rebenchdb/dash/:projectId/timeline/:runId', requireAuth, async (ctx) => getTimelineAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/profiles/:runId/:commitId', + requireAuth, async (ctx) => getProfileAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/measurements/:runId/:baseId/:changeId', + requireAuth, async (ctx) => getMeasurementsAsJson(ctx, db) ); -router.get('/rebenchdb/stats', async (ctx) => getSiteStatsAsJson(ctx, db)); -router.get('/rebenchdb/dash/:projectId/changes', async (ctx) => +router.get('/rebenchdb/stats', requireAuth, async (ctx) => + getSiteStatsAsJson(ctx, db) +); +router.get('/rebenchdb/dash/:projectId/changes', requireAuth, async (ctx) => getChangesAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/data-overview', async (ctx) => +router.get('/rebenchdb/dash/:projectId/data-overview', requireAuth, async (ctx) => getAvailableDataAsJson(ctx, db) ); -router.post('/rebenchdb/dash/:projectName/timelines', koaBody(), async (ctx) => - getTimelineDataAsJson(ctx, db) +router.post( + '/rebenchdb/dash/:projectName/timelines', + requireAuth, + koaBody(), + async (ctx) => getTimelineDataAsJson(ctx, db) ); -router.get('/admin/perform-timeline-update', async (ctx) => +router.get('/admin/perform-timeline-update', requireAuth, async (ctx) => submitTimelineUpdateJobs(ctx, db) ); router.post( '/admin/refresh/:project/:baseline/:change', + requireAuth, koaBody({ urlencoded: true }), deleteCachedReport ); From ca8b093a65f5e1749b80c53f472985c6f56181de Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:19:21 +0200 Subject: [PATCH 14/25] Implement withUserContext --- src/backend/db/db.ts | 12 ++++++++++++ tests/backend/db/db-testing.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/backend/db/db.ts b/src/backend/db/db.ts index 1b5a15d7..06dd16b4 100644 --- a/src/backend/db/db.ts +++ b/src/backend/db/db.ts @@ -166,6 +166,18 @@ export abstract class Database { queryConfig: QueryConfig ): Promise>; + /** + * Run `fn` inside a transaction with RLS enforced for the given user. + * Temporarily sets ROLE to `rdb_app` (non-superuser) and + * `app.current_user_id` so PostgreSQL RLS policies fire. + * Pass `userId = null` to run without a user context (RLS bypass via NULL + * check in policies — use only for internal/background operations). + */ + public abstract withUserContext( + userId: number | null, + fn: () => Promise + ): Promise; + public clearCache(): void { this.runs.clear(); this.sources.clear(); diff --git a/tests/backend/db/db-testing.ts b/tests/backend/db/db-testing.ts index c35ee578..3ea8e3a0 100644 --- a/tests/backend/db/db-testing.ts +++ b/tests/backend/db/db-testing.ts @@ -85,6 +85,19 @@ export class TestDatabase extends Database { } } + public async withUserContext( + userId: number | null, + fn: () => Promise + ): Promise { + await this.query({ text: 'SET LOCAL ROLE rdb_app' }); + if (userId !== null) { + await this.query({ + text: `SET LOCAL app.current_user_id = '${userId}'` + }); + } + return fn(); + } + public async rollback(): Promise { this.clearCache(); From a867ddd99c5aab25a97b1e46b4d7540b693b9e2e Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:20:42 +0200 Subject: [PATCH 15/25] Implement UserContextStorage to store userId/token Implementation of withUserContext (Setting the role in the db to rdb_app for RLS-policies to fire) --- src/backend/db/database-with-pool.ts | 33 +++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/backend/db/database-with-pool.ts b/src/backend/db/database-with-pool.ts index cdcd50fe..f3f1d0b9 100644 --- a/src/backend/db/database-with-pool.ts +++ b/src/backend/db/database-with-pool.ts @@ -1,8 +1,13 @@ -import pg, { PoolConfig, QueryConfig, QueryResult, QueryResultRow } from 'pg'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +// eslint-disable-next-line max-len +import pg, { PoolClient, PoolConfig, QueryConfig, QueryResult, QueryResultRow } from 'pg'; import { Database } from './db.js'; import { BatchingTimelineUpdater } from '../timeline/timeline-calc.js'; +const userContextStorage = new AsyncLocalStorage<{ client: PoolClient }>(); + export class DatabaseWithPool extends Database { private pool: pg.Pool; @@ -23,9 +28,35 @@ export class DatabaseWithPool extends Database { public async query( queryConfig: QueryConfig ): Promise> { + const context = userContextStorage.getStore(); + if (context) { + return context.client.query(queryConfig); + } return this.pool.query(queryConfig); } + public async withUserContext( + userId: number | null, + fn: () => Promise + ): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await client.query('SET LOCAL ROLE rdb_app'); + if (userId !== null) { + await client.query(`SET LOCAL app.current_user_id = '${userId}'`); + } + const result = await userContextStorage.run({ client }, fn); + await client.query('COMMIT'); + return result; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } + public async close(): Promise { await super.close(); this.statsValid.invalidateAndNew(); From acb22e104857ac7435e686c7ea68e4bf346a71ef Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:21:04 +0200 Subject: [PATCH 16/25] Added db.withUserContext --- src/backend/compare/compare.ts | 39 ++++++++++++++++++------------ src/backend/project/data-export.ts | 4 ++- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/backend/compare/compare.ts b/src/backend/compare/compare.ts index c042cbdb..877b4236 100644 --- a/src/backend/compare/compare.ts +++ b/src/backend/compare/compare.ts @@ -31,7 +31,9 @@ export async function getProfileAsJson( const start = startRequest(); - ctx.body = await getProfile(runId, ctx.params.commitId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getProfile(runId, ctx.params.commitId, db) + ); if (ctx.body === undefined) { ctx.status = 404; ctx.body = {}; @@ -85,12 +87,14 @@ export async function getMeasurementsAsJson( const start = startRequest(); - ctx.body = await getMeasurements( - ctx.params.projectSlug, - runId, - ctx.params.baseId, - ctx.params.changeId, - db + ctx.body = await db.withUserContext(ctx.state.userId, () => + getMeasurements( + ctx.params.projectSlug, + runId, + ctx.params.baseId, + ctx.params.changeId, + db + ) ); completeRequestAndHandlePromise(start, db, 'get-measurements'); @@ -174,9 +178,8 @@ export async function getTimelineDataAsJson( db: Database ): Promise { const timelineRequest = (ctx.request.body); - const result = await db.getTimelineData( - ctx.params.projectName, - timelineRequest + const result = await db.withUserContext(ctx.state.userId, () => + db.getTimelineData(ctx.params.projectName, timelineRequest) ); if (result === null) { ctx.body = { error: 'Requested data was not found' }; @@ -195,7 +198,9 @@ export async function redirectToNewCompareUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.getProjectByName(ctx.params.project); + const project = await db.withUserContext(ctx.state.userId, () => + db.getProjectByName(ctx.params.project) + ); if (project) { ctx.redirect( `/${project.slug}/compare/${ctx.params.baseline}..${ctx.params.change}` @@ -211,11 +216,13 @@ export async function renderComparePage( ): Promise { const start = startRequest(); - const data = await renderCompare( - ctx.params.baseline, - ctx.params.change, - ctx.params.projectSlug, - db + const data = await db.withUserContext(ctx.state.userId, () => + renderCompare( + ctx.params.baseline, + ctx.params.change, + ctx.params.projectSlug, + db + ) ); ctx.body = data.content; ctx.type = 'html'; diff --git a/src/backend/project/data-export.ts b/src/backend/project/data-export.ts index 522e13f7..928f74bf 100644 --- a/src/backend/project/data-export.ts +++ b/src/backend/project/data-export.ts @@ -107,7 +107,9 @@ export async function getAvailableDataAsJson( return; } - ctx.body = await getDataOverview(projectId, db); + ctx.body = await db.withUserContext(ctx.state.userId, () => + getDataOverview(projectId, db) + ); } export async function getDataOverview( From bf604517d6500d966e9375b84c336a614c99c145 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:21:36 +0200 Subject: [PATCH 17/25] Middleware for jsonwebtoken --- src/backend/auth/auth-middleware.ts | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/backend/auth/auth-middleware.ts diff --git a/src/backend/auth/auth-middleware.ts b/src/backend/auth/auth-middleware.ts new file mode 100644 index 00000000..320fa6e3 --- /dev/null +++ b/src/backend/auth/auth-middleware.ts @@ -0,0 +1,41 @@ +import { Next, ParameterizedContext } from 'koa'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || ''; + +if (!JWT_SECRET) { + console.warn( + '[auth] WARNING: JWT_SECRET environment variable is not set. ' + + 'Authentication will not work correctly.' + ); +} + +export interface AuthState { + userId: number; + username: string; +} + +export async function requireAuth( + ctx: ParameterizedContext, + next: Next +): Promise { + const header = ctx.headers.authorization; + if (!header?.startsWith('Bearer ')) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Authentication required' }; + return; + } + + try { + const token = header.slice(7); + const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; + ctx.state.userId = Number(payload.sub); + ctx.state.username = payload.username as string; + await next(); + } catch { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid or expired token' }; + } +} From 2c4cc7b86dfd947488453095160a9c2b42da6102 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:22:19 +0200 Subject: [PATCH 18/25] Methods for User-Authentication --- src/backend/auth/auth-routes.ts | 114 ++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/backend/auth/auth-routes.ts diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts new file mode 100644 index 00000000..bee7c5e3 --- /dev/null +++ b/src/backend/auth/auth-routes.ts @@ -0,0 +1,114 @@ +import { ParameterizedContext } from 'koa'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +import type { Database } from '../db/db.js'; +import { + createUser, + getUserByEmail, + getUserByUsername +} from './auth-db.js'; + +const JWT_SECRET = process.env.JWT_SECRET || ''; +const BCRYPT_ROUNDS = 12; + +export async function register( + ctx: ParameterizedContext, + db: Database +): Promise { + const body = ctx.request.body as any; + const { username, email, password } = body ?? {}; + + if (!username || !email || !password) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'username, email, and password are required' }; + return; + } + + if (typeof username !== 'string' || username.length > 100) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'username must be a string of at most 100 characters' }; + return; + } + + if (typeof email !== 'string' || email.length > 255) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'email must be a string of at most 255 characters' }; + return; + } + + if (typeof password !== 'string' || password.length < 8) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'password must be at least 8 characters' }; + return; + } + + const existingByUsername = await getUserByUsername(db, username); + if (existingByUsername) { + ctx.status = 409; + ctx.type = 'json'; + ctx.body = { error: 'Username already taken' }; + return; + } + + const existingByEmail = await getUserByEmail(db, email); + if (existingByEmail) { + ctx.status = 409; + ctx.type = 'json'; + ctx.body = { error: 'Email already registered' }; + return; + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const user = await createUser(db, username, email, passwordHash); + + ctx.status = 201; + ctx.type = 'json'; + ctx.body = { userId: user.id, username: user.username }; +} + +export async function login( + ctx: ParameterizedContext, + db: Database +): Promise { + const body = ctx.request.body as any; + const { username, password } = body ?? {}; + + if (!username || !password) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'username and password are required' }; + return; + } + + const user = await getUserByUsername(db, username); + + if (!user || !user.is_active) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid credentials' }; + return; + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid credentials' }; + return; + } + + const token = jwt.sign( + { sub: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: '24h' } + ); + + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { token }; +} From 3a88c72798b6454090603599a0b7e2e35fd49901 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 11:22:53 +0200 Subject: [PATCH 19/25] Methods for User-handling in db --- src/backend/auth/auth-db.ts | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/backend/auth/auth-db.ts diff --git a/src/backend/auth/auth-db.ts b/src/backend/auth/auth-db.ts new file mode 100644 index 00000000..748d035b --- /dev/null +++ b/src/backend/auth/auth-db.ts @@ -0,0 +1,50 @@ +import type { Database } from '../db/db.js'; + +export interface AppUser { + id: number; + username: string; + email: string; + password_hash: string; + created_at: Date; + is_active: boolean; +} + +export async function getUserByUsername( + db: Database, + username: string +): Promise { + const result = await db.query({ + name: 'getUserByUsername', + text: 'SELECT * FROM appuser WHERE username = $1', + values: [username] + }); + return result.rows[0] ?? null; +} + +export async function getUserByEmail( + db: Database, + email: string +): Promise { + const result = await db.query({ + name: 'getUserByEmail', + text: 'SELECT * FROM appuser WHERE email = $1', + values: [email] + }); + return result.rows[0] ?? null; +} + +export async function createUser( + db: Database, + username: string, + email: string, + passwordHash: string +): Promise { + const result = await db.query({ + name: 'createUser', + text: `INSERT INTO appuser (username, email, password_hash) + VALUES ($1, $2, $3) + RETURNING *`, + values: [username, email, passwordHash] + }); + return result.rows[0]; +} From 5fed7b07d186b3567069a34a70b570ab8c908786 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 12:42:54 +0200 Subject: [PATCH 20/25] Minimal version of login page. --- src/backend/auth/login.html | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/backend/auth/login.html diff --git a/src/backend/auth/login.html b/src/backend/auth/login.html new file mode 100644 index 00000000..e364284a --- /dev/null +++ b/src/backend/auth/login.html @@ -0,0 +1,142 @@ + + + + + ReBench – Sign In + {%- include('header.html', { rebenchVersion: it.rebenchVersion }) %} + + +
+
+
+

ReBench

+ + + +
+ +
+ +
+
+ + +
+
+ + +
+ +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+ + + + From 28e5ee4b97b7fed51b072d44e3e21d5694d9427f Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Tue, 28 Apr 2026 12:43:41 +0200 Subject: [PATCH 21/25] Added jwt secret to docker-compose.yml --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index fa20377e..74e051c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: RDB_DB: rebenchdb RDB_PORT: 5432 REFRESH_SECRET: refresh + JWT_SECRET: dev-secret-change-in-production DEV: true depends_on: - postgres From 3d19ec23e84b3550a84da817305bfae9f65364b6 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 10 May 2026 11:53:58 +0200 Subject: [PATCH 22/25] Remove app_current_user_id() is NULL bypass --- .../db/schema-updates/migration.015.sql | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/backend/db/schema-updates/migration.015.sql diff --git a/src/backend/db/schema-updates/migration.015.sql b/src/backend/db/schema-updates/migration.015.sql new file mode 100644 index 00000000..a8def2eb --- /dev/null +++ b/src/backend/db/schema-updates/migration.015.sql @@ -0,0 +1,89 @@ +BEGIN; + +ALTER POLICY project_access ON Project + USING ( + EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Project.id + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY experiment_access ON Experiment + USING ( + EXISTS ( + SELECT 1 FROM ProjectMembership pm + WHERE pm.projectId = Experiment.projectId + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY trial_access ON Trial + USING ( + EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Experiment e ON e.id = Trial.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY run_access ON Run + USING ( + EXISTS ( + SELECT 1 FROM Measurement m + JOIN Trial t ON t.id = m.trialId + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE m.runId = Run.id + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY measurement_access ON Measurement + USING ( + EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Measurement.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY timeline_access ON Timeline + USING ( + EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = Timeline.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY source_access ON Source + USING ( + EXISTS ( + SELECT 1 FROM Trial t + JOIN Experiment e ON e.id = t.expId + JOIN ProjectMembership pm ON pm.projectId = e.projectId + WHERE t.sourceId = Source.id + AND pm.userId = app_current_user_id() + ) + ); + +ALTER POLICY profiledata_access ON ProfileData + USING ( + EXISTS ( + SELECT 1 FROM ProjectMembership pm + JOIN Trial t ON t.id = ProfileData.trialId + JOIN Experiment e ON e.id = t.expId + WHERE pm.projectId = e.projectId + AND pm.userId = app_current_user_id() + ) + ); + +INSERT INTO SchemaVersion (version, updateDate) VALUES (15, now()); + +COMMIT; From 894d956c649c83b284a55d99632bc56b87a1576a Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 10 May 2026 11:54:39 +0200 Subject: [PATCH 23/25] Remove app_currenr_user_id() is NULL bypass --- src/backend/db/db.sql | 33 +++++++++++---------------------- src/index.ts | 7 ++++++- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index be7ba024..7ac7fc69 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -279,11 +279,8 @@ $$; -- ============================================================ -- 7. RLS policies -- --- The `app_current_user_id() IS NULL` clause is a temporary --- bypass so that existing routes (not yet protected by auth --- middleware) continue to work during the migration period. --- Remove this clause once all routes enforce authentication. --- +-- All user-facing routes are protected by requireAuth which +-- sets app.current_user_id via withUserContext. -- Machine-to-machine endpoints (PUT /rebenchdb/results) run -- as the pool superuser without SET ROLE, so they bypass -- RLS entirely and are unaffected by these policies. @@ -291,9 +288,8 @@ $$; -- Project: direct membership check CREATE POLICY project_access ON Project - FOR ALL USING ( -- all operations - app_current_user_id() IS NULL -- bypass - OR EXISTS ( + FOR ALL USING ( + EXISTS ( SELECT 1 FROM ProjectMembership pm WHERE pm.projectId = Project.id AND pm.userId = app_current_user_id() @@ -303,8 +299,7 @@ CREATE POLICY project_access ON Project -- Experiment: linked to Project via projectId CREATE POLICY experiment_access ON Experiment FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM ProjectMembership pm WHERE pm.projectId = Experiment.projectId AND pm.userId = app_current_user_id() @@ -314,8 +309,7 @@ CREATE POLICY experiment_access ON Experiment -- Trial: Experiment.projectId CREATE POLICY trial_access ON Trial FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM ProjectMembership pm JOIN Experiment e ON e.id = Trial.expId WHERE pm.projectId = e.projectId @@ -327,8 +321,7 @@ CREATE POLICY trial_access ON Trial -- Trial references it through Measurement. CREATE POLICY run_access ON Run FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM Measurement m JOIN Trial t ON t.id = m.trialId JOIN Experiment e ON e.id = t.expId @@ -341,8 +334,7 @@ CREATE POLICY run_access ON Run -- Measurement: Trial -> Experiment -> Project CREATE POLICY measurement_access ON Measurement FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM ProjectMembership pm JOIN Trial t ON t.id = Measurement.trialId JOIN Experiment e ON e.id = t.expId @@ -354,8 +346,7 @@ CREATE POLICY measurement_access ON Measurement -- Timeline: same join path as Measurement CREATE POLICY timeline_access ON Timeline FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM ProjectMembership pm JOIN Trial t ON t.id = Timeline.trialId JOIN Experiment e ON e.id = t.expId @@ -368,8 +359,7 @@ CREATE POLICY timeline_access ON Timeline -- Trial references it. CREATE POLICY source_access ON Source FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM Trial t JOIN Experiment e ON e.id = t.expId JOIN ProjectMembership pm ON pm.projectId = e.projectId @@ -381,8 +371,7 @@ CREATE POLICY source_access ON Source -- ProfileData: same join path as Measurement CREATE POLICY profiledata_access ON ProfileData FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM ProjectMembership pm JOIN Trial t ON t.id = ProfileData.trialId JOIN Experiment e ON e.id = t.expId diff --git a/src/index.ts b/src/index.ts index 1b535bc1..2888f8a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ import { reportResultApiVersion } from './backend/rebench/results.js'; import { requireAuth } from './backend/auth/auth-middleware.js'; -import { login, register } from './backend/auth/auth-routes.js'; +import { login, register, renderLoginPage } from './backend/auth/auth-routes.js'; import { setTimeout } from 'node:timers/promises'; import { reportConnectionRefused } from './shared/errors.js'; @@ -92,6 +92,11 @@ Disallow: /rebenchdb* ctx.type = 'text'; }); +router.get('/auth/login', (ctx) => renderLoginPage(ctx)); +router.get('/auth/logout', (ctx) => { + ctx.cookies.set('rdb_session', '', { maxAge: 0, path: '/' }); + ctx.redirect('/auth/login'); +}); router.post('/auth/register', koaBody(), async (ctx) => register(ctx, db)); router.post('/auth/login', koaBody(), async (ctx) => login(ctx, db)); From ac9c156e82c20443fad888e7956faebae353c2b4 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 10 May 2026 11:56:47 +0200 Subject: [PATCH 24/25] Add minimal login page --- src/backend/auth/auth-routes.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts index bee7c5e3..be3cf6ce 100644 --- a/src/backend/auth/auth-routes.ts +++ b/src/backend/auth/auth-routes.ts @@ -8,6 +8,15 @@ import { getUserByEmail, getUserByUsername } from './auth-db.js'; +import { prepareTemplate } from '../templates.js'; +import { rebenchVersion, robustPath } from '../util.js'; + +const loginTpl = prepareTemplate(robustPath('backend/auth/login.html')); + +export function renderLoginPage(ctx: ParameterizedContext): void { + ctx.body = loginTpl({ rebenchVersion }); + ctx.type = 'html'; +} const JWT_SECRET = process.env.JWT_SECRET || ''; const BCRYPT_ROUNDS = 12; From 108fec271fd4782f15b4a84567a3347510806a2b Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 10 May 2026 11:57:28 +0200 Subject: [PATCH 25/25] Redirect to login page --- src/backend/auth/auth-middleware.ts | 37 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/backend/auth/auth-middleware.ts b/src/backend/auth/auth-middleware.ts index 320fa6e3..9c1daf0d 100644 --- a/src/backend/auth/auth-middleware.ts +++ b/src/backend/auth/auth-middleware.ts @@ -15,27 +15,46 @@ export interface AuthState { username: string; } +function redirectOrUnauthorized(ctx: ParameterizedContext, clearCookie = false): void { + if (clearCookie) { + ctx.cookies.set('rdb_session', '', { maxAge: 0, path: '/' }); + } + if (ctx.headers.accept?.includes('text/html')) { + ctx.redirect('/auth/login'); + } else { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Authentication required' }; + } +} + export async function requireAuth( ctx: ParameterizedContext, next: Next ): Promise { - const header = ctx.headers.authorization; - if (!header?.startsWith('Bearer ')) { - ctx.status = 401; - ctx.type = 'json'; - ctx.body = { error: 'Authentication required' }; + let token: string | undefined; + + const authHeader = ctx.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + token = authHeader.slice(7); + } else { + const cookie = ctx.cookies.get('rdb_session'); + if (cookie) { + token = decodeURIComponent(cookie); + } + } + + if (!token) { + redirectOrUnauthorized(ctx); return; } try { - const token = header.slice(7); const payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; ctx.state.userId = Number(payload.sub); ctx.state.username = payload.username as string; await next(); } catch { - ctx.status = 401; - ctx.type = 'json'; - ctx.body = { error: 'Invalid or expired token' }; + redirectOrUnauthorized(ctx, true); } }