From 941bdc3e2fed8932842d71e06aa00d24f0222b17 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 23 Mar 2026 17:09:25 +0100 Subject: [PATCH 01/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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); } } From 3ef938b0de2e46e50f75bce582829fb74d5af0ea Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 18 May 2026 19:01:02 +0200 Subject: [PATCH 26/54] Update `package-lock.json` to remove unused dependencies and adjust type definitions. --- package-lock.json | 88 +++++------------------------------------------ 1 file changed, 8 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86ee6a66..43cf6450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,8 +35,8 @@ "@octokit/types": "16.0.0", "@types/bcrypt": "^6.0.0", "@types/ejs": "3.1.5", +Ad "@types/jquery": "4.0.0", "@types/jsonwebtoken": "^9.0.10", - "@types/jquery": "4.0.0", "@types/koa": "3.0.2", "@types/koa__router": "12.0.5", "@types/pg": "8.20.0", @@ -938,9 +938,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -957,9 +954,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -976,9 +970,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -995,9 +986,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1014,9 +1002,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1033,9 +1018,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1052,9 +1034,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1071,9 +1050,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1090,9 +1066,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1115,9 +1088,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1140,9 +1110,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1165,9 +1132,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1190,9 +1154,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1215,9 +1176,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1240,9 +1198,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1265,9 +1220,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2388,6 +2340,13 @@ "@types/node": "*" } }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3102,9 +3061,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3119,9 +3075,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3136,9 +3089,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3153,9 +3103,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3170,9 +3117,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3187,9 +3131,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3204,9 +3145,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3221,9 +3159,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6834,13 +6769,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "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", From 604aeb749419f52606f06c66abeb73ebea1f37c0 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 18 May 2026 21:16:31 +0200 Subject: [PATCH 27/54] Update `package-lock.json` to remove unused dependencies and adjust type definitions. --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 43cf6450..c4b2237e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@octokit/types": "16.0.0", "@types/bcrypt": "^6.0.0", "@types/ejs": "3.1.5", -Ad "@types/jquery": "4.0.0", + "@types/jquery": "4.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/koa": "3.0.2", "@types/koa__router": "12.0.5", From 4a7b71b115ebc33ef39256f702cef87549b54aec Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 12:31:20 +0200 Subject: [PATCH 28/54] Add admin functionality for project management. --- src/backend/admin/admin.html | 107 +++++++++++ src/frontend/admin.ts | 343 +++++++++++++++++++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 src/backend/admin/admin.html create mode 100644 src/frontend/admin.ts diff --git a/src/backend/admin/admin.html b/src/backend/admin/admin.html new file mode 100644 index 00000000..ac7bafa2 --- /dev/null +++ b/src/backend/admin/admin.html @@ -0,0 +1,107 @@ + + + + + ReBench – Admin + {%- include('header.html', { rebenchVersion: it.rebenchVersion }) %} + + + + +
+
+

Admin

+

Manage projects and user access.

+

Signed in as {%= it.username %}.

