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 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 diff --git a/package-lock.json b/package-lock.json index 42f1b0c0..6a30f22e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,14 @@ "@octokit/auth-app": "8.2.0", "@octokit/rest": "22.0.1", "@sgratzl/chartjs-chart-boxplot": "4.4.5", + "bcrypt": "^6.0.0", "canvas": "3.2.3", "chart.js": "4.5.1", "chartjs-plugin-annotation": "3.1.0", "decimal.js": "10.6.0", "ejs": "6.0.1", "join-images": "1.1.5", + "jsonwebtoken": "^9.0.3", "koa": "3.2.1", "koa-body": "8.0.0", "pg": "8.21.0", @@ -31,6 +33,9 @@ "devDependencies": { "@eslint/js": "10.0.1", "@octokit/types": "16.0.0", + "@types/bcrypt": "^6.0.0", + "@types/ejs": "3.1.5", + "@types/jsonwebtoken": "^9.0.10", "@types/jquery": "4.0.1", "@types/koa": "3.0.3", "@types/koa__router": "12.0.5", @@ -952,9 +957,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -971,9 +973,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -990,9 +989,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1009,9 +1005,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1028,9 +1021,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1047,9 +1037,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1066,9 +1053,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1085,9 +1069,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1104,9 +1085,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1129,9 +1107,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1154,9 +1129,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1179,9 +1151,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1204,9 +1173,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1229,9 +1195,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1254,9 +1217,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1279,9 +1239,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2368,6 +2325,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", @@ -2415,6 +2382,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", @@ -2514,6 +2488,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", @@ -2555,6 +2540,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.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", @@ -3135,9 +3127,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3152,9 +3141,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3203,9 +3189,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3220,9 +3203,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3237,9 +3217,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3254,9 +3231,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3271,9 +3245,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3288,9 +3259,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3714,6 +3682,29 @@ "node": ">=6.0.0" } }, + "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", @@ -3851,6 +3842,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", @@ -4523,6 +4520,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", @@ -6639,6 +6645,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", @@ -6792,6 +6841,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", @@ -6799,6 +6884,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", @@ -7051,6 +7142,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 76974d85..fc5002d4 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "@octokit/auth-app": "8.2.0", "@octokit/rest": "22.0.1", "@sgratzl/chartjs-chart-boxplot": "4.4.5", + "bcrypt": "^6.0.0", "canvas": "3.2.3", "chart.js": "4.5.1", "chartjs-plugin-annotation": "3.1.0", "decimal.js": "10.6.0", "ejs": "6.0.1", "join-images": "1.1.5", + "jsonwebtoken": "^9.0.3", "koa": "3.2.1", "koa-body": "8.0.0", "pg": "8.21.0", @@ -40,6 +42,9 @@ "devDependencies": { "@eslint/js": "10.0.1", "@octokit/types": "16.0.0", + "@types/bcrypt": "^6.0.0", + "@types/ejs": "3.1.5", + "@types/jsonwebtoken": "^9.0.10", "@types/jquery": "4.0.1", "@types/koa": "3.0.3", "@types/koa__router": "12.0.5", diff --git a/src/backend/admin/admin-db.ts b/src/backend/admin/admin-db.ts new file mode 100644 index 00000000..a96ef3b4 --- /dev/null +++ b/src/backend/admin/admin-db.ts @@ -0,0 +1,233 @@ +import { randomBytes } from 'node:crypto'; + +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::projectRole)`, + 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::projectRole + 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; +} + +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 "apiToken" = $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<{ apiToken: string | null }>({ + name: 'admin_getApiTokenStatusForUser', + text: `SELECT "apiToken" FROM AppUser WHERE id = $1`, + values: [userId] + }); + const token = result.rows[0]?.apiToken ?? 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 "apiToken" = $1 AND "isActive" = 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; +} + +/** + * 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, + slug: string, + description: string | null, + ownerUserId: number +): Promise { + 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/admin-routes.ts b/src/backend/admin/admin-routes.ts new file mode 100644 index 00000000..8922ec97 --- /dev/null +++ b/src/backend/admin/admin-routes.ts @@ -0,0 +1,503 @@ +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, + generateApiTokenForUser, + getApiTokenStatusForUser, + getProjectsForUser, + getUserRoleForProject, + isProjectRole, + listProjectMembers, + removeProjectMember, + 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')); + +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 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 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 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 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 getUserRoleForProject(db, userId, projectId); + if (current === 'owner') { + const owners = await countOwners(db, projectId); + if (owners <= 1) { + jsonError( + ctx, + 409, + 'Cannot demote the last owner of the project' + ); + return; + } + } + } + + const updated = await 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 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 +): 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 getUserRoleForProject(db, userId, projectId); + if (current === null) { + jsonError(ctx, 404, 'Member not found'); + return; + } + if (current === 'owner') { + const owners = await countOwners(db, projectId); + if (owners <= 1) { + jsonError(ctx, 409, 'Cannot remove the last owner of the project'); + return; + } + } + + 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 new file mode 100644 index 00000000..a7ad4a94 --- /dev/null +++ b/src/backend/admin/admin.html @@ -0,0 +1,224 @@ + + + + + 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. +

    +
    +
    + +
    +
    API Token
    +
    +

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

    +
    + + +
    +
    + +
    +
    Create Project
    +
    + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + +
    +
    Groups
    +
    + +
      +

      + No groups yet. Create one below. +

      +
      +
      + +
      +
      Create Group
      +
      + +
      +
      + + +
      +
      + + +
      + +
      +
      +
      +
      + +
      + + + + +
      + 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..adb50fc2 --- /dev/null +++ b/src/backend/admin/group-db.ts @@ -0,0 +1,139 @@ +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/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-db.ts b/src/backend/auth/auth-db.ts new file mode 100644 index 00000000..956df72c --- /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; + passwordHash: string; + createdAt: Date; + isActive: 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, "passwordHash") + 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..4941cda6 --- /dev/null +++ b/src/backend/auth/auth-middleware.ts @@ -0,0 +1,69 @@ +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; + 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 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); + } + } + + if (!token) { + redirectOrUnauthorized(ctx); + return; + } + + let payload: jwt.JwtPayload; + try { + payload = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; + } catch { + redirectOrUnauthorized(ctx, true); + return; + } + + ctx.state.userId = Number(payload.sub); + ctx.state.username = payload.username as string; + + // 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 new file mode 100644 index 00000000..b6b246aa --- /dev/null +++ b/src/backend/auth/auth-routes.ts @@ -0,0 +1,134 @@ +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'; +import { prepareTemplate } from '../templates.js'; +import { + BCRYPT_ROUNDS, JWT_SECRET, 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'; +} + +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 || + password.length > 72 + ) { + ctx.status = 400; + ctx.type = 'json'; + ctx.body = { error: 'password must be between 8 and 72 characters' }; + return; + } + + // 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 = { 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; + } + + // 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: '72h' } + ); + + ctx.status = 200; + ctx.type = 'json'; + ctx.body = { token }; + }); +} diff --git a/src/backend/auth/login.html b/src/backend/auth/login.html new file mode 100644 index 00000000..bf4819cc --- /dev/null +++ b/src/backend/auth/login.html @@ -0,0 +1,74 @@ + + + + + + ReBench – Sign In + {%- include('header.html', { rebenchVersion: it.rebenchVersion }) %} + + +
      +
      +
      +

      ReBench

      + + + +
      + +
      + +
      +
      + + +
      +
      + + +
      + +
      +
      + + +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      + +
      +
      +
      +
      +
      +
      + + + 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 cdcd50fe..cb0a8006 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,7 +28,48 @@ export class DatabaseWithPool extends Database { public async query( queryConfig: QueryConfig ): Promise> { - return this.pool.query(queryConfig); + const context = userContextStorage.getStore(); + if (context) { + return context.client.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( + 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(`SELECT set_config('app.currentUserId', $1, true)`, [ + String(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 withSystemContext(fn: () => Promise): Promise { + const client = await this.pool.connect(); + try { + return await userContextStorage.run({ client }, fn); + } finally { + client.release(); + } } public async close(): Promise { diff --git a/src/backend/db/db.sql b/src/backend/db/db.sql index 6d6be3ca..cdc9ee12 100644 --- a/src/backend/db/db.sql +++ b/src/backend/db/db.sql @@ -200,6 +200,204 @@ CREATE TABLE Timeline ( foreign key (criterion) references Criterion (id) ); +-- ENUM type for membership roles + +CREATE TYPE projectRole AS ENUM ('view', 'edit', 'owner'); + +-- 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 +); + +-- 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) +); + +CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); + +-- 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$$; + +-- 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; + +-- 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; +$$; + +-- 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. + +-- Project: direct membership check +CREATE POLICY project_access ON Project + FOR ALL USING ( + 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 ( + 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 ( + 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 ( + 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 ( + 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 ( + 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 ( + 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 ( + 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() + ) + ); + +-- 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; + -- Used by ReBenchDB's perf-tracker, for self-performance tracking CREATE PROCEDURE recordAdditionalMeasurement( aRunId int, diff --git a/src/backend/db/db.ts b/src/backend/db/db.ts index 1b5a15d7..affd5cbd 100644 --- a/src/backend/db/db.ts +++ b/src/backend/db/db.ts @@ -166,6 +166,25 @@ 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.currentUserId` 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; + + /** + * 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(); @@ -187,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 { @@ -1192,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/db/schema-updates/migration.014.sql b/src/backend/db/schema-updates/migration.014.sql index be898c6a..22702091 100644 --- a/src/backend/db/schema-updates/migration.014.sql +++ b/src/backend/db/schema-updates/migration.014.sql @@ -1,37 +1,217 @@ --- Change the smallint runId and trialId to int, to allow for more runs and trials. -ALTER TABLE Measurement - ALTER COLUMN runId TYPE int, - ALTER COLUMN trialId TYPE int; - -ALTER TABLE ProfileData - ALTER COLUMN runId TYPE int, - ALTER COLUMN trialId TYPE int; - -ALTER TABLE Timeline - ALTER COLUMN runId TYPE int, - ALTER COLUMN trialId TYPE int; - -DROP PROCEDURE recordAdditionalMeasurement(smallint, smallint, smallint, float4); - -CREATE PROCEDURE recordAdditionalMeasurement( - aRunId int, - aTrialId int, - aCriterionId smallint, - aValue float4) -LANGUAGE plpgsql -AS $$ - BEGIN - UPDATE Measurement m - SET values = array_append(values, aValue) - WHERE - m.runId = aRunId AND - m.trialId = aTrialId AND - m.criterion = aCriterionId AND - m.invocation = 1; - - IF NOT FOUND THEN - INSERT INTO Measurement (runId, trialId, criterion, invocation, values) - VALUES (aRunId, aTrialId, aCriterionId, 1, ARRAY[aValue]); - END IF; - END; -$$; +-- migration.014.sql +-- 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; +-- 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; + +BEGIN; + +-- ENUM type for membership roles + +CREATE TYPE projectRole AS ENUM ('view', 'edit', 'owner'); + +-- 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 +); + +-- 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 projectRole NOT NULL DEFAULT 'view', + PRIMARY KEY (userId, projectId) +); + +CREATE INDEX projectmembership_projectid_idx ON ProjectMembership (projectId); + +-- 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$$; + +-- 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; + +-- 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; +$$; + +-- 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. + +-- Project: direct membership check +CREATE POLICY project_access ON Project + FOR ALL USING ( + 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 ( + 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 ( + 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 ( + 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 ( + 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 ( + 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 ( + 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 ( + 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() + ) + ); + +-- 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; + +-- Schema version bump + +INSERT INTO SchemaVersion (version, updateDate) VALUES (14, now()); + +COMMIT; 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/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/rebench/results.ts b/src/backend/rebench/results.ts index 92d02e40..57b18ef4 100644 --- a/src/backend/rebench/results.ts +++ b/src/backend/rebench/results.ts @@ -3,6 +3,10 @@ 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,30 +83,70 @@ export async function acceptResultData( 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` - ) - ) - .catch((e) => { - log.error('/rebenchdb/results failed to store measurements:', e.stack); - }); + const token = typeof data.token === 'string' ? data.token.trim() : null; + if (token === null) { 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); + // eslint-disable-next-line max-len + 'Authorization required, dd a token field to the rebenchdb section in conf-file'; + ctx.status = 401; + return; } + // 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); + } + }); + completeRequestAndHandlePromise(start, db, 'put-results'); } 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) { diff --git a/src/backend/timeline/timeline.ts b/src/backend/timeline/timeline.ts index a2098cfe..72b76e62 100644 --- a/src/backend/timeline/timeline.ts +++ b/src/backend/timeline/timeline.ts @@ -59,13 +59,12 @@ export async function renderTimeline( db: Database ): Promise { 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: await getLatestBenchmarksForTimelineView(project.id, db) - }); + ctx.body = timelineTpl({ rebenchVersion, project, benchmarks }); ctx.type = 'html'; } else { respondProjectNotFound(ctx, ctx.params.projectSlug); 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; diff --git a/src/frontend/admin.ts b/src/frontend/admin.ts new file mode 100644 index 00000000..1aa93d58 --- /dev/null +++ b/src/frontend/admin.ts @@ -0,0 +1,783 @@ +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; + selectedGroupId = null; + renderGroupsList(); + $id('admin-group-card').style.display = 'none'; + 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.'); + } + }); +} + +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) { + // eslint-disable-next-line max-len + 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( + // eslint-disable-next-line max-len + '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.'); + } + }); +} + +// ── 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/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.'); + } + }); diff --git a/src/index.ts b/src/index.ts index 5322cccd..976375ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,9 @@ import Router from '@koa/router'; import { initPerfTracker } from './backend/perf-tracker.js'; import { - DEV, cacheInvalidationDelay, dbConfig, + DEV, rebenchVersion, siteConfig, statsConfig @@ -49,10 +49,34 @@ import { } from './backend/compare/compare.js'; import { getAvailableDataAsJson } from './backend/project/data-export.js'; 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'; import { acceptResultData, reportResultApiVersion } from './backend/rebench/results.js'; +import { requireAuth } from './backend/auth/auth-middleware.js'; +import { + login, + register, + renderLoginPage +} from './backend/auth/auth-routes.js'; import { setTimeout } from 'node:timers/promises'; import { reportConnectionRefused } from './shared/errors.js'; @@ -68,7 +92,7 @@ export const db = new DatabaseWithPool( cacheInvalidationDelay ); -router.get('/', async (ctx) => { +router.get('/', requireAuth(db), async (ctx) => { return renderMainPage(ctx, db); }); @@ -90,73 +114,173 @@ Disallow: /rebenchdb* ctx.type = 'text'; }); -router.get('/:projectSlug', async (ctx) => renderProjectPage(ctx, db)); -router.get('/:projectSlug/source/:sourceId', async (ctx) => +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)); + +router.get('/admin', requireAuth(db), async (ctx) => renderAdminPage(ctx)); + +router.get('/:projectSlug', requireAuth(db), async (ctx) => + renderProjectPage(ctx, db) +); +router.get('/:projectSlug/source/:sourceId', requireAuth(db), 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) => { - if ( - ctx.header['X-Purpose'] === 'preview' || - ctx.header['Purpose'] === 'prefetch' || - ctx.header['X-Moz'] === 'prefetch' - ) { - ctx.set('Cache-Control', 'must-revalidate'); - ctx.status = 425; // HTTP Code for 'Too Early' - return; +router.get('/:projectSlug/timeline', requireAuth(db), async (ctx) => + renderTimeline(ctx, db) +); +router.get('/:projectSlug/data', requireAuth(db), async (ctx) => + renderProjectDataPage(ctx, db) +); +router.get( + '/:projectSlug/data/:expIdAndExtension', + requireAuth(db), + async (ctx) => { + if ( + ctx.header['X-Purpose'] === 'preview' || + ctx.header['Purpose'] === 'prefetch' || + ctx.header['X-Moz'] === 'prefetch' + ) { + ctx.set('Cache-Control', 'must-revalidate'); + ctx.status = 425; // HTTP Code for 'Too Early' + return; + } + return renderDataExport(ctx, db); } - return renderDataExport(ctx, db); -}); -router.get('/:projectSlug/compare/:baseline..:change', async (ctx) => - renderComparePage(ctx, db) +); +router.get( + '/:projectSlug/compare/:baseline..:change', + requireAuth(db), + async (ctx) => renderComparePage(ctx, db) ); // DEPRECATED: remove for 1.0 -router.get('/timeline/:projectId', async (ctx) => +router.get('/timeline/:projectId', requireAuth(db), async (ctx) => redirectToNewTimelineUrl(ctx, db) ); -router.get('/project/:projectId', async (ctx) => +router.get('/project/:projectId', requireAuth(db), async (ctx) => redirectToNewProjectDataUrl(ctx, db) ); -router.get('/rebenchdb/get-exp-data/:expId', async (ctx) => +router.get('/rebenchdb/get-exp-data/:expId', requireAuth(db), async (ctx) => redirectToNewProjectDataExportUrl(ctx, db) ); -router.get('/compare/:project/:baseline/:change', 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 // for the project -router.get('/rebenchdb/dash/:projectId/results', async (ctx) => +router.get('/rebenchdb/dash/:projectId/results', requireAuth(db), async (ctx) => getLast100MeasurementsAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/timeline/:runId', async (ctx) => - getTimelineAsJson(ctx, db) +router.get( + '/rebenchdb/dash/:projectId/timeline/:runId', + requireAuth(db), + async (ctx) => getTimelineAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/profiles/:runId/:commitId', + requireAuth(db), async (ctx) => getProfileAsJson(ctx, db) ); router.get( '/rebenchdb/dash/:projectSlug/measurements/:runId/:baseId/:changeId', + requireAuth(db), 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(db), async (ctx) => + getSiteStatsAsJson(ctx, db) +); +router.get('/rebenchdb/dash/:projectId/changes', requireAuth(db), async (ctx) => getChangesAsJson(ctx, db) ); -router.get('/rebenchdb/dash/:projectId/data-overview', async (ctx) => - getAvailableDataAsJson(ctx, db) +router.get( + '/rebenchdb/dash/:projectId/data-overview', + requireAuth(db), + async (ctx) => getAvailableDataAsJson(ctx, db) +); +router.post( + '/rebenchdb/dash/:projectName/timelines', + requireAuth(db), + koaBody(), + async (ctx) => getTimelineDataAsJson(ctx, db) +); + +router.get('/admin/api/my-projects', requireAuth(db), async (ctx) => + listMyProjects(ctx, db) +); +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.post( + '/admin/api/projects/:projectId/members', + requireAuth(db), + koaBody(), + async (ctx) => addMember(ctx, db) +); +router.put( + '/admin/api/projects/:projectId/members/:userId', + requireAuth(db), + koaBody(), + async (ctx) => updateMember(ctx, db) +); +router.delete( + '/admin/api/projects/:projectId/members/:userId', + requireAuth(db), + async (ctx) => deleteMember(ctx, db) +); + +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('/rebenchdb/dash/:projectName/timelines', koaBody(), async (ctx) => - getTimelineDataAsJson(ctx, db) +router.post('/admin/api/token/generate', requireAuth(db), async (ctx) => + generateMyApiToken(ctx, db) ); -router.get('/admin/perform-timeline-update', async (ctx) => +router.get('/admin/perform-timeline-update', requireAuth(db), async (ctx) => submitTimelineUpdateJobs(ctx, db) ); router.post( '/admin/refresh/:project/:baseline/:change', + requireAuth(db), koaBody({ urlencoded: true }), deleteCachedReport ); 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 { 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 diff --git a/tests/backend/db/auth-db.test.ts b/tests/backend/db/auth-db.test.ts new file mode 100644 index 00000000..2596ef58 --- /dev/null +++ b/tests/backend/db/auth-db.test.ts @@ -0,0 +1,545 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it +} from '@jest/globals'; + +import { + closeMainDb, + createAndInitializeDB, + TestDatabase +} from './db-testing.js'; + +import { + createUser, + getUserByEmail, + 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; + + 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.passwordHash).toEqual('hash_abc'); + expect(user.id).toBeGreaterThan(0); + expect(user.isActive).toEqual(true); + expect(user.createdAt).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!.passwordHash).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); + }); + + // eslint-disable-next-line max-len + 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(); + }); + + // eslint-disable-next-line max-len + 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); + }); +}); + +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(); +}); diff --git a/tests/backend/db/db-setup.test.ts b/tests/backend/db/db-setup.test.ts index 9c5693ff..e8cb9615 100644 --- a/tests/backend/db/db-setup.test.ts +++ b/tests/backend/db/db-setup.test.ts @@ -1,9 +1,9 @@ import { - describe, - expect, - beforeAll, afterAll, afterEach, + beforeAll, + describe, + expect, it, jest } from '@jest/globals'; @@ -16,10 +16,10 @@ import type { } from '../../../src/shared/api.js'; import { loadScheme } from '../../../src/backend/db/db.js'; import { - TestDatabase, + closeMainDb, createAndInitializeDB, createDB, - closeMainDb + TestDatabase } from './db-testing.js'; import { robustPath } from '../../../src/backend/util.js'; import { loadLargePayload, loadSmallPayload } from '../../payload.js'; diff --git a/tests/backend/db/db-testing.ts b/tests/backend/db/db-testing.ts index 9fc1877e..291869ab 100644 --- a/tests/backend/db/db-testing.ts +++ b/tests/backend/db/db-testing.ts @@ -85,6 +85,24 @@ 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: `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(); @@ -93,13 +111,6 @@ export class TestDatabase extends Database { } } - private async release(): Promise { - const mainDB = getMainDB(); - await mainDB.query({ - text: `DROP DATABASE IF EXISTS ${this.dbConfig.database}` - }); - } - public async close(): Promise { await super.close(); @@ -127,6 +138,15 @@ export class TestDatabase extends Database { } } } + + private async release(): Promise { + const mainDB = getMainDB(); + await mainDB.withSystemContext(() => + mainDB.query({ + text: `DROP DATABASE IF EXISTS ${this.dbConfig.database}` + }) + ); + } } export async function createAndInitializeDB( @@ -168,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 096fc626..49699e0c 100644 --- a/tests/backend/db/db.test.ts +++ b/tests/backend/db/db.test.ts @@ -1,18 +1,18 @@ import { - describe, - expect, + afterAll, beforeAll, beforeEach, - afterAll, + describe, + expect, it, jest } from '@jest/globals'; import { PoolConfig, QueryConfig, QueryResult, QueryResultRow } from 'pg'; import { - TestDatabase, + closeMainDb, createAndInitializeDB, - closeMainDb + TestDatabase } from './db-testing.js'; import { BenchmarkData, @@ -21,11 +21,11 @@ import { } from '../../../src/shared/api.js'; import { - Experiment, + Criterion, Environment, + Experiment, Run, - Trial, - Criterion + Trial } from '../../../src/backend/db/types.js'; import { Database } from '../../../src/backend/db/db.js'; import { loadSmallPayload } from '../../payload.js'; @@ -278,6 +278,17 @@ describe('createValueBatchForInsertion()', () => { ): Promise> { return null; } + + public async withUserContext( + _userId: number | null, + fn: () => Promise + ): Promise { + return fn(); + } + + public async withSystemContext(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..5a37539c --- /dev/null +++ b/tests/backend/db/rls.test.ts @@ -0,0 +1,541 @@ +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 }>({ + // eslint-disable-next-line max-len + 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({ + // eslint-disable-next-line max-len + 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({ + // eslint-disable-next-line max-len + 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()); + + // eslint-disable-next-line max-len + 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); + }); + + // eslint-disable-next-line max-len + 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); + }); + + // eslint-disable-next-line max-len + 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); + }); + + // eslint-disable-next-line max-len + 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()); + + // eslint-disable-next-line max-len + 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()); 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(); 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.