+
+ {%- include('common-menu.html', it) %} +
+ +
+
+
+
+
My Projects
+
+ +
    +

    + You don't have access to any projects yet. Create one below. +

    +
    +
    + +
    +
    Create Project
    +
    + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    + +
    + + +
    + Select a project on the left to manage its members. +
    +
    +
    +
    + + + diff --git a/src/frontend/admin.ts b/src/frontend/admin.ts new file mode 100644 index 00000000..bef1dd88 --- /dev/null +++ b/src/frontend/admin.ts @@ -0,0 +1,343 @@ +type ProjectRole = 'view' | 'edit' | 'owner'; + +interface MyProject { + id: number; + name: string; + slug: string; + description: string | null; + role: ProjectRole; +} + +interface Member { + userId: number; + username: string; + email: string; + role: ProjectRole; +} + +const ROLES: ProjectRole[] = ['view', 'edit', 'owner']; + +let myProjects: MyProject[] = []; +let selectedProjectId: number | null = null; + +function $id(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing element #${id}`); + return el; +} + +function showAlert(id: string, message: string): void { + const el = $id(id); + el.textContent = message; + el.classList.remove('d-none'); +} + +function hideAlert(id: string): void { + $id(id).classList.add('d-none'); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function readJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return {}; + } +} + +async function fetchMyProjects(): Promise { + hideAlert('admin-projects-error'); + try { + const res = await fetch('/admin/api/my-projects', { + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-projects-error', + data.error || `Server error (${res.status})` + ); + return; + } + myProjects = data.projects || []; + renderProjectsList(); + } catch { + showAlert('admin-projects-error', 'Network error loading projects.'); + } +} + +function renderProjectsList(): void { + const ul = $id('admin-projects-list'); + const empty = $id('admin-no-projects'); + ul.innerHTML = ''; + if (myProjects.length === 0) { + empty.classList.remove('d-none'); + return; + } + empty.classList.add('d-none'); + for (const p of myProjects) { + const li = document.createElement('li'); + li.className = + 'list-group-item d-flex justify-content-between align-items-center'; + if (p.id === selectedProjectId) { + li.classList.add('active'); + } + li.style.cursor = 'pointer'; + li.innerHTML = ` + + ${escapeHtml(p.name)} + /${escapeHtml(p.slug)} + + ${escapeHtml(p.role)} + `; + li.addEventListener('click', () => selectProject(p.id)); + ul.appendChild(li); + } +} + +async function selectProject(projectId: number): Promise { + selectedProjectId = projectId; + renderProjectsList(); + + const project = myProjects.find((p) => p.id === projectId); + if (!project) return; + + const card = $id('admin-members-card'); + const placeholder = $id('admin-members-placeholder'); + const nameEl = $id('admin-members-project-name'); + const notOwnerAlert = $id('admin-members-not-owner'); + const addSection = $id('admin-add-member-section'); + const tbody = $id('admin-members-tbody'); + + nameEl.textContent = project.name; + card.style.display = 'block'; + placeholder.style.display = 'none'; + hideAlert('admin-members-error'); + + if (project.role !== 'owner') { + notOwnerAlert.classList.remove('d-none'); + addSection.style.display = 'none'; + tbody.innerHTML = ''; + return; + } + + notOwnerAlert.classList.add('d-none'); + addSection.style.display = ''; + + try { + const res = await fetch(`/admin/api/projects/${projectId}/members`, { + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-members-error', + data.error || `Server error (${res.status})` + ); + tbody.innerHTML = ''; + return; + } + renderMembersTable(projectId, data.members || []); + } catch { + showAlert('admin-members-error', 'Network error loading members.'); + } +} + +function renderMembersTable(projectId: number, members: Member[]): void { + const tbody = $id('admin-members-tbody'); + tbody.innerHTML = ''; + for (const m of members) { + const tr = document.createElement('tr'); + const roleOptions = ROLES.map( + (r) => + `` + ).join(''); + tr.innerHTML = ` + ${escapeHtml(m.username)} + ${escapeHtml(m.email)} + + + + + + + `; + tbody.appendChild(tr); + } + + tbody.querySelectorAll('.member-role-select').forEach( + (select) => { + const userId = Number(select.dataset.userId); + const originalRole = select.value as ProjectRole; + select.addEventListener('change', () => + changeMemberRole(projectId, userId, select, originalRole) + ); + } + ); + tbody.querySelectorAll('.member-remove-btn').forEach( + (btn) => { + const userId = Number(btn.dataset.userId); + btn.addEventListener('click', () => removeMember(projectId, userId)); + } + ); +} + +async function changeMemberRole( + projectId: number, + userId: number, + select: HTMLSelectElement, + originalRole: ProjectRole +): Promise { + hideAlert('admin-members-error'); + const newRole = select.value as ProjectRole; + try { + const res = await fetch( + `/admin/api/projects/${projectId}/members/${userId}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ role: newRole }) + } + ); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-members-error', + data.error || `Server error (${res.status})` + ); + select.value = originalRole; + return; + } + select.dataset.originalRole = newRole; + } catch { + showAlert('admin-members-error', 'Network error updating role.'); + select.value = originalRole; + } +} + +async function removeMember( + projectId: number, + userId: number +): Promise { + hideAlert('admin-members-error'); + if (!confirm('Remove this member from the project?')) return; + try { + const res = await fetch( + `/admin/api/projects/${projectId}/members/${userId}`, + { + method: 'DELETE', + headers: { Accept: 'application/json' } + } + ); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-members-error', + data.error || `Server error (${res.status})` + ); + return; + } + selectProject(projectId); + } catch { + showAlert('admin-members-error', 'Network error removing member.'); + } +} + +function wireCreateProject(): void { + const form = $id('create-project-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-project-error'); + const name = ( + $id('create-project-name') as HTMLInputElement + ).value.trim(); + const description = ( + $id('create-project-description') as HTMLTextAreaElement + ).value.trim(); + if (!name) return; + try { + const res = await fetch('/admin/api/projects', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ name, description }) + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'create-project-error', + data.error || `Server error (${res.status})` + ); + return; + } + form.reset(); + await fetchMyProjects(); + if (data.project?.id) { + selectProject(data.project.id); + } + } catch { + showAlert('create-project-error', 'Network error creating project.'); + } + }); +} + +function wireAddMember(): void { + const form = $id('add-member-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('admin-members-error'); + if (selectedProjectId === null) return; + const username = ( + $id('add-member-username') as HTMLInputElement + ).value.trim(); + const role = ($id('add-member-role') as HTMLSelectElement).value; + if (!username) return; + try { + const res = await fetch( + `/admin/api/projects/${selectedProjectId}/members`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ username, role }) + } + ); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-members-error', + data.error || `Server error (${res.status})` + ); + return; + } + form.reset(); + selectProject(selectedProjectId); + } catch { + showAlert('admin-members-error', 'Network error adding member.'); + } + }); +} + +document.addEventListener('DOMContentLoaded', () => { + wireCreateProject(); + wireAddMember(); + fetchMyProjects(); +}); From a8fc7e2285984f4114b0b846fa8bbbdaf853fb42 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 12:31:51 +0200 Subject: [PATCH 29/54] DB operations for admin functionalities --- src/backend/admin/admin-db.ts | 173 ++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/backend/admin/admin-db.ts diff --git a/src/backend/admin/admin-db.ts b/src/backend/admin/admin-db.ts new file mode 100644 index 00000000..61e33572 --- /dev/null +++ b/src/backend/admin/admin-db.ts @@ -0,0 +1,173 @@ +import type { Database } from '../db/db.js'; + +export type ProjectRole = 'view' | 'edit' | 'owner'; + +export const PROJECT_ROLES: ProjectRole[] = ['view', 'edit', 'owner']; + +export function isProjectRole(value: unknown): value is ProjectRole { + return ( + typeof value === 'string' && + (PROJECT_ROLES as string[]).includes(value) + ); +} + +export interface MyProject { + id: number; + name: string; + slug: string; + description: string | null; + role: ProjectRole; +} + +export interface ProjectMember { + userId: number; + username: string; + email: string; + role: ProjectRole; +} + +export async function getProjectsForUser( + db: Database, + userId: number +): Promise { + const result = await db.query({ + name: 'admin_getProjectsForUser', + text: `SELECT p.id, p.name, p.slug, p.description, pm.role + FROM Project p + JOIN ProjectMembership pm ON pm.projectId = p.id + WHERE pm.userId = $1 + ORDER BY p.position DESC, p.name ASC`, + values: [userId] + }); + return result.rows; +} + +export async function getUserRoleForProject( + db: Database, + userId: number, + projectId: number +): Promise { + const result = await db.query<{ role: ProjectRole }>({ + name: 'admin_getUserRoleForProject', + text: `SELECT role FROM ProjectMembership + WHERE userId = $1 AND projectId = $2`, + values: [userId, projectId] + }); + return result.rows[0]?.role ?? null; +} + +export async function listProjectMembers( + db: Database, + projectId: number +): Promise { + const result = await db.query({ + name: 'admin_listProjectMembers', + text: `SELECT u.id AS "userId", u.username, u.email, pm.role + FROM ProjectMembership pm + JOIN appuser u ON u.id = pm.userId + WHERE pm.projectId = $1 + ORDER BY pm.role DESC, u.username ASC`, + values: [projectId] + }); + return result.rows; +} + +export async function addProjectMember( + db: Database, + projectId: number, + userId: number, + role: ProjectRole +): Promise { + await db.query({ + name: 'admin_addProjectMember', + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, $3)`, + values: [userId, projectId, role] + }); +} + +export async function updateProjectMemberRole( + db: Database, + projectId: number, + userId: number, + role: ProjectRole +): Promise { + const result = await db.query({ + name: 'admin_updateProjectMemberRole', + text: `UPDATE ProjectMembership + SET role = $3 + WHERE projectId = $1 AND userId = $2`, + values: [projectId, userId, role] + }); + return (result.rowCount ?? 0) > 0; +} + +export async function removeProjectMember( + db: Database, + projectId: number, + userId: number +): Promise { + const result = await db.query({ + name: 'admin_removeProjectMember', + text: `DELETE FROM ProjectMembership + WHERE projectId = $1 AND userId = $2`, + values: [projectId, userId] + }); + return (result.rowCount ?? 0) > 0; +} + +export async function countOwners( + db: Database, + projectId: number +): Promise { + const result = await db.query<{ cnt: string }>({ + name: 'admin_countOwners', + text: `SELECT count(*)::text AS cnt FROM ProjectMembership + WHERE projectId = $1 AND role = 'owner'`, + values: [projectId] + }); + return Number(result.rows[0]?.cnt ?? 0); +} + +export function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export interface CreatedProject { + id: number; + name: string; + slug: string; +} + +/** + * Inserts the Project and its initial owner ProjectMembership in one + * statement. Must be called outside withUserContext so it runs as the pool + * (superuser) connection — RLS on Project would otherwise reject the INSERT + * because no membership exists for the project yet. + */ +export async function createProjectWithOwner( + db: Database, + name: string, + slug: string, + description: string | null, + ownerUserId: number +): Promise { + const result = await db.query({ + name: 'admin_createProjectWithOwner', + text: `WITH new_project AS ( + INSERT INTO Project (name, slug, description) + VALUES ($1, $2, $3) + RETURNING id, name, slug + ), new_membership AS ( + INSERT INTO ProjectMembership (userId, projectId, role) + SELECT $4, id, 'owner' FROM new_project + ) + SELECT id, name, slug FROM new_project`, + values: [name, slug, description, ownerUserId] + }); + return result.rows[0]; +} From a19cb4382f493defd0317a21308c8d0e09b27ad4 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 12:32:05 +0200 Subject: [PATCH 30/54] Added Admin routes for project management --- src/backend/admin/admin-routes.ts | 295 ++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 src/backend/admin/admin-routes.ts diff --git a/src/backend/admin/admin-routes.ts b/src/backend/admin/admin-routes.ts new file mode 100644 index 00000000..010092c9 --- /dev/null +++ b/src/backend/admin/admin-routes.ts @@ -0,0 +1,295 @@ +import { ParameterizedContext } from 'koa'; + +import type { Database } from '../db/db.js'; +import { prepareTemplate } from '../templates.js'; +import { rebenchVersion, robustPath } from '../util.js'; +import { getUserByUsername } from '../auth/auth-db.js'; +import { + PROJECT_ROLES, + ProjectRole, + addProjectMember, + countOwners, + createProjectWithOwner, + getProjectsForUser, + getUserRoleForProject, + isProjectRole, + listProjectMembers, + removeProjectMember, + slugify, + updateProjectMemberRole +} from './admin-db.js'; + +const adminTpl = prepareTemplate(robustPath('backend/admin/admin.html')); + +export function renderAdminPage(ctx: ParameterizedContext): void { + ctx.body = adminTpl({ + rebenchVersion, + username: ctx.state.username + }); + ctx.type = 'html'; +} + +function jsonError( + ctx: ParameterizedContext, + status: number, + message: string +): void { + ctx.status = status; + ctx.type = 'json'; + ctx.body = { error: message }; +} + +function parseProjectId(ctx: ParameterizedContext): number | null { + const raw = ctx.params.projectId; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) { + jsonError(ctx, 400, 'Invalid projectId'); + return null; + } + return id; +} + +function parseUserId(ctx: ParameterizedContext): number | null { + const raw = ctx.params.userId; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) { + jsonError(ctx, 400, 'Invalid userId'); + return null; + } + return id; +} + +async function requireOwner( + ctx: ParameterizedContext, + db: Database, + projectId: number +): Promise { + const role = await db.withUserContext(ctx.state.userId, () => + getUserRoleForProject(db, ctx.state.userId, projectId) + ); + if (role !== 'owner') { + jsonError( + ctx, + role === null ? 404 : 403, + role === null ? 'Project not found' : 'Owner role required' + ); + return false; + } + return true; +} + +export async function listMyProjects( + ctx: ParameterizedContext, + db: Database +): Promise { + const projects = await db.withUserContext(ctx.state.userId, () => + getProjectsForUser(db, ctx.state.userId) + ); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { projects }; +} + +export async function createProject( + ctx: ParameterizedContext, + db: Database +): Promise { + const body = ctx.request.body as any; + const name = typeof body?.name === 'string' ? body.name.trim() : ''; + const description = + typeof body?.description === 'string' && body.description.trim() !== '' + ? body.description.trim() + : null; + + if (!name) { + jsonError(ctx, 400, 'Project name is required'); + return; + } + if (name.length > 100) { + jsonError(ctx, 400, 'Project name must be at most 100 characters'); + return; + } + + const slug = slugify(name); + if (!slug) { + jsonError(ctx, 400, 'Project name must contain alphanumeric characters'); + return; + } + + try { + const project = await createProjectWithOwner( + db, + name, + slug, + description, + ctx.state.userId + ); + ctx.status = 201; + ctx.type = 'json'; + ctx.body = { project }; + } catch (e: any) { + if (e?.code === '23505') { + jsonError(ctx, 409, 'A project with that name or slug already exists'); + return; + } + throw e; + } +} + +export async function getMembers( + ctx: ParameterizedContext, + db: Database +): Promise { + const projectId = parseProjectId(ctx); + if (projectId === null) return; + if (!(await requireOwner(ctx, db, projectId))) return; + + const members = await db.withUserContext(ctx.state.userId, () => + listProjectMembers(db, projectId) + ); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { members }; +} + +export async function addMember( + ctx: ParameterizedContext, + db: Database +): Promise { + const projectId = parseProjectId(ctx); + if (projectId === null) return; + if (!(await requireOwner(ctx, db, projectId))) return; + + const body = ctx.request.body as any; + const username = + typeof body?.username === 'string' ? body.username.trim() : ''; + const role = body?.role; + + if (!username) { + jsonError(ctx, 400, 'username is required'); + return; + } + if (!isProjectRole(role)) { + jsonError( + ctx, + 400, + `role must be one of: ${PROJECT_ROLES.join(', ')}` + ); + return; + } + + const user = await getUserByUsername(db, username); + if (!user) { + jsonError(ctx, 404, `No user found with username "${username}"`); + return; + } + + try { + await db.withUserContext(ctx.state.userId, () => + addProjectMember(db, projectId, user.id, role as ProjectRole) + ); + ctx.status = 201; + ctx.type = 'json'; + ctx.body = { + member: { + userId: user.id, + username: user.username, + email: user.email, + role + } + }; + } catch (e: any) { + if (e?.code === '23505') { + jsonError(ctx, 409, 'User is already a member of this project'); + return; + } + throw e; + } +} + +export async function updateMember( + ctx: ParameterizedContext, + db: Database +): Promise { + const projectId = parseProjectId(ctx); + if (projectId === null) return; + const userId = parseUserId(ctx); + if (userId === null) return; + if (!(await requireOwner(ctx, db, projectId))) return; + + const body = ctx.request.body as any; + const role = body?.role; + if (!isProjectRole(role)) { + jsonError( + ctx, + 400, + `role must be one of: ${PROJECT_ROLES.join(', ')}` + ); + return; + } + + if (role !== 'owner') { + const current = await db.withUserContext(ctx.state.userId, () => + getUserRoleForProject(db, userId, projectId) + ); + if (current === 'owner') { + const owners = await db.withUserContext(ctx.state.userId, () => + countOwners(db, projectId) + ); + if (owners <= 1) { + jsonError( + ctx, + 409, + 'Cannot demote the last owner of the project' + ); + return; + } + } + } + + const updated = await db.withUserContext(ctx.state.userId, () => + updateProjectMemberRole(db, projectId, userId, role as ProjectRole) + ); + if (!updated) { + jsonError(ctx, 404, 'Member not found'); + return; + } + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { ok: true }; +} + +export async function deleteMember( + ctx: ParameterizedContext, + db: Database +): Promise { + const projectId = parseProjectId(ctx); + if (projectId === null) return; + const userId = parseUserId(ctx); + if (userId === null) return; + if (!(await requireOwner(ctx, db, projectId))) return; + + const current = await db.withUserContext(ctx.state.userId, () => + getUserRoleForProject(db, userId, projectId) + ); + if (current === null) { + jsonError(ctx, 404, 'Member not found'); + return; + } + if (current === 'owner') { + const owners = await db.withUserContext(ctx.state.userId, () => + countOwners(db, projectId) + ); + if (owners <= 1) { + jsonError(ctx, 409, 'Cannot remove the last owner of the project'); + return; + } + } + + await db.withUserContext(ctx.state.userId, () => + removeProjectMember(db, projectId, userId) + ); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { ok: true }; +} From 0e6ee6b571604091208f716d8a5eb81e6ae2f182 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 12:32:37 +0200 Subject: [PATCH 31/54] Edited comment. --- src/backend/db/db.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index 7ac7fc69..c9f782dc 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -267,7 +267,6 @@ 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 From 4d5352650e8f28a6e339ebd4d59216902347f3a1 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 12:33:21 +0200 Subject: [PATCH 32/54] Added admin functionalities for project management --- src/index.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2888f8a9..13ce3922 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,15 @@ import { } from './backend/compare/compare.js'; import { getAvailableDataAsJson } from './backend/project/data-export.js'; import { submitTimelineUpdateJobs } from './backend/admin/operations.js'; +import { + addMember, + createProject, + deleteMember, + getMembers, + listMyProjects, + renderAdminPage, + updateMember +} from './backend/admin/admin-routes.js'; import { acceptResultData, reportResultApiVersion @@ -100,6 +109,8 @@ router.get('/auth/logout', (ctx) => { router.post('/auth/register', koaBody(), async (ctx) => register(ctx, db)); router.post('/auth/login', koaBody(), async (ctx) => login(ctx, db)); +router.get('/admin', requireAuth, async (ctx) => renderAdminPage(ctx)); + router.get('/:projectSlug', requireAuth, async (ctx) => renderProjectPage(ctx, db) ); @@ -175,6 +186,35 @@ router.post( async (ctx) => getTimelineDataAsJson(ctx, db) ); +router.get('/admin/api/my-projects', requireAuth, async (ctx) => + listMyProjects(ctx, db) +); +router.post('/admin/api/projects', requireAuth, koaBody(), async (ctx) => + createProject(ctx, db) +); +router.get( + '/admin/api/projects/:projectId/members', + requireAuth, + async (ctx) => getMembers(ctx, db) +); +router.post( + '/admin/api/projects/:projectId/members', + requireAuth, + koaBody(), + async (ctx) => addMember(ctx, db) +); +router.put( + '/admin/api/projects/:projectId/members/:userId', + requireAuth, + koaBody(), + async (ctx) => updateMember(ctx, db) +); +router.delete( + '/admin/api/projects/:projectId/members/:userId', + requireAuth, + async (ctx) => deleteMember(ctx, db) +); + router.get('/admin/perform-timeline-update', requireAuth, async (ctx) => submitTimelineUpdateJobs(ctx, db) ); From 5d2e46be6d77644ad82cbbd560d302c571cef83a Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 12:40:27 +0200 Subject: [PATCH 33/54] Consolidate DB-changes into one migration file. --- .../db/schema-updates/migration.014.sql | 35 +++----- .../db/schema-updates/migration.015.sql | 89 ------------------- 2 files changed, 12 insertions(+), 112 deletions(-) delete mode 100644 src/backend/db/schema-updates/migration.015.sql diff --git a/src/backend/db/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql index ca6f4254..1e4d506a 100644 --- a/src/backend/db/schema-updates/migration.014.sql +++ b/src/backend/db/schema-updates/migration.014.sql @@ -80,7 +80,6 @@ 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 @@ -92,11 +91,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. @@ -105,8 +101,7 @@ $$; -- Project: direct membership check CREATE POLICY project_access ON Project FOR ALL USING ( - app_current_user_id() IS NULL - OR EXISTS ( + EXISTS ( SELECT 1 FROM ProjectMembership pm WHERE pm.projectId = Project.id AND pm.userId = app_current_user_id() @@ -116,8 +111,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() @@ -127,8 +121,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 @@ -140,8 +133,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 @@ -154,8 +146,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 @@ -167,8 +158,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 @@ -181,8 +171,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 @@ -194,8 +183,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 @@ -205,8 +193,9 @@ CREATE POLICY profiledata_access ON ProfileData ); -- ============================================================ --- 8. Schema version bump +-- 8. Schema version bump (consolidates migrations 14 and 15) -- ============================================================ INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); +INSERT INTO SchemaVersion (version, updateDate) VALUES (15, now()); COMMIT; diff --git a/src/backend/db/schema-updates/migration.015.sql b/src/backend/db/schema-updates/migration.015.sql deleted file mode 100644 index a8def2eb..00000000 --- a/src/backend/db/schema-updates/migration.015.sql +++ /dev/null @@ -1,89 +0,0 @@ -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 44ab74604d6db27f82d1b3cd914d11c88e0924bf Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 19:46:02 +0200 Subject: [PATCH 34/54] Added API Token management functionality --- src/backend/admin/admin-db.ts | 58 +++++++++++++++++++++++++++++++ src/backend/admin/admin-routes.ts | 22 ++++++++++++ src/backend/admin/admin.html | 17 +++++++++ src/frontend/admin.ts | 52 +++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/src/backend/admin/admin-db.ts b/src/backend/admin/admin-db.ts index 61e33572..ff2d1cfa 100644 --- a/src/backend/admin/admin-db.ts +++ b/src/backend/admin/admin-db.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + import type { Database } from '../db/db.js'; export type ProjectRole = 'view' | 'edit' | 'owner'; @@ -149,6 +151,62 @@ export interface CreatedProject { * (superuser) connection — RLS on Project would otherwise reject the INSERT * because no membership exists for the project yet. */ +export async function generateApiTokenForUser( + db: Database, + userId: number +): Promise { + const token = randomBytes(32).toString('hex'); + await db.query({ + name: 'admin_generateApiTokenForUser', + text: `UPDATE appuser SET api_token = $2 WHERE id = $1`, + values: [userId, token] + }); + return token; +} + +export async function getApiTokenStatusForUser( + db: Database, + userId: number +): Promise<{ hasToken: boolean; suffix: string | null }> { + const result = await db.query<{ api_token: string | null }>({ + name: 'admin_getApiTokenStatusForUser', + text: `SELECT api_token FROM appuser WHERE id = $1`, + values: [userId] + }); + const token = result.rows[0]?.api_token ?? null; + return { + hasToken: token !== null, + suffix: token ? token.slice(-8) : null + }; +} + +export async function getUserByApiToken( + db: Database, + token: string +): Promise<{ id: number } | null> { + const result = await db.query<{ id: number }>({ + name: 'admin_getUserByApiToken', + text: `SELECT id FROM appuser WHERE api_token = $1 AND is_active = true`, + values: [token] + }); + return result.rows[0] ?? null; +} + +export async function getUserRoleForProjectByName( + db: Database, + userId: number, + projectName: string +): Promise { + const result = await db.query<{ role: ProjectRole }>({ + name: 'admin_getUserRoleForProjectByName', + text: `SELECT pm.role FROM ProjectMembership pm + JOIN Project p ON p.id = pm.projectId + WHERE pm.userId = $1 AND p.name = $2`, + values: [userId, projectName] + }); + return result.rows[0]?.role ?? null; +} + export async function createProjectWithOwner( db: Database, name: string, diff --git a/src/backend/admin/admin-routes.ts b/src/backend/admin/admin-routes.ts index 010092c9..d72e7f21 100644 --- a/src/backend/admin/admin-routes.ts +++ b/src/backend/admin/admin-routes.ts @@ -10,6 +10,8 @@ import { addProjectMember, countOwners, createProjectWithOwner, + generateApiTokenForUser, + getApiTokenStatusForUser, getProjectsForUser, getUserRoleForProject, isProjectRole, @@ -259,6 +261,26 @@ export async function updateMember( ctx.body = { ok: true }; } +export async function getMyApiToken( + ctx: ParameterizedContext, + db: Database +): Promise { + const status = await getApiTokenStatusForUser(db, ctx.state.userId); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = status; +} + +export async function generateMyApiToken( + ctx: ParameterizedContext, + db: Database +): Promise { + const token = await generateApiTokenForUser(db, ctx.state.userId); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { token }; +} + export async function deleteMember( ctx: ParameterizedContext, db: Database diff --git a/src/backend/admin/admin.html b/src/backend/admin/admin.html index ac7bafa2..4bda37c2 100644 --- a/src/backend/admin/admin.html +++ b/src/backend/admin/admin.html @@ -31,6 +31,23 @@
    My Projects
    +
    +
    API Token
    +
    +

    + Use this token in rebench.conf to authenticate data submissions. +

    +
    + + +
    +
    +
    Create Project
    diff --git a/src/frontend/admin.ts b/src/frontend/admin.ts index bef1dd88..c1c51f77 100644 --- a/src/frontend/admin.ts +++ b/src/frontend/admin.ts @@ -336,8 +336,60 @@ function wireAddMember(): void { }); } +async function fetchApiTokenStatus(): Promise { + try { + const res = await fetch('/admin/api/token', { + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + const statusEl = $id('api-token-status'); + if (!res.ok) { + statusEl.textContent = 'Could not load token status.'; + return; + } + if (data.hasToken) { + statusEl.innerHTML = `Token set — ends in …${escapeHtml(data.suffix)}`; + } else { + statusEl.textContent = 'No token set.'; + } + } catch { + $id('api-token-status').textContent = 'Network error loading token status.'; + } +} + +function wireApiToken(): void { + const btn = $id('api-token-generate-btn'); + btn.addEventListener('click', async () => { + if ( + !confirm( + 'Generate a new API token? Any existing token will stop working immediately.' + ) + ) + return; + try { + const res = await fetch('/admin/api/token/generate', { + method: 'POST', + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + alert(data.error || `Server error (${res.status})`); + return; + } + const reveal = $id('api-token-reveal'); + ($id('api-token-value') as HTMLElement).textContent = data.token; + reveal.classList.remove('d-none'); + await fetchApiTokenStatus(); + } catch { + alert('Network error generating token.'); + } + }); +} + document.addEventListener('DOMContentLoaded', () => { wireCreateProject(); wireAddMember(); + wireApiToken(); fetchMyProjects(); + fetchApiTokenStatus(); }); From 16dd99662ffc90e197094606c2fd693d900d23e2 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 19:47:34 +0200 Subject: [PATCH 35/54] Added token field to Benchmarkdata --- src/shared/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/api.ts b/src/shared/api.ts index 3bf2ba52..705e8543 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -133,6 +133,7 @@ export interface BenchmarkData { startTime: string; endTime?: string | null; projectName: string; + token?: string; } export interface BenchmarkCompletion { From 9360f0baa08336ceb4cbec64fa15ea654b90c82f Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 19:47:52 +0200 Subject: [PATCH 36/54] Added Admin-Button --- src/views/common-menu.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/common-menu.html b/src/views/common-menu.html index 0fc75018..e1384d6d 100644 --- a/src/views/common-menu.html +++ b/src/views/common-menu.html @@ -1,5 +1,6 @@ \ No newline at end of file From ea51f2b5fad8c52ac9dc702d5c01dbafbc4e7edb Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 19:48:08 +0200 Subject: [PATCH 37/54] Added Token to Appuser-Table --- src/backend/db/db.sql | 1 + src/backend/db/schema-updates/migration.014.sql | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index c9f782dc..0e384ed5 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -213,6 +213,7 @@ CREATE TABLE appuser ( username VARCHAR(100) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, -- bcrypt hash, always 60 chars + api_token VARCHAR(64) UNIQUE NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), is_active BOOLEAN NOT NULL DEFAULT true ); diff --git a/src/backend/db/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql index 1e4d506a..82a65bab 100644 --- a/src/backend/db/schema-updates/migration.014.sql +++ b/src/backend/db/schema-updates/migration.014.sql @@ -26,6 +26,7 @@ CREATE TABLE appuser ( username VARCHAR(100) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, -- bcrypt hash, always 60 chars + api_token VARCHAR(64) UNIQUE NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), is_active BOOLEAN NOT NULL DEFAULT true ); @@ -193,9 +194,16 @@ CREATE POLICY profiledata_access ON ProfileData ); -- ============================================================ --- 8. Schema version bump (consolidates migrations 14 and 15) +-- 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. -- ============================================================ -INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); -INSERT INTO SchemaVersion (version, updateDate) VALUES (15, now()); +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; +-- ============================================================ +-- 9. Schema version bump +-- ============================================================ +INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); COMMIT; From ef513e5688ffd31c12abd4486a7a5638150c68dd Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 19:48:23 +0200 Subject: [PATCH 38/54] Added Authentication of user-token --- src/backend/rebench/results.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/backend/rebench/results.ts b/src/backend/rebench/results.ts index 92d02e40..41fd605c 100644 --- a/src/backend/rebench/results.ts +++ b/src/backend/rebench/results.ts @@ -3,6 +3,7 @@ import { ValidateFunction } from 'ajv'; import { BenchmarkData } from '../../shared/api.js'; import { Database } from '../db/db.js'; +import { getUserByApiToken, getUserRoleForProjectByName } from '../admin/admin-db.js'; import { createValidator } from './api-validator.js'; import { DEBUG } from '../util.js'; import { log } from '../logging.js'; @@ -79,6 +80,28 @@ export async function acceptResultData( return; } + const token = typeof data.token === 'string' ? data.token.trim() : null; + + if (token === null) { + ctx.body = 'Authorization required. Add a token field to the rebenchdb section in rebench.conf.'; + ctx.status = 401; + return; + } + + const user = await getUserByApiToken(db, token); + if (user === null) { + ctx.body = 'Invalid API token.'; + ctx.status = 401; + return; + } + + const role = await getUserRoleForProjectByName(db, user.id, data.projectName); + if (role !== 'edit' && role !== 'owner') { + ctx.body = `User does not have write permission on project "${data.projectName}".`; + ctx.status = 403; + return; + } + try { const recRunsPromise = db.recordMetaDataAndRuns(data); log.info(`/rebenchdb/results: Content-Length=${ctx.request.length}`); From d04296939a9b1e58f47a63cae91322ea23b333a9 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 19:48:46 +0200 Subject: [PATCH 39/54] Added Token routes --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index 13ce3922..b436ef1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,9 @@ import { addMember, createProject, deleteMember, + generateMyApiToken, getMembers, + getMyApiToken, listMyProjects, renderAdminPage, updateMember @@ -215,6 +217,13 @@ router.delete( async (ctx) => deleteMember(ctx, db) ); +router.get('/admin/api/token', requireAuth, async (ctx) => + getMyApiToken(ctx, db) +); +router.post('/admin/api/token/generate', requireAuth, async (ctx) => + generateMyApiToken(ctx, db) +); + router.get('/admin/perform-timeline-update', requireAuth, async (ctx) => submitTimelineUpdateJobs(ctx, db) ); From b337050f9f86426a4689e8d43e57f4edb85a21eb Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 21:14:58 +0200 Subject: [PATCH 40/54] Added admin button to expected html output --- tests/data/expected-results/main/index.html | 1 + tests/data/expected-results/project/get-exp-data.html | 1 + tests/data/expected-results/project/project-data.html | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/data/expected-results/main/index.html b/tests/data/expected-results/main/index.html index fb97bf84..1afc32aa 100644 --- a/tests/data/expected-results/main/index.html +++ b/tests/data/expected-results/main/index.html @@ -30,6 +30,7 @@

    Execute and document benchmarks reproducibly.

    - diff --git a/src/frontend/login.ts b/src/frontend/login.ts new file mode 100644 index 00000000..a82e03c9 --- /dev/null +++ b/src/frontend/login.ts @@ -0,0 +1,88 @@ +function setSessionCookie(token: string): void { + document.cookie = + 'rdb_session=' + + encodeURIComponent(token) + + '; path=/; samesite=strict; max-age=86400'; +} + +function showError(id: string, message: string): void { + const el = document.getElementById(id)!; + el.textContent = message; + el.classList.remove('d-none'); +} + +function hideError(id: string): void { + document.getElementById(id)!.classList.add('d-none'); +} + +document + .getElementById('login-form')! + .addEventListener('submit', async (e) => { + e.preventDefault(); + hideError('login-error'); + const username = ( + document.getElementById('login-username') as HTMLInputElement + ).value; + const password = ( + document.getElementById('login-password') as HTMLInputElement + ).value; + try { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + let data: any; + try { + data = await res.json(); + } catch { + data = {}; + } + if (!res.ok) { + showError('login-error', data.error || `Server error (${res.status})`); + return; + } + setSessionCookie(data.token); + const params = new URLSearchParams(window.location.search); + window.location.href = params.get('next') || '/'; + } catch { + showError('login-error', 'Network error. Please try again.'); + } + }); + +document + .getElementById('register-form')! + .addEventListener('submit', async (e) => { + e.preventDefault(); + hideError('register-error'); + document.getElementById('register-success')!.classList.add('d-none'); + const username = ( + document.getElementById('reg-username') as HTMLInputElement + ).value; + const email = ( + document.getElementById('reg-email') as HTMLInputElement + ).value; + const password = ( + document.getElementById('reg-password') as HTMLInputElement + ).value; + try { + const res = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }) + }); + const data = await res.json(); + if (!res.ok) { + showError('register-error', data.error || 'Registration failed'); + return; + } + const successEl = document.getElementById('register-success')!; + successEl.textContent = 'Account created! You can now sign in.'; + successEl.classList.remove('d-none'); + (document.getElementById('login-tab') as HTMLButtonElement).click(); + (document.getElementById('login-username') as HTMLInputElement).value = + username; + } catch { + showError('register-error', 'Network error. Please try again.'); + } + }); From 2dd04e7476904ca78c282d770044f0b00d194d2b Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 31 May 2026 22:04:16 +0200 Subject: [PATCH 44/54] Moved JWT_SECRET and BCRYPT_ROUNDS to util.js --- src/backend/auth/auth-middleware.ts | 9 +-------- src/backend/auth/auth-routes.ts | 5 +---- src/backend/util.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/backend/auth/auth-middleware.ts b/src/backend/auth/auth-middleware.ts index 4c9fb495..a555a1d7 100644 --- a/src/backend/auth/auth-middleware.ts +++ b/src/backend/auth/auth-middleware.ts @@ -1,14 +1,7 @@ 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.' - ); -} +import { JWT_SECRET } from '../util.js'; export interface AuthState { userId: number; diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts index a4e079c4..adf8b119 100644 --- a/src/backend/auth/auth-routes.ts +++ b/src/backend/auth/auth-routes.ts @@ -9,7 +9,7 @@ import { getUserByUsername } from './auth-db.js'; import { prepareTemplate } from '../templates.js'; -import { rebenchVersion, robustPath } from '../util.js'; +import { BCRYPT_ROUNDS, JWT_SECRET, rebenchVersion, robustPath } from '../util.js'; const loginTpl = prepareTemplate(robustPath('backend/auth/login.html')); @@ -18,9 +18,6 @@ export function renderLoginPage(ctx: ParameterizedContext): void { ctx.type = 'html'; } -const JWT_SECRET = process.env.JWT_SECRET || ''; -const BCRYPT_ROUNDS = 12; - export async function register( ctx: ParameterizedContext, db: Database diff --git a/src/backend/util.ts b/src/backend/util.ts index b2f7531a..298d46ed 100644 --- a/src/backend/util.ts +++ b/src/backend/util.ts @@ -124,6 +124,16 @@ export const siteConfig = { export const TotalCriterion = 'total'; +export const JWT_SECRET = process.env.JWT_SECRET || ''; +export const BCRYPT_ROUNDS = 12; + +if (!JWT_SECRET) { + console.warn( + '[auth] WARNING: JWT_SECRET environment variable is not set. ' + + 'Authentication will not work correctly.' + ); +} + export const rebenchVersion = JSON.parse( readFileSync(robustPath('../package.json'), 'utf-8') ).version; From f7ea627a6b8854ae6d2fc74724894602b84d961b Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 1 Jun 2026 19:10:19 +0200 Subject: [PATCH 45/54] Cast role to projectRole in SQL queries --- src/backend/admin/admin-db.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/admin/admin-db.ts b/src/backend/admin/admin-db.ts index 29d475b7..5bf5bf18 100644 --- a/src/backend/admin/admin-db.ts +++ b/src/backend/admin/admin-db.ts @@ -83,7 +83,7 @@ export async function addProjectMember( await db.query({ name: 'admin_addProjectMember', text: `INSERT INTO ProjectMembership (userId, projectId, role) - VALUES ($1, $2, $3)`, + VALUES ($1, $2, $3::projectRole)`, values: [userId, projectId, role] }); } @@ -97,7 +97,7 @@ export async function updateProjectMemberRole( const result = await db.query({ name: 'admin_updateProjectMemberRole', text: `UPDATE ProjectMembership - SET role = $3 + SET role = $3::projectRole WHERE projectId = $1 AND userId = $2`, values: [projectId, userId, role] }); From 700504b9e03df43ba214dc543dc44261677acf23 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 1 Jun 2026 20:38:49 +0200 Subject: [PATCH 46/54] Removed whole ressources directory from file to enable loading css files in docker. --- .dockerignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 72de1f51..0680603e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ **/node_modules /dist -/resources +/resources/exp-data +/resources/reports From 8fc310ed3b2cee315190e6b65b15a292b560a10e Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sat, 20 Jun 2026 18:54:47 +0200 Subject: [PATCH 47/54] Added Group-Functionality --- src/backend/admin/admin-routes.ts | 241 +++++++++-- src/backend/admin/admin.html | 106 ++++- src/backend/admin/group-db.ts | 138 +++++++ src/backend/db/db.sql | 19 + .../db/schema-updates/migration.014.sql | 38 +- src/frontend/admin.ts | 388 ++++++++++++++++++ src/index.ts | 97 +++-- tests/backend/db/auth-db.test.ts | 308 ++++++++++++++ 8 files changed, 1266 insertions(+), 69 deletions(-) create mode 100644 src/backend/admin/group-db.ts diff --git a/src/backend/admin/admin-routes.ts b/src/backend/admin/admin-routes.ts index d72e7f21..c6a86478 100644 --- a/src/backend/admin/admin-routes.ts +++ b/src/backend/admin/admin-routes.ts @@ -20,6 +20,16 @@ import { slugify, updateProjectMemberRole } from './admin-db.js'; +import { + addGroupToProject, + addUserToGroup, + createGroup, + deleteGroup, + getGroupById, + listGroupMembers, + listGroups, + removeUserFromGroup +} from './group-db.js'; const adminTpl = prepareTemplate(robustPath('backend/admin/admin.html')); @@ -66,9 +76,7 @@ async function requireOwner( db: Database, projectId: number ): Promise { - const role = await db.withUserContext(ctx.state.userId, () => - getUserRoleForProject(db, ctx.state.userId, projectId) - ); + const role = await getUserRoleForProject(db, ctx.state.userId, projectId); if (role !== 'owner') { jsonError( ctx, @@ -84,9 +92,7 @@ export async function listMyProjects( ctx: ParameterizedContext, db: Database ): Promise { - const projects = await db.withUserContext(ctx.state.userId, () => - getProjectsForUser(db, ctx.state.userId) - ); + const projects = await getProjectsForUser(db, ctx.state.userId); ctx.status = 200; ctx.type = 'json'; ctx.body = { projects }; @@ -146,9 +152,7 @@ export async function getMembers( if (projectId === null) return; if (!(await requireOwner(ctx, db, projectId))) return; - const members = await db.withUserContext(ctx.state.userId, () => - listProjectMembers(db, projectId) - ); + const members = await listProjectMembers(db, projectId); ctx.status = 200; ctx.type = 'json'; ctx.body = { members }; @@ -187,9 +191,7 @@ export async function addMember( } try { - await db.withUserContext(ctx.state.userId, () => - addProjectMember(db, projectId, user.id, role as ProjectRole) - ); + await addProjectMember(db, projectId, user.id, role as ProjectRole); ctx.status = 201; ctx.type = 'json'; ctx.body = { @@ -231,13 +233,9 @@ export async function updateMember( } if (role !== 'owner') { - const current = await db.withUserContext(ctx.state.userId, () => - getUserRoleForProject(db, userId, projectId) - ); + const current = await getUserRoleForProject(db, userId, projectId); if (current === 'owner') { - const owners = await db.withUserContext(ctx.state.userId, () => - countOwners(db, projectId) - ); + const owners = await countOwners(db, projectId); if (owners <= 1) { jsonError( ctx, @@ -249,8 +247,11 @@ export async function updateMember( } } - const updated = await db.withUserContext(ctx.state.userId, () => - updateProjectMemberRole(db, projectId, userId, role as ProjectRole) + const updated = await updateProjectMemberRole( + db, + projectId, + userId, + role as ProjectRole ); if (!updated) { jsonError(ctx, 404, 'Member not found'); @@ -291,27 +292,209 @@ export async function deleteMember( if (userId === null) return; if (!(await requireOwner(ctx, db, projectId))) return; - const current = await db.withUserContext(ctx.state.userId, () => - getUserRoleForProject(db, userId, projectId) - ); + const current = await getUserRoleForProject(db, userId, projectId); if (current === null) { jsonError(ctx, 404, 'Member not found'); return; } if (current === 'owner') { - const owners = await db.withUserContext(ctx.state.userId, () => - countOwners(db, projectId) - ); + const owners = await countOwners(db, projectId); if (owners <= 1) { jsonError(ctx, 409, 'Cannot remove the last owner of the project'); return; } } - await db.withUserContext(ctx.state.userId, () => - removeProjectMember(db, projectId, userId) - ); + await removeProjectMember(db, projectId, userId); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { ok: true }; +} + +function parseGroupId(ctx: ParameterizedContext): number | null { + const raw = ctx.params.groupId; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) { + jsonError(ctx, 400, 'Invalid groupId'); + return null; + } + return id; +} + +export async function listAllGroups( + ctx: ParameterizedContext, + db: Database +): Promise { + const groups = await listGroups(db); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { groups }; +} + +export async function createNewGroup( + ctx: ParameterizedContext, + db: Database +): Promise { + const body = ctx.request.body as any; + const name = typeof body?.name === 'string' ? body.name.trim() : ''; + const description = + typeof body?.description === 'string' && body.description.trim() !== '' + ? body.description.trim() + : null; + + if (!name) { + jsonError(ctx, 400, 'Group name is required'); + return; + } + if (name.length > 100) { + jsonError(ctx, 400, 'Group name must be at most 100 characters'); + return; + } + + try { + const group = await createGroup(db, name, description, ctx.state.userId); + ctx.status = 201; + ctx.type = 'json'; + ctx.body = { group }; + } catch (e: any) { + if (e?.code === '23505') { + jsonError(ctx, 409, 'A group with that name already exists'); + return; + } + throw e; + } +} + +export async function removeGroup( + ctx: ParameterizedContext, + db: Database +): Promise { + const groupId = parseGroupId(ctx); + if (groupId === null) return; + + const deleted = await deleteGroup(db, groupId); + if (!deleted) { + jsonError(ctx, 404, 'Group not found'); + return; + } ctx.status = 200; ctx.type = 'json'; ctx.body = { ok: true }; } + +export async function getGroupMembers( + ctx: ParameterizedContext, + db: Database +): Promise { + const groupId = parseGroupId(ctx); + if (groupId === null) return; + + const group = await getGroupById(db, groupId); + if (group === null) { + jsonError(ctx, 404, 'Group not found'); + return; + } + + const members = await listGroupMembers(db, groupId); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { members }; +} + +export async function addGroupMember( + ctx: ParameterizedContext, + db: Database +): Promise { + const groupId = parseGroupId(ctx); + if (groupId === null) return; + + const group = await getGroupById(db, groupId); + if (group === null) { + jsonError(ctx, 404, 'Group not found'); + return; + } + + const body = ctx.request.body as any; + const username = + typeof body?.username === 'string' ? body.username.trim() : ''; + if (!username) { + jsonError(ctx, 400, 'username is required'); + return; + } + + const user = await getUserByUsername(db, username); + if (!user) { + jsonError(ctx, 404, `No user found with username "${username}"`); + return; + } + + try { + await addUserToGroup(db, groupId, user.id); + ctx.status = 201; + ctx.type = 'json'; + ctx.body = { member: { userId: user.id, username: user.username, email: user.email } }; + } catch (e: any) { + if (e?.code === '23505') { + jsonError(ctx, 409, 'User is already a member of this group'); + return; + } + throw e; + } +} + +export async function removeGroupMember( + ctx: ParameterizedContext, + db: Database +): Promise { + const groupId = parseGroupId(ctx); + if (groupId === null) return; + const userId = parseUserId(ctx); + if (userId === null) return; + + const removed = await removeUserFromGroup(db, groupId, userId); + if (!removed) { + jsonError(ctx, 404, 'Member not found in group'); + return; + } + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { ok: true }; +} + +export async function assignGroupToProject( + ctx: ParameterizedContext, + db: Database +): Promise { + const projectId = parseProjectId(ctx); + if (projectId === null) return; + if (!(await requireOwner(ctx, db, projectId))) return; + + const body = ctx.request.body as any; + const groupId = typeof body?.groupId === 'number' ? body.groupId : Number(body?.groupId); + if (!Number.isInteger(groupId) || groupId <= 0) { + jsonError(ctx, 400, 'groupId is required'); + return; + } + + const role = body?.role; + if (!isProjectRole(role)) { + jsonError(ctx, 400, `role must be one of: ${PROJECT_ROLES.join(', ')}`); + return; + } + + const group = await getGroupById(db, groupId); + if (group === null) { + jsonError(ctx, 404, 'Group not found'); + return; + } + + const added = await addGroupToProject( + db, + projectId, + groupId, + role as ProjectRole + ); + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { added }; +} diff --git a/src/backend/admin/admin.html b/src/backend/admin/admin.html index 4bda37c2..a7ad4a94 100644 --- a/src/backend/admin/admin.html +++ b/src/backend/admin/admin.html @@ -48,7 +48,7 @@
    API Token
    -
    +
    Create Project
    @@ -66,6 +66,36 @@
    Create Project
    + +
    +
    Groups
    +
    + +
      +

      + No groups yet. Create one below. +

      +
      +
      + +
      +
      Create Group
      +
      + +
      +
      + + +
      +
      + + +
      + +
      +
      +
      @@ -92,7 +122,7 @@
      Add Member
      -
      +
      @@ -109,12 +139,82 @@
      Add Member
      + +
      Add Group
      +
      +
      + + +
      +
      + + +
      +
      + +
      +
      +
      + +
      - Select a project on the left to manage its members. + Select a project or group on the left to manage it.
      diff --git a/src/backend/admin/group-db.ts b/src/backend/admin/group-db.ts new file mode 100644 index 00000000..48472256 --- /dev/null +++ b/src/backend/admin/group-db.ts @@ -0,0 +1,138 @@ +import type { Database } from '../db/db.js'; +import type { ProjectRole } from './admin-db.js'; + +export interface UserGroup { + id: number; + name: string; + description: string | null; + createdBy: number | null; + createdAt: string; + memberCount: number; +} + +export interface GroupMember { + userId: number; + username: string; + email: string; +} + +export async function createGroup( + db: Database, + name: string, + description: string | null, + createdByUserId: number +): Promise { + const result = await db.query({ + name: 'group_createGroup', + text: `INSERT INTO UserGroup (name, description, "createdBy") + VALUES ($1, $2, $3) + RETURNING id, name, description, "createdBy", "createdAt", 0 AS "memberCount"`, + values: [name, description, createdByUserId] + }); + return result.rows[0]; +} + +export async function listGroups(db: Database): Promise { + const result = await db.query({ + name: 'group_listGroups', + text: `SELECT g.id, g.name, g.description, g."createdBy", g."createdAt", + COUNT(m.userId)::int AS "memberCount" + FROM UserGroup g + LEFT JOIN UserGroupMembership m ON m.groupId = g.id + GROUP BY g.id + ORDER BY g.name ASC` + }); + return result.rows; +} + +export async function getGroupById( + db: Database, + groupId: number +): Promise { + const result = await db.query({ + name: 'group_getGroupById', + text: `SELECT g.id, g.name, g.description, g."createdBy", g."createdAt", + COUNT(m.userId)::int AS "memberCount" + FROM UserGroup g + LEFT JOIN UserGroupMembership m ON m.groupId = g.id + WHERE g.id = $1 + GROUP BY g.id`, + values: [groupId] + }); + return result.rows[0] ?? null; +} + +export async function deleteGroup( + db: Database, + groupId: number +): Promise { + const result = await db.query({ + name: 'group_deleteGroup', + text: `DELETE FROM UserGroup WHERE id = $1`, + values: [groupId] + }); + return (result.rowCount ?? 0) > 0; +} + +export async function listGroupMembers( + db: Database, + groupId: number +): Promise { + const result = await db.query({ + name: 'group_listGroupMembers', + text: `SELECT u.id AS "userId", u.username, u.email + FROM UserGroupMembership m + JOIN AppUser u ON u.id = m.userId + WHERE m.groupId = $1 + ORDER BY u.username ASC`, + values: [groupId] + }); + return result.rows; +} + +export async function addUserToGroup( + db: Database, + groupId: number, + userId: number +): Promise { + await db.query({ + name: 'group_addUserToGroup', + text: `INSERT INTO UserGroupMembership (userId, groupId) VALUES ($1, $2)`, + values: [userId, groupId] + }); +} + +export async function removeUserFromGroup( + db: Database, + groupId: number, + userId: number +): Promise { + const result = await db.query({ + name: 'group_removeUserFromGroup', + text: `DELETE FROM UserGroupMembership WHERE groupId = $1 AND userId = $2`, + values: [groupId, userId] + }); + return (result.rowCount ?? 0) > 0; +} + +export async function addGroupToProject( + db: Database, + projectId: number, + groupId: number, + role: ProjectRole +): Promise { + const result = await db.query<{ cnt: string }>({ + name: 'group_addGroupToProject', + text: `WITH members AS ( + SELECT userId FROM UserGroupMembership WHERE groupId = $2 + ), inserted AS ( + INSERT INTO ProjectMembership (userId, projectId, role) + SELECT userId, $1, $3::projectRole FROM members + ON CONFLICT (userId, projectId) DO NOTHING + RETURNING userId + ) + SELECT COUNT(*)::text AS cnt FROM inserted`, + values: [projectId, groupId, role] + }); + return Number(result.rows[0]?.cnt ?? 0); +} diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index 8ee8bd43..154c14cd 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -389,6 +389,25 @@ CREATE POLICY profiledata_access ON ProfileData 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; +-- ============================================================ +-- 9. User groups (for batch-assigning users to projects) +-- ============================================================ +CREATE TABLE UserGroup ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + "createdBy" INTEGER REFERENCES AppUser(id) ON DELETE SET NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE UserGroupMembership ( + userId INTEGER NOT NULL REFERENCES AppUser(id) ON DELETE CASCADE, + groupId INTEGER NOT NULL REFERENCES UserGroup(id) ON DELETE CASCADE, + PRIMARY KEY (userId, groupId) +); + +CREATE INDEX usergroupmembership_groupid_idx ON UserGroupMembership (groupId); + -- Used by ReBenchDB's perf-tracker, for self-performance tracking CREATE PROCEDURE recordAdditionalMeasurement( aRunId smallint, diff --git a/src/backend/db/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql index 5322716b..5869ab0f 100644 --- a/src/backend/db/schema-updates/migration.014.sql +++ b/src/backend/db/schema-updates/migration.014.sql @@ -1,5 +1,6 @@ -- migration.014.sql --- Adds user authentication and project-level Row Level Security. +-- Adds user authentication, project-level Row Level Security, +-- and user groups for batch-assigning users to projects. -- -- Post-migration manual steps required (run as superuser): -- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rdb_app; @@ -44,7 +45,29 @@ CREATE TABLE ProjectMembership ( CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); -- ============================================================ --- 4. Dedicated non-superuser DB role for the application. +-- 4. User groups (for batch-assigning users to projects) +-- Groups are a snapshot mechanism: assigning a group to a +-- project bulk-inserts all current group members into +-- ProjectMembership. +-- ============================================================ +CREATE TABLE UserGroup ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + "createdBy" INTEGER REFERENCES AppUser(id) ON DELETE SET NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE UserGroupMembership ( + userId INTEGER NOT NULL REFERENCES AppUser(id) ON DELETE CASCADE, + groupId INTEGER NOT NULL REFERENCES UserGroup(id) ON DELETE CASCADE, + PRIMARY KEY (userId, groupId) +); + +CREATE INDEX usergroupmembership_groupid_idx ON UserGroupMembership (groupId); + +-- ============================================================ +-- 5. 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. @@ -57,7 +80,7 @@ BEGIN END$$; -- ============================================================ --- 5. Enable Row Level Security on all relevant tables. +-- 6. 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. -- ============================================================ @@ -80,7 +103,7 @@ ALTER TABLE Source FORCE ROW LEVEL SECURITY; ALTER TABLE ProfileData FORCE ROW LEVEL SECURITY; -- ============================================================ --- 6. Helper function to read the session-local user ID. +-- 7. Helper function to read the session-local user ID. -- SECURITY DEFINER so rdb_app can call current_setting. -- ============================================================ CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS INTEGER @@ -90,7 +113,7 @@ $$ $$; -- ============================================================ --- 7. RLS policies +-- 8. RLS policies -- -- All user-facing routes are protected by requireAuth which -- sets app.currentUserId via withUserContext. @@ -194,7 +217,7 @@ CREATE POLICY profiledata_access ON ProfileData ); -- ============================================================ --- 8. Grants for rdb_app so RLS policies can be tested and enforced. +-- 9. 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. @@ -203,7 +226,8 @@ 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; -- ============================================================ --- 9. Schema version bump +-- 10. Schema version bump -- ============================================================ INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); + COMMIT; diff --git a/src/frontend/admin.ts b/src/frontend/admin.ts index 689bd315..f0a13d72 100644 --- a/src/frontend/admin.ts +++ b/src/frontend/admin.ts @@ -105,6 +105,9 @@ function renderProjectsList(): void { async function selectProject(projectId: number): Promise { selectedProjectId = projectId; + selectedGroupId = null; + renderGroupsList(); + $id('admin-group-card').style.display = 'none'; renderProjectsList(); const project = myProjects.find((p) => p.id === projectId); @@ -383,10 +386,395 @@ function wireApiToken(): void { }); } +// ── Group management ───────────────────────────────────────────────────────── + +interface GroupInfo { + id: number; + name: string; + description: string | null; + memberCount: number; +} + +interface GroupMember { + userId: number; + username: string; + email: string; +} + +let groups: GroupInfo[] = []; +let selectedGroupId: number | null = null; + +async function fetchGroups(): Promise { + hideAlert('admin-groups-error'); + try { + const res = await fetch('/admin/api/groups', { + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-groups-error', + data.error || `Server error (${res.status})` + ); + return; + } + groups = data.groups || []; + renderGroupsList(); + populateGroupSelect(); + } catch { + showAlert('admin-groups-error', 'Network error loading groups.'); + } +} + +function renderGroupsList(): void { + const ul = $id('admin-groups-list'); + const empty = $id('admin-no-groups'); + ul.innerHTML = ''; + if (groups.length === 0) { + empty.classList.remove('d-none'); + return; + } + empty.classList.add('d-none'); + for (const g of groups) { + const li = document.createElement('li'); + li.className = + 'list-group-item d-flex justify-content-between align-items-center'; + if (g.id === selectedGroupId) li.classList.add('active'); + li.style.cursor = 'pointer'; + li.innerHTML = ` + ${escapeHtml(g.name)} + + ${g.memberCount} member${g.memberCount === 1 ? '' : 's'} + + `; + li.addEventListener('click', () => selectGroup(g.id)); + ul.appendChild(li); + } +} + +function populateGroupSelect(): void { + const select = document.getElementById( + 'add-group-to-project-select' + ) as HTMLSelectElement | null; + if (!select) return; + const prev = select.value; + select.innerHTML = groups + .map((g) => ``) + .join(''); + if (groups.some((g) => String(g.id) === prev)) select.value = prev; +} + +function populateOwnerProjectSelect(): void { + const select = document.getElementById( + 'assign-group-project' + ) as HTMLSelectElement | null; + if (!select) return; + const ownerProjects = myProjects.filter((p) => p.role === 'owner'); + const prev = select.value; + select.innerHTML = ownerProjects + .map((p) => ``) + .join(''); + if (ownerProjects.some((p) => String(p.id) === prev)) select.value = prev; +} + +async function selectGroup(groupId: number): Promise { + selectedGroupId = groupId; + selectedProjectId = null; + renderGroupsList(); + renderProjectsList(); + + const group = groups.find((g) => g.id === groupId); + if (!group) return; + + $id('admin-group-name').textContent = group.name; + $id('admin-group-card').style.display = 'block'; + $id('admin-members-card').style.display = 'none'; + $id('admin-members-placeholder').style.display = 'none'; + hideAlert('admin-group-error'); + $id('assign-group-result').classList.add('d-none'); + + populateOwnerProjectSelect(); + + try { + const res = await fetch(`/admin/api/groups/${groupId}/members`, { + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-group-error', + data.error || `Server error (${res.status})` + ); + $id('admin-group-members-tbody').innerHTML = ''; + return; + } + renderGroupMembersTable(groupId, data.members || []); + } catch { + showAlert('admin-group-error', 'Network error loading group members.'); + } +} + +function renderGroupMembersTable( + groupId: number, + members: GroupMember[] +): void { + const tbody = $id('admin-group-members-tbody'); + tbody.innerHTML = ''; + if (members.length === 0) { + tbody.innerHTML = + 'No members yet.'; + return; + } + for (const m of members) { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${escapeHtml(m.username)} + ${escapeHtml(m.email)} + + + + `; + tbody.appendChild(tr); + } + tbody + .querySelectorAll('.group-member-remove-btn') + .forEach((btn) => { + const userId = Number(btn.dataset.userId); + btn.addEventListener('click', () => removeGroupMember(groupId, userId)); + }); +} + +async function removeGroupMember( + groupId: number, + userId: number +): Promise { + hideAlert('admin-group-error'); + if (!confirm('Remove this member from the group?')) return; + try { + const res = await fetch(`/admin/api/groups/${groupId}/members/${userId}`, { + method: 'DELETE', + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-group-error', + data.error || `Server error (${res.status})` + ); + return; + } + await fetchGroups(); + await selectGroup(groupId); + } catch { + showAlert('admin-group-error', 'Network error removing member.'); + } +} + +async function deleteSelectedGroup(): Promise { + if (selectedGroupId === null) return; + const group = groups.find((g) => g.id === selectedGroupId); + if ( + !confirm(`Delete group "${group?.name ?? ''}"? This cannot be undone.`) + ) + return; + try { + const res = await fetch(`/admin/api/groups/${selectedGroupId}`, { + method: 'DELETE', + headers: { Accept: 'application/json' } + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-group-error', + data.error || `Server error (${res.status})` + ); + return; + } + selectedGroupId = null; + $id('admin-group-card').style.display = 'none'; + $id('admin-members-placeholder').style.display = ''; + await fetchGroups(); + } catch { + showAlert('admin-group-error', 'Network error deleting group.'); + } +} + +function wireCreateGroup(): void { + const form = $id('create-group-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-group-error'); + const name = ($id('create-group-name') as HTMLInputElement).value.trim(); + const description = ( + $id('create-group-description') as HTMLTextAreaElement + ).value.trim(); + if (!name) return; + try { + const res = await fetch('/admin/api/groups', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ name, description: description || null }) + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'create-group-error', + data.error || `Server error (${res.status})` + ); + return; + } + form.reset(); + await fetchGroups(); + if (data.group?.id) selectGroup(data.group.id); + } catch { + showAlert('create-group-error', 'Network error creating group.'); + } + }); +} + +function wireAddGroupMember(): void { + const form = $id('add-group-member-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('admin-group-error'); + if (selectedGroupId === null) return; + const username = ( + $id('add-group-member-username') as HTMLInputElement + ).value.trim(); + if (!username) return; + try { + const res = await fetch(`/admin/api/groups/${selectedGroupId}/members`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ username }) + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-group-error', + data.error || `Server error (${res.status})` + ); + return; + } + form.reset(); + await fetchGroups(); + await selectGroup(selectedGroupId); + } catch { + showAlert('admin-group-error', 'Network error adding member.'); + } + }); +} + +function wireAssignGroupToProject(): void { + const form = $id('assign-group-form') as HTMLFormElement; + const resultEl = $id('assign-group-result'); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('admin-group-error'); + resultEl.classList.add('d-none'); + if (selectedGroupId === null) return; + const projectId = Number( + ($id('assign-group-project') as HTMLSelectElement).value + ); + const role = ($id('assign-group-role') as HTMLSelectElement).value; + if (!projectId) { + showAlert('admin-group-error', 'Please select a project.'); + return; + } + try { + const res = await fetch(`/admin/api/projects/${projectId}/groups`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ groupId: selectedGroupId, role }) + }); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-group-error', + data.error || `Server error (${res.status})` + ); + return; + } + const added: number = data.added ?? 0; + resultEl.textContent = `${added} member${added === 1 ? '' : 's'} added to project.`; + resultEl.className = 'mt-2 alert alert-success'; + } catch { + showAlert('admin-group-error', 'Network error assigning group.'); + } + }); +} + +function wireAddGroupToProject(): void { + const form = $id('add-group-to-project-form') as HTMLFormElement; + const resultEl = $id('add-group-to-project-result'); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('admin-members-error'); + resultEl.classList.add('d-none'); + if (selectedProjectId === null) return; + const groupId = Number( + ($id('add-group-to-project-select') as HTMLSelectElement).value + ); + const role = ($id('add-group-to-project-role') as HTMLSelectElement).value; + if (!groupId) { + showAlert('admin-members-error', 'Please select a group.'); + return; + } + try { + const res = await fetch( + `/admin/api/projects/${selectedProjectId}/groups`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ groupId, role }) + } + ); + const data = await readJson(res); + if (!res.ok) { + showAlert( + 'admin-members-error', + data.error || `Server error (${res.status})` + ); + return; + } + const added: number = data.added ?? 0; + resultEl.textContent = `${added} member${added === 1 ? '' : 's'} added to project.`; + resultEl.className = 'mt-2 alert alert-success'; + if (added > 0) selectProject(selectedProjectId); + } catch { + showAlert('admin-members-error', 'Network error adding group to project.'); + } + }); +} + +function wireDeleteGroup(): void { + $id('admin-group-delete-btn').addEventListener('click', deleteSelectedGroup); +} + document.addEventListener('DOMContentLoaded', () => { wireCreateProject(); wireAddMember(); wireApiToken(); + wireCreateGroup(); + wireAddGroupMember(); + wireAssignGroupToProject(); + wireAddGroupToProject(); + wireDeleteGroup(); fetchMyProjects(); fetchApiTokenStatus(); + fetchGroups(); }); diff --git a/src/index.ts b/src/index.ts index 986cdea6..80384401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,11 +52,18 @@ import { submitTimelineUpdateJobs } from './backend/admin/operations.js'; import { addMember, createProject, + addGroupMember, + assignGroupToProject, + createNewGroup, deleteMember, generateMyApiToken, + getGroupMembers, getMembers, getMyApiToken, + listAllGroups, listMyProjects, + removeGroup, + removeGroupMember, renderAdminPage, updateMember } from './backend/admin/admin-routes.js'; @@ -85,7 +92,7 @@ export const db = new DatabaseWithPool( cacheInvalidationDelay ); -router.get('/', requireAuth, async (ctx) => { +router.get('/', requireAuth(db), async (ctx) => { return renderMainPage(ctx, db); }); @@ -115,23 +122,23 @@ router.get('/auth/logout', (ctx) => { router.post('/auth/register', koaBody(), async (ctx) => register(ctx, db)); router.post('/auth/login', koaBody(), async (ctx) => login(ctx, db)); -router.get('/admin', requireAuth, async (ctx) => renderAdminPage(ctx)); +router.get('/admin', requireAuth(db), async (ctx) => renderAdminPage(ctx)); -router.get('/:projectSlug', requireAuth, async (ctx) => +router.get('/:projectSlug', requireAuth(db), async (ctx) => renderProjectPage(ctx, db) ); -router.get('/:projectSlug/source/:sourceId', requireAuth, async (ctx) => +router.get('/:projectSlug/source/:sourceId', requireAuth(db), async (ctx) => getSourceAsJson(ctx, db) ); -router.get('/:projectSlug/timeline', requireAuth, async (ctx) => +router.get('/:projectSlug/timeline', requireAuth(db), async (ctx) => renderTimeline(ctx, db) ); -router.get('/:projectSlug/data', requireAuth, async (ctx) => +router.get('/:projectSlug/data', requireAuth(db), async (ctx) => renderProjectDataPage(ctx, db) ); router.get( '/:projectSlug/data/:expIdAndExtension', - requireAuth, + requireAuth(db), async (ctx) => { if ( ctx.header['X-Purpose'] === 'preview' || @@ -147,101 +154,131 @@ router.get( ); router.get( '/:projectSlug/compare/:baseline..:change', - requireAuth, + requireAuth(db), async (ctx) => renderComparePage(ctx, db) ); // DEPRECATED: remove for 1.0 -router.get('/timeline/:projectId', requireAuth, async (ctx) => +router.get('/timeline/:projectId', requireAuth(db), async (ctx) => redirectToNewTimelineUrl(ctx, db) ); -router.get('/project/:projectId', requireAuth, async (ctx) => +router.get('/project/:projectId', requireAuth(db), async (ctx) => redirectToNewProjectDataUrl(ctx, db) ); -router.get('/rebenchdb/get-exp-data/:expId', requireAuth, async (ctx) => +router.get('/rebenchdb/get-exp-data/:expId', requireAuth(db), async (ctx) => redirectToNewProjectDataExportUrl(ctx, db) ); -router.get('/compare/:project/:baseline/:change', requireAuth, async (ctx) => +router.get('/compare/:project/:baseline/:change', requireAuth(db), 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', requireAuth, async (ctx) => +router.get('/rebenchdb/dash/:projectId/results', requireAuth(db), async (ctx) => getLast100MeasurementsAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectId/timeline/:runId', - requireAuth, + requireAuth(db), async (ctx) => getTimelineAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/profiles/:runId/:commitId', - requireAuth, + requireAuth(db), async (ctx) => getProfileAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/measurements/:runId/:baseId/:changeId', - requireAuth, + requireAuth(db), async (ctx) => getMeasurementsAsJson(ctx, db) ); -router.get('/rebenchdb/stats', requireAuth, async (ctx) => +router.get('/rebenchdb/stats', requireAuth(db), async (ctx) => getSiteStatsAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/changes', requireAuth, async (ctx) => +router.get('/rebenchdb/dash/:projectId/changes', requireAuth(db), async (ctx) => getChangesAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectId/data-overview', - requireAuth, + requireAuth(db), async (ctx) => getAvailableDataAsJson(ctx, db) ); router.post( '/rebenchdb/dash/:projectName/timelines', - requireAuth, + requireAuth(db), koaBody(), async (ctx) => getTimelineDataAsJson(ctx, db) ); -router.get('/admin/api/my-projects', requireAuth, async (ctx) => +router.get('/admin/api/my-projects', requireAuth(db), async (ctx) => listMyProjects(ctx, db) ); -router.post('/admin/api/projects', requireAuth, koaBody(), async (ctx) => +router.post('/admin/api/projects', requireAuth(db), koaBody(), async (ctx) => createProject(ctx, db) ); -router.get('/admin/api/projects/:projectId/members', requireAuth, async (ctx) => +router.get('/admin/api/projects/:projectId/members', requireAuth(db), async (ctx) => getMembers(ctx, db) ); router.post( '/admin/api/projects/:projectId/members', - requireAuth, + requireAuth(db), koaBody(), async (ctx) => addMember(ctx, db) ); router.put( '/admin/api/projects/:projectId/members/:userId', - requireAuth, + requireAuth(db), koaBody(), async (ctx) => updateMember(ctx, db) ); router.delete( '/admin/api/projects/:projectId/members/:userId', - requireAuth, + requireAuth(db), async (ctx) => deleteMember(ctx, db) ); -router.get('/admin/api/token', requireAuth, async (ctx) => +router.get('/admin/api/groups', requireAuth(db), async (ctx) => + listAllGroups(ctx, db) +); +router.post('/admin/api/groups', requireAuth(db), koaBody(), async (ctx) => + createNewGroup(ctx, db) +); +router.delete('/admin/api/groups/:groupId', requireAuth(db), async (ctx) => + removeGroup(ctx, db) +); +router.get('/admin/api/groups/:groupId/members', requireAuth(db), async (ctx) => + getGroupMembers(ctx, db) +); +router.post( + '/admin/api/groups/:groupId/members', + requireAuth(db), + koaBody(), + async (ctx) => addGroupMember(ctx, db) +); +router.delete( + '/admin/api/groups/:groupId/members/:userId', + requireAuth(db), + async (ctx) => removeGroupMember(ctx, db) +); +router.post( + '/admin/api/projects/:projectId/groups', + requireAuth(db), + koaBody(), + async (ctx) => assignGroupToProject(ctx, db) +); + +router.get('/admin/api/token', requireAuth(db), async (ctx) => getMyApiToken(ctx, db) ); -router.post('/admin/api/token/generate', requireAuth, async (ctx) => +router.post('/admin/api/token/generate', requireAuth(db), async (ctx) => generateMyApiToken(ctx, db) ); -router.get('/admin/perform-timeline-update', requireAuth, async (ctx) => +router.get('/admin/perform-timeline-update', requireAuth(db), async (ctx) => submitTimelineUpdateJobs(ctx, db) ); router.post( '/admin/refresh/:project/:baseline/:change', - requireAuth, + requireAuth(db), koaBody({ urlencoded: true }), deleteCachedReport ); diff --git a/tests/backend/db/auth-db.test.ts b/tests/backend/db/auth-db.test.ts index dac86096..70aae87e 100644 --- a/tests/backend/db/auth-db.test.ts +++ b/tests/backend/db/auth-db.test.ts @@ -19,6 +19,17 @@ import { getUserByUsername } from '../../../src/backend/auth/auth-db.js'; +import { + addGroupToProject, + addUserToGroup, + createGroup, + deleteGroup, + getGroupById, + listGroupMembers, + listGroups, + removeUserFromGroup +} from '../../../src/backend/admin/group-db.js'; + describe('appuser table operations', () => { let db: TestDatabase; @@ -227,6 +238,303 @@ describe('ProjectMembership table operations', () => { }); }); +describe('UserGroup table operations', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('auth_groups'); + }); + + afterAll(async () => { + return db.close(); + }); + + afterEach(async () => { + return db.rollback(); + }); + + it('should create a group and return all fields', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Team Alpha', 'A test team', owner.id); + + expect(group.id).toBeGreaterThan(0); + expect(group.name).toEqual('Team Alpha'); + expect(group.description).toEqual('A test team'); + expect(group.createdBy).toEqual(owner.id); + expect(group.memberCount).toEqual(0); + }); + + it('should support a null description', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'No Description', null, owner.id); + + expect(group.description).toBeNull(); + }); + + it('should return an empty list when no groups exist', async () => { + const groups = await listGroups(db); + expect(groups).toHaveLength(0); + }); + + it('should list all groups ordered by name', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + await createGroup(db, 'Zebra Team', null, owner.id); + await createGroup(db, 'Alpha Team', null, owner.id); + + const groups = await listGroups(db); + + expect(groups).toHaveLength(2); + expect(groups[0].name).toEqual('Alpha Team'); + expect(groups[1].name).toEqual('Zebra Team'); + }); + + it('should include the correct member count in listGroups', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); + const group = await createGroup(db, 'Counted Team', null, owner.id); + + await addUserToGroup(db, group.id, owner.id); + await addUserToGroup(db, group.id, bob.id); + + const groups = await listGroups(db); + expect(groups[0].memberCount).toEqual(2); + }); + + it('should return null for getGroupById with an unknown id', async () => { + const result = await getGroupById(db, 99999); + expect(result).toBeNull(); + }); + + it('should return a group by id', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const created = await createGroup(db, 'Findable', 'desc', owner.id); + + const found = await getGroupById(db, created.id); + + expect(found).not.toBeNull(); + expect(found!.name).toEqual('Findable'); + expect(found!.description).toEqual('desc'); + }); + + it('should delete a group and return true', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Temporary', null, owner.id); + + const deleted = await deleteGroup(db, group.id); + + expect(deleted).toEqual(true); + expect(await getGroupById(db, group.id)).toBeNull(); + }); + + it('should return false when deleting a non-existent group', async () => { + const deleted = await deleteGroup(db, 99999); + expect(deleted).toEqual(false); + }); + + it('should reject a duplicate group name', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + await createGroup(db, 'Duplicate', null, owner.id); + + await expect( + createGroup(db, 'Duplicate', null, owner.id) + ).rejects.toThrow(); + }); +}); + +describe('UserGroupMembership table operations', () => { + let db: TestDatabase; + + beforeAll(async () => { + db = await createAndInitializeDB('auth_group_membership'); + }); + + afterAll(async () => { + return db.close(); + }); + + afterEach(async () => { + return db.rollback(); + }); + + async function createTestProject(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 add a user to a group and list them as a member', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Team', null, owner.id); + + await addUserToGroup(db, group.id, owner.id); + + const members = await listGroupMembers(db, group.id); + expect(members).toHaveLength(1); + expect(members[0].userId).toEqual(owner.id); + expect(members[0].username).toEqual('alice'); + expect(members[0].email).toEqual('alice@example.com'); + }); + + it('should list group members ordered by username', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); + const zoe = await createUser(db, 'zoe', 'zoe@example.com', 'h'); + const group = await createGroup(db, 'Sorted Team', null, alice.id); + + await addUserToGroup(db, group.id, zoe.id); + await addUserToGroup(db, group.id, alice.id); + await addUserToGroup(db, group.id, bob.id); + + const members = await listGroupMembers(db, group.id); + expect(members.map((m) => m.username)).toEqual(['alice', 'bob', 'zoe']); + }); + + it('should return an empty list for a group with no members', async () => { + const owner = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Empty Team', null, owner.id); + + const members = await listGroupMembers(db, group.id); + expect(members).toHaveLength(0); + }); + + it('should remove a user from a group', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + + await addUserToGroup(db, group.id, alice.id); + await addUserToGroup(db, group.id, bob.id); + + const removed = await removeUserFromGroup(db, group.id, alice.id); + + expect(removed).toEqual(true); + const members = await listGroupMembers(db, group.id); + expect(members).toHaveLength(1); + expect(members[0].username).toEqual('bob'); + }); + + it('should return false when removing a user who is not a group member', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + + const removed = await removeUserFromGroup(db, group.id, alice.id); + expect(removed).toEqual(false); + }); + + it('should reject adding the same user to a group twice', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + + await addUserToGroup(db, group.id, alice.id); + + await expect(addUserToGroup(db, group.id, alice.id)).rejects.toThrow(); + }); + + it('should cascade-delete memberships when the user is deleted', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + await addUserToGroup(db, group.id, alice.id); + + await db.query({ + text: `DELETE FROM AppUser WHERE id = $1`, + values: [alice.id] + }); + + const members = await listGroupMembers(db, group.id); + expect(members).toHaveLength(0); + }); + + it('should cascade-delete all memberships when the group is deleted', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + await addUserToGroup(db, group.id, alice.id); + await addUserToGroup(db, group.id, bob.id); + + await deleteGroup(db, group.id); + + const result = await db.query({ + text: `SELECT * FROM UserGroupMembership WHERE groupId = $1`, + values: [group.id] + }); + expect(result.rowCount).toEqual(0); + }); + + it('should add all group members to a project', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + await addUserToGroup(db, group.id, alice.id); + await addUserToGroup(db, group.id, bob.id); + const projectId = await createTestProject('Project X'); + + const added = await addGroupToProject(db, projectId, group.id, 'view'); + + expect(added).toEqual(2); + const result = await db.query<{ userid: number; role: string }>({ + text: `SELECT userId, role FROM ProjectMembership WHERE projectId = $1 + ORDER BY userId`, + values: [projectId] + }); + expect(result.rowCount).toEqual(2); + expect(result.rows.map((r) => r.role)).toEqual(['view', 'view']); + }); + + it('should assign the specified role when adding a group to a project', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + await addUserToGroup(db, group.id, alice.id); + const projectId = await createTestProject('Project Y'); + + await addGroupToProject(db, projectId, group.id, 'edit'); + + const result = await db.query<{ role: string }>({ + text: `SELECT role FROM ProjectMembership WHERE projectId = $1 AND userId = $2`, + values: [projectId, alice.id] + }); + expect(result.rows[0].role).toEqual('edit'); + }); + + it('should return zero when adding an empty group to a project', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const group = await createGroup(db, 'Empty Team', null, alice.id); + const projectId = await createTestProject('Project Z'); + + const added = await addGroupToProject(db, projectId, group.id, 'view'); + + expect(added).toEqual(0); + }); + + it('should skip members already in the project (ON CONFLICT DO NOTHING)', async () => { + const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); + const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); + const group = await createGroup(db, 'Team', null, alice.id); + await addUserToGroup(db, group.id, alice.id); + await addUserToGroup(db, group.id, bob.id); + const projectId = await createTestProject('Project W'); + + await db.query({ + text: `INSERT INTO ProjectMembership (userId, projectId, role) + VALUES ($1, $2, 'owner')`, + values: [alice.id, projectId] + }); + + const added = await addGroupToProject(db, projectId, group.id, 'view'); + + expect(added).toEqual(1); + const result = await db.query<{ userid: number; role: string }>({ + text: `SELECT userId, role FROM ProjectMembership + WHERE projectId = $1 ORDER BY userId`, + values: [projectId] + }); + expect(result.rowCount).toEqual(2); + const aliceRow = result.rows.find((r) => r.userid === alice.id); + expect(aliceRow!.role).toEqual('owner'); + }); +}); + afterAll(async () => { return closeMainDb(); }); From 91712fdeedf0313c071b098e2390d1bcb8752055 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Sun, 21 Jun 2026 18:55:30 +0200 Subject: [PATCH 48/54] Introduce `withSystemContext` for bypassing RLS in privileged operations. requireAuth now establishes the per-user RLS transaction for the whole request, so route handlers no longer need to remember to wrap queries in db.withUserContext themselves. --- src/backend/admin/admin-db.ts | 40 ++++++----- src/backend/admin/operations.ts | 12 ++-- src/backend/auth/auth-middleware.ts | 59 ++++++++++------ src/backend/auth/auth-routes.ts | 100 +++++++++++++++------------ src/backend/compare/compare.ts | 39 +++++------ src/backend/compare/report.ts | 12 ++-- src/backend/db/database-with-pool.ts | 19 ++++- src/backend/db/db.ts | 21 ++++-- src/backend/github/github.ts | 6 +- src/backend/main/main.ts | 15 ++-- src/backend/perf-tracker.ts | 18 +++-- src/backend/project/data-export.ts | 4 +- src/backend/project/project.ts | 28 ++++---- src/backend/rebench/results.ts | 91 ++++++++++++++---------- src/backend/timeline/timeline.ts | 23 ++---- tests/backend/db/db-testing.ts | 27 +++++--- tests/backend/db/db.test.ts | 4 ++ 17 files changed, 289 insertions(+), 229 deletions(-) diff --git a/src/backend/admin/admin-db.ts b/src/backend/admin/admin-db.ts index 5bf5bf18..a96ef3b4 100644 --- a/src/backend/admin/admin-db.ts +++ b/src/backend/admin/admin-db.ts @@ -145,12 +145,6 @@ export interface CreatedProject { slug: string; } -/** - * Inserts the Project and its initial owner ProjectMembership in one - * statement. Must be called outside withUserContext so it runs as the pool - * (superuser) connection — RLS on Project would otherwise reject the INSERT - * because no membership exists for the project yet. - */ export async function generateApiTokenForUser( db: Database, userId: number @@ -207,6 +201,12 @@ export async function getUserRoleForProjectByName( return result.rows[0]?.role ?? null; } +/** + * Inserts the Project and its initial owner ProjectMembership in one + * statement. Runs via withSystemContext so it executes as the pool + * (superuser) connection — RLS on Project would otherwise reject the INSERT + * because no membership exists for the project yet. + */ export async function createProjectWithOwner( db: Database, name: string, @@ -214,18 +214,20 @@ export async function createProjectWithOwner( description: string | null, ownerUserId: number ): Promise { - const result = await db.query({ - name: 'admin_createProjectWithOwner', - text: `WITH new_project AS ( - INSERT INTO Project (name, slug, description) - VALUES ($1, $2, $3) - RETURNING id, name, slug - ), new_membership AS ( - INSERT INTO ProjectMembership (userId, projectId, role) - SELECT $4, id, 'owner' FROM new_project - ) - SELECT id, name, slug FROM new_project`, - values: [name, slug, description, ownerUserId] - }); + const result = await db.withSystemContext(() => + db.query({ + name: 'admin_createProjectWithOwner', + text: `WITH new_project AS ( + INSERT INTO Project (name, slug, description) + VALUES ($1, $2, $3) + RETURNING id, name, slug + ), new_membership AS ( + INSERT INTO ProjectMembership (userId, projectId, role) + SELECT $4, id, 'owner' FROM new_project + ) + SELECT id, name, slug FROM new_project`, + values: [name, slug, description, ownerUserId] + }) + ); return result.rows[0]; } diff --git a/src/backend/admin/operations.ts b/src/backend/admin/operations.ts index fb193bb6..8bb4f797 100644 --- a/src/backend/admin/operations.ts +++ b/src/backend/admin/operations.ts @@ -6,10 +6,14 @@ export async function submitTimelineUpdateJobs( ctx: ParameterizedContext, db: Database ): Promise { - db.getTimelineUpdater() - ?.submitUpdateJobs() - .then((n) => n) - .catch((e) => e); + // Detached background job that outlives this request's RLS-scoped + // connection, so it needs its own independent context. + const updater = db.getTimelineUpdater(); + if (updater) { + db.withSystemContext(() => updater.submitUpdateJobs()) + .then((n) => n) + .catch((e) => e); + } ctx.body = 'update process started'; ctx.type = 'text'; ctx.status = 200; diff --git a/src/backend/auth/auth-middleware.ts b/src/backend/auth/auth-middleware.ts index a555a1d7..4941cda6 100644 --- a/src/backend/auth/auth-middleware.ts +++ b/src/backend/auth/auth-middleware.ts @@ -2,6 +2,7 @@ import { Next, ParameterizedContext } from 'koa'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../util.js'; +import type { Database } from '../db/db.js'; export interface AuthState { userId: number; @@ -24,33 +25,45 @@ function redirectOrUnauthorized( } } -export async function requireAuth( - ctx: ParameterizedContext, - next: Next -): Promise { - let token: string | undefined; +export function requireAuth( + db: Database +): (ctx: ParameterizedContext, next: Next) => Promise { + return async function requireAuthMiddleware( + ctx: ParameterizedContext, + next: Next + ): Promise { + 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); + 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; - } + if (!token) { + redirectOrUnauthorized(ctx); + return; + } + + let payload: jwt.JwtPayload; + try { + payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; + } catch { + redirectOrUnauthorized(ctx, true); + return; + } - try { - 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 { - redirectOrUnauthorized(ctx, true); - } + + // Establishes the RLS-scoped transaction for the whole request, so + // every route behind requireAuth(db) is automatically scoped to the + // authenticated user — there is nothing left for a route handler to + // remember to wrap. + await db.withUserContext(ctx.state.userId, next); + }; } diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts index adf8b119..b3db7412 100644 --- a/src/backend/auth/auth-routes.ts +++ b/src/backend/auth/auth-routes.ts @@ -53,28 +53,32 @@ export async function register( return; } - const existingByUsername = await getUserByUsername(db, username); - if (existingByUsername) { - ctx.status = 409; + // Pre-authentication: no JWT/ctx.state.userId exists yet, and AppUser + // isn't RLS-protected, so this runs via the explicit system-context path. + await db.withSystemContext(async () => { + 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 = { 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 }; + ctx.body = { userId: user.id, username: user.username }; + }); } export async function login( @@ -91,30 +95,34 @@ export async function login( return; } - const user = await getUserByUsername(db, username); - - if (!user || !user.isActive) { - ctx.status = 401; + // Pre-authentication: no JWT/ctx.state.userId exists yet, and AppUser + // isn't RLS-protected, so this runs via the explicit system-context path. + await db.withSystemContext(async () => { + const user = await getUserByUsername(db, username); + + if (!user || !user.isActive) { + ctx.status = 401; + ctx.type = 'json'; + ctx.body = { error: 'Invalid credentials' }; + return; + } + + const valid = await bcrypt.compare(password, user.passwordHash); + 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 = { error: 'Invalid credentials' }; - return; - } - - const valid = await bcrypt.compare(password, user.passwordHash); - 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 }; + ctx.body = { token }; + }); } diff --git a/src/backend/compare/compare.ts b/src/backend/compare/compare.ts index 877b4236..c042cbdb 100644 --- a/src/backend/compare/compare.ts +++ b/src/backend/compare/compare.ts @@ -31,9 +31,7 @@ export async function getProfileAsJson( const start = startRequest(); - ctx.body = await db.withUserContext(ctx.state.userId, () => - getProfile(runId, ctx.params.commitId, db) - ); + ctx.body = await getProfile(runId, ctx.params.commitId, db); if (ctx.body === undefined) { ctx.status = 404; ctx.body = {}; @@ -87,14 +85,12 @@ export async function getMeasurementsAsJson( const start = startRequest(); - ctx.body = await db.withUserContext(ctx.state.userId, () => - getMeasurements( - ctx.params.projectSlug, - runId, - ctx.params.baseId, - ctx.params.changeId, - db - ) + ctx.body = await getMeasurements( + ctx.params.projectSlug, + runId, + ctx.params.baseId, + ctx.params.changeId, + db ); completeRequestAndHandlePromise(start, db, 'get-measurements'); @@ -178,8 +174,9 @@ export async function getTimelineDataAsJson( db: Database ): Promise { const timelineRequest = (ctx.request.body); - const result = await db.withUserContext(ctx.state.userId, () => - db.getTimelineData(ctx.params.projectName, timelineRequest) + const result = await db.getTimelineData( + ctx.params.projectName, + timelineRequest ); if (result === null) { ctx.body = { error: 'Requested data was not found' }; @@ -198,9 +195,7 @@ export async function redirectToNewCompareUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.withUserContext(ctx.state.userId, () => - db.getProjectByName(ctx.params.project) - ); + const project = await db.getProjectByName(ctx.params.project); if (project) { ctx.redirect( `/${project.slug}/compare/${ctx.params.baseline}..${ctx.params.change}` @@ -216,13 +211,11 @@ export async function renderComparePage( ): Promise { const start = startRequest(); - const data = await db.withUserContext(ctx.state.userId, () => - renderCompare( - ctx.params.baseline, - ctx.params.change, - ctx.params.projectSlug, - db - ) + const data = await renderCompare( + ctx.params.baseline, + ctx.params.change, + ctx.params.projectSlug, + db ); ctx.body = data.content; ctx.type = 'html'; diff --git a/src/backend/compare/report.ts b/src/backend/compare/report.ts index 55961fef..704c14d4 100644 --- a/src/backend/compare/report.ts +++ b/src/backend/compare/report.ts @@ -137,12 +137,12 @@ export async function renderCompare( }; reportGeneration.set(reportId, reportStatus); - const p = renderCompareViewToFile( - base, - change, - projectSlug, - reportFile, - db + // Detached background job: the request that triggered it returns + // immediately (inProgress: true) and its RLS-scoped connection may + // already be released by the time this runs, so it needs its own + // independent context rather than inheriting the caller's. + const p = db.withSystemContext(() => + renderCompareViewToFile(base, change, projectSlug, reportFile, db) ); const pp = p .then(() => { diff --git a/src/backend/db/database-with-pool.ts b/src/backend/db/database-with-pool.ts index 98dbba4c..cb0a8006 100644 --- a/src/backend/db/database-with-pool.ts +++ b/src/backend/db/database-with-pool.ts @@ -32,7 +32,11 @@ export class DatabaseWithPool extends Database { if (context) { return context.client.query(queryConfig); } - return this.pool.query(queryConfig); + throw new Error( + 'Database query attempted without an established context. ' + + 'Use requireAuth (user-facing routes) or db.withSystemContext() ' + + '(explicitly privileged, audited operations).' + ); } public async withUserContext( @@ -44,7 +48,9 @@ export class DatabaseWithPool extends Database { await client.query('BEGIN'); await client.query('SET LOCAL ROLE rdb_app'); if (userId !== null) { - await client.query(`SET LOCAL app.currentUserId = '${userId}'`); + await client.query(`SELECT set_config('app.currentUserId', $1, true)`, [ + String(userId) + ]); } const result = await userContextStorage.run({ client }, fn); await client.query('COMMIT'); @@ -57,6 +63,15 @@ export class DatabaseWithPool extends Database { } } + public async withSystemContext(fn: () => Promise): Promise { + const client = await this.pool.connect(); + try { + return await userContextStorage.run({ client }, fn); + } finally { + client.release(); + } + } + public async close(): Promise { await super.close(); this.statsValid.invalidateAndNew(); diff --git a/src/backend/db/db.ts b/src/backend/db/db.ts index 5b05f610..affd5cbd 100644 --- a/src/backend/db/db.ts +++ b/src/backend/db/db.ts @@ -178,6 +178,13 @@ export abstract class Database { fn: () => Promise ): Promise; + /** + * Run `fn` with the pool's superuser privileges, deliberately bypassing + * RLS. Reserved for the few legitimate cases that must run before/outside + * any per-user context (auth bootstrap, M2M ingestion, project creation). + */ + public abstract withSystemContext(fn: () => Promise): Promise; + public clearCache(): void { this.runs.clear(); this.sources.clear(); @@ -199,10 +206,12 @@ export abstract class Database { } public async initializeDatabase(): Promise { - if (await this.needsTables()) { - const schema = loadScheme(); - await this.query({ text: schema }); - } + await this.withSystemContext(async () => { + if (await this.needsTables()) { + const schema = loadScheme(); + await this.query({ text: schema }); + } + }); } public async close(): Promise { @@ -1204,7 +1213,9 @@ export abstract class Database { this.timelineUpdater && !suppressTimelineGeneration ) { - this.timelineUpdater.submitUpdateJobs(); + // Detached background job that outlives this call's connection, so + // it needs its own independent context. + this.withSystemContext(() => this.timelineUpdater!.submitUpdateJobs()); } return [recordedMeasurements, recordedProfiles]; diff --git a/src/backend/github/github.ts b/src/backend/github/github.ts index 48bb7efd..f5a3c811 100644 --- a/src/backend/github/github.ts +++ b/src/backend/github/github.ts @@ -27,7 +27,11 @@ export async function handleReBenchCompletion( } try { - await reportCompletion(dbConfig, db, github, data); + // Webhook-style M2M endpoint with no requireAuth/JWT, so it runs via + // the explicit system-context path rather than a per-user RLS scope. + await db.withSystemContext(() => + reportCompletion(dbConfig, db, github, data) + ); log.debug( `/rebenchdb/completion: ${data.projectName}` + `${data.experimentName} was completed` diff --git a/src/backend/main/main.ts b/src/backend/main/main.ts index e12d420a..606fc696 100644 --- a/src/backend/main/main.ts +++ b/src/backend/main/main.ts @@ -25,9 +25,7 @@ export async function renderMainPage( ctx: ParameterizedContext, db: Database ): Promise { - const projects = await db.withUserContext(ctx.state.userId, () => - db.getAllProjects() - ); + const projects = await db.getAllProjects(); ctx.body = mainTpl({ rebenchVersion, projects, @@ -49,9 +47,7 @@ export async function getLast100MeasurementsAsJson( } const start = startRequest(); - ctx.body = await db.withUserContext(ctx.state.userId, () => - getLast100Measurements(projectId, db) - ); + ctx.body = await getLast100Measurements(projectId, db); completeRequestAndHandlePromise(start, db, 'get-results'); } @@ -129,8 +125,7 @@ export async function getSiteStatsAsJson( ctx: ParameterizedContext, db: Database ): Promise { - ctx.body = await db.withUserContext(ctx.state.userId, () => - getStatistics(db)); + ctx.body = await getStatistics(db); ctx.type = 'application/json'; } @@ -188,9 +183,7 @@ export async function getChangesAsJson( return; } - ctx.body = await db.withUserContext(ctx.state.userId, () => - getChanges(projectId, db) - ); + ctx.body = await getChanges(projectId, db); } export async function getChanges( diff --git a/src/backend/perf-tracker.ts b/src/backend/perf-tracker.ts index 243c26ba..82b3983a 100644 --- a/src/backend/perf-tracker.ts +++ b/src/backend/perf-tracker.ts @@ -44,7 +44,9 @@ export async function initPerfTracker(db: Database): Promise { const benchmarkNames = Object.keys(descriptions); const initializationData = constructInitialization(benchmarkNames); - const { metadata, runs } = await db.recordMetaDataAndRuns(initializationData); + const { metadata, runs } = await db.withSystemContext(() => + db.recordMetaDataAndRuns(initializationData) + ); const criterion = [...metadata.criteria.values()][0]; @@ -141,11 +143,15 @@ export async function _completeRequest( const details = trialDetails[request]; - return db.recordAdditionalMeasurementValue( - details.run, - details.trial, - details.criterionId, - time + // Fire-and-forget from the caller's perspective, so it must not depend on + // the request's own (possibly already-closed) RLS transaction/connection. + return db.withSystemContext(() => + db.recordAdditionalMeasurementValue( + details.run, + details.trial, + details.criterionId, + time + ) ); } diff --git a/src/backend/project/data-export.ts b/src/backend/project/data-export.ts index 928f74bf..522e13f7 100644 --- a/src/backend/project/data-export.ts +++ b/src/backend/project/data-export.ts @@ -107,9 +107,7 @@ export async function getAvailableDataAsJson( return; } - ctx.body = await db.withUserContext(ctx.state.userId, () => - getDataOverview(projectId, db) - ); + ctx.body = await getDataOverview(projectId, db); } export async function getDataOverview( diff --git a/src/backend/project/project.ts b/src/backend/project/project.ts index 489ba3fb..1ff239bb 100644 --- a/src/backend/project/project.ts +++ b/src/backend/project/project.ts @@ -20,9 +20,7 @@ export async function renderProjectPage( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.withUserContext(ctx.state.userId, () => - db.getProjectBySlug(ctx.params.projectSlug) - ); + const project = await db.getProjectBySlug(ctx.params.projectSlug); if (project) { ctx.body = projectHtml({ ...project, rebenchVersion }); ctx.type = 'html'; @@ -35,8 +33,9 @@ export async function getSourceAsJson( ctx: ParameterizedContext, db: Database ): Promise { - const result = await db.withUserContext(ctx.state.userId, () => - db.getSourceById(ctx.params.projectSlug, ctx.params.sourceId) + const result = await db.getSourceById( + ctx.params.projectSlug, + ctx.params.sourceId ); if (result !== null) { @@ -58,9 +57,7 @@ export async function redirectToNewProjectDataUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.withUserContext(ctx.state.userId, () => - db.getProject(Number(ctx.params.projectId)) - ); + const project = await db.getProject(Number(ctx.params.projectId)); if (project) { ctx.redirect(`/${project.slug}/data`); } else { @@ -78,9 +75,7 @@ export async function renderProjectDataPage( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.withUserContext(ctx.state.userId, () => - db.getProjectBySlug(ctx.params.projectSlug) - ); + const project = await db.getProjectBySlug(ctx.params.projectSlug); if (project) { ctx.body = projectDataTpl({ project, rebenchVersion }); ctx.type = 'html'; @@ -96,9 +91,7 @@ export async function redirectToNewProjectDataExportUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.withUserContext(ctx.state.userId, () => - db.getProjectByExpId(Number(ctx.params.expId)) - ); + const project = await db.getProjectByExpId(Number(ctx.params.expId)); if (project) { ctx.redirect(`/${project.slug}/data/${ctx.params.expId}`); } else { @@ -121,8 +114,11 @@ export async function renderDataExport( : 'csv'; const expId = ctx.params.expIdAndExtension.replace(`.${format}.gz`, ''); - const data = await db.withUserContext(ctx.state.userId, () => - getExpData(ctx.params.projectSlug, Number(expId), db, format) + const data = await getExpData( + ctx.params.projectSlug, + Number(expId), + db, + format ); if (data.preparingData) { diff --git a/src/backend/rebench/results.ts b/src/backend/rebench/results.ts index cca92389..57b18ef4 100644 --- a/src/backend/rebench/results.ts +++ b/src/backend/rebench/results.ts @@ -93,45 +93,60 @@ export async function acceptResultData( return; } - const user = await getUserByApiToken(db, token); - if (user === null) { - ctx.body = 'Invalid API token.'; - ctx.status = 401; - return; - } - - const role = await getUserRoleForProjectByName(db, user.id, data.projectName); - if (role !== 'edit' && role !== 'owner') { - // eslint-disable-next-line max-len - ctx.body = `User does not have write permission on project "${data.projectName}".`; - ctx.status = 403; - return; - } - - try { - const recRunsPromise = db.recordMetaDataAndRuns(data); - log.info(`/rebenchdb/results: Content-Length=${ctx.request.length}`); - const recordedRuns = await recRunsPromise; - db.recordAllData(data) - .then(([recMs, recPs]) => - log.info( - // eslint-disable-next-line max-len - `/rebenchdb/results: stored ${recMs} sets of measurements, ${recPs} profiles` + // M2M endpoint: authenticated by API token rather than JWT/requireAuth, + // with its own explicit role check above instead of relying on RLS — so + // it deliberately runs as the pool's privileged connection. + await db.withSystemContext(async () => { + const user = await getUserByApiToken(db, token); + if (user === null) { + ctx.body = 'Invalid API token.'; + ctx.status = 401; + return; + } + + const role = await getUserRoleForProjectByName( + db, + user.id, + data.projectName + ); + if (role !== 'edit' && role !== 'owner') { + // eslint-disable-next-line max-len + ctx.body = `User does not have write permission on project "${data.projectName}".`; + ctx.status = 403; + return; + } + + try { + const recRunsPromise = db.recordMetaDataAndRuns(data); + log.info(`/rebenchdb/results: Content-Length=${ctx.request.length}`); + const recordedRuns = await recRunsPromise; + + // Runs in its own withSystemContext so its connection stays alive for + // the full background duration, independent of this request's scope. + db.withSystemContext(() => db.recordAllData(data)) + .then(([recMs, recPs]) => + log.info( + // eslint-disable-next-line max-len + `/rebenchdb/results: stored ${recMs} sets of measurements, ${recPs} profiles` + ) ) - ) - .catch((e) => { - log.error('/rebenchdb/results failed to store measurements:', e.stack); - }); - - ctx.body = - `Meta data for ${recordedRuns} stored.` + - ' Storing of measurements is ongoing'; - ctx.status = 201; - } catch (e: any) { - ctx.status = 500; - ctx.body = `${e.stack}`; - log.error(e, e.stack); - } + .catch((e) => { + log.error( + '/rebenchdb/results failed to store measurements:', + e.stack + ); + }); + + ctx.body = + `Meta data for ${recordedRuns} stored.` + + ' Storing of measurements is ongoing'; + ctx.status = 201; + } catch (e: any) { + ctx.status = 500; + ctx.body = `${e.stack}`; + log.error(e, e.stack); + } + }); completeRequestAndHandlePromise(start, db, 'put-results'); } diff --git a/src/backend/timeline/timeline.ts b/src/backend/timeline/timeline.ts index c9556042..72b76e62 100644 --- a/src/backend/timeline/timeline.ts +++ b/src/backend/timeline/timeline.ts @@ -33,9 +33,7 @@ export async function getTimelineAsJson( return; } - ctx.body = await db.withUserContext(ctx.state.userId, () => - db.getTimelineForRun(projectId, runId) - ); + ctx.body = await db.getTimelineForRun(projectId, runId); if (ctx.body === null) { ctx.status = 500; } @@ -48,9 +46,7 @@ export async function redirectToNewTimelineUrl( ctx: ParameterizedContext, db: Database ): Promise { - const project = await db.withUserContext(ctx.state.userId, () => - db.getProject(Number(ctx.params.projectId)) - ); + const project = await db.getProject(Number(ctx.params.projectId)); if (project) { ctx.redirect(`/${project.slug}/timeline`); } else { @@ -62,17 +58,10 @@ export async function renderTimeline( ctx: ParameterizedContext, db: Database ): Promise { - 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; - } - ); + const project = await db.getProjectBySlug(ctx.params.projectSlug); + const benchmarks = project + ? await getLatestBenchmarksForTimelineView(project.id, db) + : null; if (project) { ctx.body = timelineTpl({ rebenchVersion, project, benchmarks }); diff --git a/tests/backend/db/db-testing.ts b/tests/backend/db/db-testing.ts index e480c74f..291869ab 100644 --- a/tests/backend/db/db-testing.ts +++ b/tests/backend/db/db-testing.ts @@ -92,12 +92,17 @@ export class TestDatabase extends Database { await this.query({ text: 'SET LOCAL ROLE rdb_app' }); if (userId !== null) { await this.query({ - text: `SET LOCAL app.currentUserId = '${userId}'` + text: `SELECT set_config('app.currentUserId', $1, true)`, + values: [String(userId)] }); } return fn(); } + public async withSystemContext(fn: () => Promise): Promise { + return fn(); + } + public async rollback(): Promise { this.clearCache(); @@ -136,9 +141,11 @@ export class TestDatabase extends Database { private async release(): Promise { const mainDB = getMainDB(); - await mainDB.query({ - text: `DROP DATABASE IF EXISTS ${this.dbConfig.database}` - }); + await mainDB.withSystemContext(() => + mainDB.query({ + text: `DROP DATABASE IF EXISTS ${this.dbConfig.database}` + }) + ); } } @@ -181,11 +188,13 @@ export async function createDB( const cfg = getConfig(); const db = getMainDB(); const dbNameForSuite = `${cfg.database}_${testSuite}`; - await db.query({ - text: `DROP DATABASE IF EXISTS ${dbNameForSuite}` - }); - await db.query({ - text: `CREATE DATABASE ${dbNameForSuite}` + await db.withSystemContext(async () => { + await db.query({ + text: `DROP DATABASE IF EXISTS ${dbNameForSuite}` + }); + await db.query({ + text: `CREATE DATABASE ${dbNameForSuite}` + }); }); cfg.database = dbNameForSuite; diff --git a/tests/backend/db/db.test.ts b/tests/backend/db/db.test.ts index 27d9a110..49699e0c 100644 --- a/tests/backend/db/db.test.ts +++ b/tests/backend/db/db.test.ts @@ -285,6 +285,10 @@ describe('createValueBatchForInsertion()', () => { ): Promise { return fn(); } + + public async withSystemContext(fn: () => Promise): Promise { + return fn(); + } } const run1 = { id: 1 } as Run; From 8c754565aab6cdceb340e5313049b47d9d207342 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 22 Jun 2026 17:22:51 +0200 Subject: [PATCH 49/54] Changed check for password length to 72. --- src/backend/auth/auth-routes.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts index b3db7412..70237633 100644 --- a/src/backend/auth/auth-routes.ts +++ b/src/backend/auth/auth-routes.ts @@ -46,10 +46,14 @@ export async function register( return; } - if (typeof password !== 'string' || password.length < 8) { + if ( + typeof password !== 'string' || + password.length < 8 || + password.length > 72 + ) { ctx.status = 400; ctx.type = 'json'; - ctx.body = { error: 'password must be at least 8 characters' }; + ctx.body = { error: 'password must be between 8 and 72 characters' }; return; } From ac7f979f3069c6f33ed221f9a112de37c51805c1 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 22 Jun 2026 17:23:30 +0200 Subject: [PATCH 50/54] Wrap recordTimeline in withSystemContext --- src/backend/timeline/timeline-calc.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/backend/timeline/timeline-calc.ts b/src/backend/timeline/timeline-calc.ts index ae4db71d..2befd684 100644 --- a/src/backend/timeline/timeline-calc.ts +++ b/src/backend/timeline/timeline-calc.ts @@ -90,7 +90,11 @@ export class TimelineWorker { } const results = message; - this.updater.receiveResults(results); + try { + await this.updater.receiveResults(results); + } catch (e: any) { + log.error('Error while recording timeline results', e.stack); + } } public async shutdown(): Promise { @@ -155,14 +159,19 @@ export class BatchingTimelineUpdater implements ResultReceiver { return; } - for (const result of r.results) { - await this.db.recordTimeline( - result.runId, - result.trialId, - result.criterion, - result.stats - ); - } + // Runs in its own withSystemContext because this is invoked from the + // timeline worker's message handler, a background context that never + // goes through requireAuth. + await this.db.withSystemContext(async () => { + for (const result of r.results) { + await this.db!.recordTimeline( + result.runId, + result.trialId, + result.criterion, + result.stats + ); + } + }); const req = this.activeRequests.get(r.requestId); if (!req) { From 9bdc7088e4fd0ea9f124ddee2157c6a8bfe8c05d Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 22 Jun 2026 17:25:06 +0200 Subject: [PATCH 51/54] Extend JWT token expiration from 24h to 72h in auth routes. --- src/backend/auth/auth-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts index 70237633..a8e2a2f1 100644 --- a/src/backend/auth/auth-routes.ts +++ b/src/backend/auth/auth-routes.ts @@ -122,7 +122,7 @@ export async function login( const token = jwt.sign( { sub: user.id, username: user.username }, JWT_SECRET, - { expiresIn: '24h' } + { expiresIn: '72h' } ); ctx.status = 200; From 69dbab0fdb47f25f6feac93acc18ca5a44bd8936 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 22 Jun 2026 17:42:47 +0200 Subject: [PATCH 52/54] Style changes --- src/backend/db/db.sql | 104 +++++++-------- .../db/schema-updates/migration.014.sql | 120 ++++++++---------- 2 files changed, 99 insertions(+), 125 deletions(-) diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index 154c14cd..72c06067 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -200,54 +200,49 @@ CREATE TABLE Timeline ( foreign key (criterion) references Criterion (id) ); --- ============================================================ --- 1. ENUM type for membership roles --- ============================================================ +-- ENUM type for membership roles + CREATE TYPE projectRole AS ENUM ('view', 'edit', 'owner'); --- ============================================================ --- 2. Application user table --- ============================================================ +-- Application user table + CREATE TABLE AppUser ( - id SERIAL PRIMARY KEY, - username VARCHAR(100) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - "passwordHash" VARCHAR(255) NOT NULL, --Double quotes to be consistent with camelCase - "apiToken" VARCHAR(64) UNIQUE NULL, - "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), - "isActive" BOOLEAN NOT NULL DEFAULT true + id SERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + "passwordHash" VARCHAR(255) NOT NULL, -- Double quotes to be consistent with camelCase + "apiToken" VARCHAR(64) UNIQUE NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "isActive" BOOLEAN NOT NULL DEFAULT true ); --- ============================================================ --- 3. Project membership (user <-> project with role) --- ============================================================ +-- 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 projectRole NOT NULL DEFAULT 'view', - PRIMARY KEY (userId, projectId) + userId INTEGER NOT NULL REFERENCES AppUser(id) ON DELETE CASCADE, + projectId INTEGER NOT NULL REFERENCES Project(id) ON DELETE CASCADE, + role projectRole 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. --- ============================================================ +-- 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; + 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. --- ============================================================ +-- 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; @@ -266,25 +261,22 @@ 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. --- SECURITY DEFINER so rdb_app can call current_setting. --- ============================================================ +-- Helper function to read the session-local user ID. +-- 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.currentUserId', true), '')::INTEGER; + SELECT NULLIF(current_setting('app.currentUserId', true), '')::INTEGER; $$; --- ============================================================ --- 7. RLS policies +-- RLS policies -- --- All user-facing routes are protected by requireAuth which --- sets app.currentUserId 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. --- ============================================================ +-- All user-facing routes are protected by requireAuth which +-- sets app.currentUserId 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. -- Project: direct membership check CREATE POLICY project_access ON Project @@ -380,18 +372,8 @@ CREATE POLICY profiledata_access ON ProfileData ) ); --- ============================================================ --- 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; +-- User groups (for batch-assigning users to projects) --- ============================================================ --- 9. User groups (for batch-assigning users to projects) --- ============================================================ CREATE TABLE UserGroup ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, @@ -408,6 +390,14 @@ CREATE TABLE UserGroupMembership ( CREATE INDEX usergroupmembership_groupid_idx ON UserGroupMembership (groupId); +-- 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/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql index 5869ab0f..22702091 100644 --- a/src/backend/db/schema-updates/migration.014.sql +++ b/src/backend/db/schema-updates/migration.014.sql @@ -9,32 +9,27 @@ -- 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 --- ============================================================ +-- ENUM type for membership roles + CREATE TYPE projectRole AS ENUM ('view', 'edit', 'owner'); --- ============================================================ --- 2. Application user table (local auth, no SSO) --- ============================================================ +-- Application user table + CREATE TABLE AppUser ( - id SERIAL PRIMARY KEY, - username VARCHAR(100) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - "passwordHash" VARCHAR(255) NOT NULL, + id SERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + "passwordHash" VARCHAR(255) NOT NULL, -- Double quotes to be consistent with camelCase "apiToken" VARCHAR(64) UNIQUE NULL, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), "isActive" BOOLEAN NOT NULL DEFAULT true ); --- ============================================================ --- 3. Project membership (user <-> project with role) --- ============================================================ +-- 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, @@ -44,34 +39,11 @@ CREATE TABLE ProjectMembership ( CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); --- ============================================================ --- 4. User groups (for batch-assigning users to projects) --- Groups are a snapshot mechanism: assigning a group to a --- project bulk-inserts all current group members into --- ProjectMembership. --- ============================================================ -CREATE TABLE UserGroup ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - "createdBy" INTEGER REFERENCES AppUser(id) ON DELETE SET NULL, - "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE UserGroupMembership ( - userId INTEGER NOT NULL REFERENCES AppUser(id) ON DELETE CASCADE, - groupId INTEGER NOT NULL REFERENCES UserGroup(id) ON DELETE CASCADE, - PRIMARY KEY (userId, groupId) -); - -CREATE INDEX usergroupmembership_groupid_idx ON UserGroupMembership (groupId); +-- 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. --- ============================================================ --- 5. 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 @@ -79,11 +51,10 @@ BEGIN END IF; END$$; --- ============================================================ --- 6. 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. --- ============================================================ +-- 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; @@ -102,25 +73,22 @@ ALTER TABLE Timeline FORCE ROW LEVEL SECURITY; ALTER TABLE Source FORCE ROW LEVEL SECURITY; ALTER TABLE ProfileData FORCE ROW LEVEL SECURITY; --- ============================================================ --- 7. Helper function to read the session-local user ID. --- SECURITY DEFINER so rdb_app can call current_setting. --- ============================================================ +-- Helper function to read the session-local user ID. +-- 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.currentUserId', true), '')::INTEGER; $$; --- ============================================================ --- 8. RLS policies +-- RLS policies -- --- All user-facing routes are protected by requireAuth which --- sets app.currentUserId 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. --- ============================================================ +-- All user-facing routes are protected by requireAuth which +-- sets app.currentUserId 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. -- Project: direct membership check CREATE POLICY project_access ON Project @@ -216,18 +184,34 @@ CREATE POLICY profiledata_access ON ProfileData ) ); --- ============================================================ --- 9. 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. --- ============================================================ +-- User groups (for batch-assigning users to projects) + +CREATE TABLE UserGroup ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + "createdBy" INTEGER REFERENCES AppUser(id) ON DELETE SET NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE UserGroupMembership ( + userId INTEGER NOT NULL REFERENCES AppUser(id) ON DELETE CASCADE, + groupId INTEGER NOT NULL REFERENCES UserGroup(id) ON DELETE CASCADE, + PRIMARY KEY (userId, groupId) +); + +CREATE INDEX usergroupmembership_groupid_idx ON UserGroupMembership (groupId); + +-- 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; --- ============================================================ --- 10. Schema version bump --- ============================================================ +-- Schema version bump + INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); COMMIT; From ccee57f271fdbf87843395d644324c997f5a31fa Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 22 Jun 2026 17:52:03 +0200 Subject: [PATCH 53/54] Fixed TimelineUpdater to use withSystemContext --- tests/backend/timeline/timeline-calc.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/backend/timeline/timeline-calc.test.ts b/tests/backend/timeline/timeline-calc.test.ts index f7ab448a..f98b58cf 100644 --- a/tests/backend/timeline/timeline-calc.test.ts +++ b/tests/backend/timeline/timeline-calc.test.ts @@ -82,7 +82,8 @@ describe('TimelineWorker', () => { describe('BatchingTimelineUpdater', () => { const recordTimeline = jest.fn(); - const db = ({ recordTimeline }); + const withSystemContext = jest.fn((fn: () => Promise) => fn()); + const db = ({ recordTimeline, withSystemContext }); beforeEach(() => { recordTimeline.mockClear(); From 556a8f53376de33cce80e8026f301baa74373f39 Mon Sep 17 00:00:00 2001 From: Thomas Bachner Date: Mon, 22 Jun 2026 17:57:01 +0200 Subject: [PATCH 54/54] Fixed Linting --- src/backend/admin/admin-routes.ts | 7 +++++-- src/backend/admin/group-db.ts | 3 ++- src/backend/auth/auth-routes.ts | 4 +++- src/frontend/admin.ts | 9 ++++++--- src/index.ts | 10 ++++++---- tests/backend/db/auth-db.test.ts | 15 ++++++++++----- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/backend/admin/admin-routes.ts b/src/backend/admin/admin-routes.ts index c6a86478..8922ec97 100644 --- a/src/backend/admin/admin-routes.ts +++ b/src/backend/admin/admin-routes.ts @@ -432,7 +432,9 @@ export async function addGroupMember( await addUserToGroup(db, groupId, user.id); ctx.status = 201; ctx.type = 'json'; - ctx.body = { member: { userId: user.id, username: user.username, email: user.email } }; + ctx.body = { + member: { userId: user.id, username: user.username, email: user.email } + }; } catch (e: any) { if (e?.code === '23505') { jsonError(ctx, 409, 'User is already a member of this group'); @@ -470,7 +472,8 @@ export async function assignGroupToProject( if (!(await requireOwner(ctx, db, projectId))) return; const body = ctx.request.body as any; - const groupId = typeof body?.groupId === 'number' ? body.groupId : Number(body?.groupId); + const groupId = + typeof body?.groupId === 'number' ? body.groupId : Number(body?.groupId); if (!Number.isInteger(groupId) || groupId <= 0) { jsonError(ctx, 400, 'groupId is required'); return; diff --git a/src/backend/admin/group-db.ts b/src/backend/admin/group-db.ts index 48472256..adb50fc2 100644 --- a/src/backend/admin/group-db.ts +++ b/src/backend/admin/group-db.ts @@ -26,7 +26,8 @@ export async function createGroup( name: 'group_createGroup', text: `INSERT INTO UserGroup (name, description, "createdBy") VALUES ($1, $2, $3) - RETURNING id, name, description, "createdBy", "createdAt", 0 AS "memberCount"`, + RETURNING id, name, description, "createdBy", "createdAt", + 0 AS "memberCount"`, values: [name, description, createdByUserId] }); return result.rows[0]; diff --git a/src/backend/auth/auth-routes.ts b/src/backend/auth/auth-routes.ts index a8e2a2f1..b6b246aa 100644 --- a/src/backend/auth/auth-routes.ts +++ b/src/backend/auth/auth-routes.ts @@ -9,7 +9,9 @@ import { getUserByUsername } from './auth-db.js'; import { prepareTemplate } from '../templates.js'; -import { BCRYPT_ROUNDS, JWT_SECRET, rebenchVersion, robustPath } from '../util.js'; +import { + BCRYPT_ROUNDS, JWT_SECRET, rebenchVersion, robustPath +} from '../util.js'; const loginTpl = prepareTemplate(robustPath('backend/auth/login.html')); diff --git a/src/frontend/admin.ts b/src/frontend/admin.ts index f0a13d72..1aa93d58 100644 --- a/src/frontend/admin.ts +++ b/src/frontend/admin.ts @@ -707,7 +707,8 @@ function wireAssignGroupToProject(): void { return; } const added: number = data.added ?? 0; - resultEl.textContent = `${added} member${added === 1 ? '' : 's'} added to project.`; + resultEl.textContent = + `${added} member${added === 1 ? '' : 's'} added to project.`; resultEl.className = 'mt-2 alert alert-success'; } catch { showAlert('admin-group-error', 'Network error assigning group.'); @@ -752,11 +753,13 @@ function wireAddGroupToProject(): void { return; } const added: number = data.added ?? 0; - resultEl.textContent = `${added} member${added === 1 ? '' : 's'} added to project.`; + resultEl.textContent = + `${added} member${added === 1 ? '' : 's'} added to project.`; resultEl.className = 'mt-2 alert alert-success'; if (added > 0) selectProject(selectedProjectId); } catch { - showAlert('admin-members-error', 'Network error adding group to project.'); + showAlert( + 'admin-members-error', 'Network error adding group to project.'); } }); } diff --git a/src/index.ts b/src/index.ts index 80384401..976375ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -167,8 +167,9 @@ router.get('/project/:projectId', requireAuth(db), async (ctx) => router.get('/rebenchdb/get-exp-data/:expId', requireAuth(db), async (ctx) => redirectToNewProjectDataExportUrl(ctx, db) ); -router.get('/compare/:project/:baseline/:change', requireAuth(db), async (ctx) => - redirectToNewCompareUrl(ctx, db) +router.get( + '/compare/:project/:baseline/:change', requireAuth(db), async (ctx) => + redirectToNewCompareUrl(ctx, db) ); // todo: rename this to say that this endpoint gets the last 100 measurements @@ -215,8 +216,9 @@ router.get('/admin/api/my-projects', requireAuth(db), async (ctx) => router.post('/admin/api/projects', requireAuth(db), koaBody(), async (ctx) => createProject(ctx, db) ); -router.get('/admin/api/projects/:projectId/members', requireAuth(db), async (ctx) => - getMembers(ctx, db) +router.get( + '/admin/api/projects/:projectId/members', requireAuth(db), async (ctx) => + getMembers(ctx, db) ); router.post( '/admin/api/projects/:projectId/members', diff --git a/tests/backend/db/auth-db.test.ts b/tests/backend/db/auth-db.test.ts index 70aae87e..2596ef58 100644 --- a/tests/backend/db/auth-db.test.ts +++ b/tests/backend/db/auth-db.test.ts @@ -415,7 +415,8 @@ describe('UserGroupMembership table operations', () => { expect(members[0].username).toEqual('bob'); }); - it('should return false when removing a user who is not a group member', async () => { + it('should return false when removing a user who is not a group member', + async () => { const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); const group = await createGroup(db, 'Team', null, alice.id); @@ -446,7 +447,8 @@ describe('UserGroupMembership table operations', () => { expect(members).toHaveLength(0); }); - it('should cascade-delete all memberships when the group is deleted', async () => { + it('should cascade-delete all memberships when the group is deleted', + async () => { const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); const group = await createGroup(db, 'Team', null, alice.id); @@ -482,7 +484,8 @@ describe('UserGroupMembership table operations', () => { expect(result.rows.map((r) => r.role)).toEqual(['view', 'view']); }); - it('should assign the specified role when adding a group to a project', async () => { + it('should assign the specified role when adding a group to a project', + async () => { const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); const group = await createGroup(db, 'Team', null, alice.id); await addUserToGroup(db, group.id, alice.id); @@ -491,7 +494,8 @@ describe('UserGroupMembership table operations', () => { await addGroupToProject(db, projectId, group.id, 'edit'); const result = await db.query<{ role: string }>({ - text: `SELECT role FROM ProjectMembership WHERE projectId = $1 AND userId = $2`, + text: `SELECT role FROM ProjectMembership` + + ` WHERE projectId = $1 AND userId = $2`, values: [projectId, alice.id] }); expect(result.rows[0].role).toEqual('edit'); @@ -507,7 +511,8 @@ describe('UserGroupMembership table operations', () => { expect(added).toEqual(0); }); - it('should skip members already in the project (ON CONFLICT DO NOTHING)', async () => { + it('should skip members already in the project (ON CONFLICT DO NOTHING)', + async () => { const alice = await createUser(db, 'alice', 'alice@example.com', 'h'); const bob = await createUser(db, 'bob', 'bob@example.com', 'h'); const group = await createGroup(db, 'Team', null, alice.id);