From 774bde7c00cea9c2b88ac767c8ed9184b0faf7c0 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 22:39:11 +0100 Subject: [PATCH 1/8] Reserve certain keys on info tables --- .../server/lib/db/info/check-permission.js | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 lib/www/server/lib/db/info/check-permission.js diff --git a/lib/www/server/lib/db/info/check-permission.js b/lib/www/server/lib/db/info/check-permission.js new file mode 100644 index 0000000..e8dd7aa --- /dev/null +++ b/lib/www/server/lib/db/info/check-permission.js @@ -0,0 +1,83 @@ +/** Check permission to read or write certain keys. + * + * The global and survey `info` tables can be used to + * store and retrieve arbitrary data, but it is also + * used by the software, with some keys being reserved + * for specific purposes. + * + * This module lists those keys which are in some way + * reserved and reports on who should be allowed what + * type of access to them. + */ + + +/** Reserved keys. + * + * The structure of this dictionary is + * object.verb.subject = Boolean. + * + * The special value `_` is a wildcard + * denoting the default condition for + * a verb or a subject. + */ +const dictionary = { + version: { + // Database or schema version string. + // Everyone can read, nobody can alter. + get: { _: true }, + _ : { _: false } + }, + config: { + // Configuration (site-wide or survey) + // Nobody except admin can access + _: { _: false, admin: true } + }, + qc: { + // QC results (survey) + // Everyone can read, nobody can write + get: { _: true }, + _ : { _: false } + }, + equipment: { + // Equipment info (site) + // Everyone can read, user + admin can alter + get: { _: true }, + _ : { _: false, user: true, admin: true } + }, + contact: { + // Contact details (basically an example entry) + // Everyone can read, admin can alter + get: { _: true }, + _ : { _: false, admin: true }, + } +} + +/** Check if access is allowed to an info entry. + * + * @a key {String} is the object of the action. + * @a verb {String} is the action. + * @a role {String} is the subject of the action. + * + * @returns {Boolean} `true` is the action is allowed, + * `false` if it is not. + * + * By default, all actions are allowed on a key that's + * not listed in the dictionary. For a key that is listed, + * the result for a default action or subject is denoted + * by `_`, others are entered explicitly. + * + */ +function checkPermission (key, verb, role) { + const entry = dictionary[key] + if (entry) { + const action = entry[verb] ?? entry._ + if (action) { + return action[role] ?? action._ ?? false; + } + return false; + } + return true; +} + + +module.exports = checkPermission; From f10103d396fb9886ece2346163cd56dfe8d9e765 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 22:40:53 +0100 Subject: [PATCH 2/8] Enfore info key access restrictions on the API. Obviously, those keys can be edited freely at the database level. This is intended. --- lib/www/server/api/middleware/info/delete.js | 2 +- lib/www/server/api/middleware/info/get.js | 2 +- lib/www/server/api/middleware/info/post.js | 2 +- lib/www/server/api/middleware/info/put.js | 2 +- lib/www/server/lib/db/info/delete.js | 8 +++++++- lib/www/server/lib/db/info/get.js | 8 +++++++- lib/www/server/lib/db/info/post.js | 8 +++++++- lib/www/server/lib/db/info/put.js | 8 +++++++- 8 files changed, 32 insertions(+), 8 deletions(-) diff --git a/lib/www/server/api/middleware/info/delete.js b/lib/www/server/api/middleware/info/delete.js index 431dc20..047c951 100644 --- a/lib/www/server/api/middleware/info/delete.js +++ b/lib/www/server/api/middleware/info/delete.js @@ -4,7 +4,7 @@ const { info } = require('../../../lib/db'); module.exports = async function (req, res, next) { try { - await info.delete(req.params.project, req.params.path); + await info.delete(req.params.project, req.params.path, undefined, req.user.role); res.status(204).send(); next(); } catch (err) { diff --git a/lib/www/server/api/middleware/info/get.js b/lib/www/server/api/middleware/info/get.js index 195bfae..fe2a1d4 100644 --- a/lib/www/server/api/middleware/info/get.js +++ b/lib/www/server/api/middleware/info/get.js @@ -4,7 +4,7 @@ const { info } = require('../../../lib/db'); module.exports = async function (req, res, next) { try { - res.status(200).json(await info.get(req.params.project, req.params.path, req.query)); + res.status(200).json(await info.get(req.params.project, req.params.path, req.query, req.user.role)); } catch (err) { if (err instanceof TypeError) { res.status(404).json(null); diff --git a/lib/www/server/api/middleware/info/post.js b/lib/www/server/api/middleware/info/post.js index eaa26bb..b3514b0 100644 --- a/lib/www/server/api/middleware/info/post.js +++ b/lib/www/server/api/middleware/info/post.js @@ -6,7 +6,7 @@ module.exports = async function (req, res, next) { try { const payload = req.body; - await info.post(req.params.project, req.params.path, payload); + await info.post(req.params.project, req.params.path, payload, undefined, req.user.role); res.status(201).send(); next(); } catch (err) { diff --git a/lib/www/server/api/middleware/info/put.js b/lib/www/server/api/middleware/info/put.js index 6ab51ed..8e97347 100644 --- a/lib/www/server/api/middleware/info/put.js +++ b/lib/www/server/api/middleware/info/put.js @@ -6,7 +6,7 @@ module.exports = async function (req, res, next) { try { const payload = req.body; - await info.put(req.params.project, req.params.path, payload); + await info.put(req.params.project, req.params.path, payload, undefined, req.user.role); res.status(201).send(); next(); } catch (err) { diff --git a/lib/www/server/lib/db/info/delete.js b/lib/www/server/lib/db/info/delete.js index 38139b4..d0b0c6f 100644 --- a/lib/www/server/lib/db/info/delete.js +++ b/lib/www/server/lib/db/info/delete.js @@ -1,9 +1,15 @@ const { setSurvey, transaction } = require('../connection'); +const checkPermission = require('./check-permission'); -async function del (projectId, path, opts = {}) { +async function del (projectId, path, opts = {}, role) { const client = await setSurvey(projectId); const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length); + if (!checkPermission(key, "delete", role)) { + throw {status: 403, message: "Forbidden"}; + return; + } + try { const text = jsonpath.length ? ` diff --git a/lib/www/server/lib/db/info/get.js b/lib/www/server/lib/db/info/get.js index 1cc562c..867b47c 100644 --- a/lib/www/server/lib/db/info/get.js +++ b/lib/www/server/lib/db/info/get.js @@ -1,9 +1,15 @@ const { setSurvey } = require('../connection'); +const checkPermission = require('./check-permission'); -async function get (projectId, path, opts = {}) { +async function get (projectId, path, opts = {}, role) { const client = await setSurvey(projectId); const [key, ...subkey] = path.split("/").filter(i => i.trim().length); + if (!checkPermission(key, "get", role)) { + throw {status: 403, message: "Forbidden"}; + return; + } + const text = ` SELECT value FROM info diff --git a/lib/www/server/lib/db/info/post.js b/lib/www/server/lib/db/info/post.js index 3aefed8..e9b79a7 100644 --- a/lib/www/server/lib/db/info/post.js +++ b/lib/www/server/lib/db/info/post.js @@ -1,9 +1,15 @@ const { setSurvey, transaction } = require('../connection'); +const checkPermission = require('./check-permission'); -async function post (projectId, path, payload, opts = {}) { +async function post (projectId, path, payload, opts = {}, role) { const client = await setSurvey(projectId); const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length); + if (!checkPermission(key, "post", role)) { + throw {status: 403, message: "Forbidden"}; + return; + } + try { const text = jsonpath.length ? ` diff --git a/lib/www/server/lib/db/info/put.js b/lib/www/server/lib/db/info/put.js index 383d0d4..b65963b 100644 --- a/lib/www/server/lib/db/info/put.js +++ b/lib/www/server/lib/db/info/put.js @@ -1,9 +1,15 @@ const { setSurvey, transaction } = require('../connection'); +const checkPermission = require('./check-permission'); -async function put (projectId, path, payload, opts = {}) { +async function put (projectId, path, payload, opts = {}, role) { const client = await setSurvey(projectId); const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length); + if (!checkPermission(key, "put", role)) { + throw {status: 403, message: "Forbidden"}; + return; + } + try { const text = jsonpath.length ? ` From 8ec479805a844e0a5e6f9a20bbbfd0fec9b118f9 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 22:48:20 +0100 Subject: [PATCH 3/8] Add version reporting library. This reports the current server version, from Git by default. Also, and of more interest, it reports whether the current database schema is compatible with the server code. --- lib/www/server/lib/version.js | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 lib/www/server/lib/version.js diff --git a/lib/www/server/lib/version.js b/lib/www/server/lib/version.js new file mode 100644 index 0000000..89c848e --- /dev/null +++ b/lib/www/server/lib/version.js @@ -0,0 +1,68 @@ +const semver = require("semver"); +const { exec } = require("child_process"); +const pkg = require("../package.json"); + +/** Report whether the database schema version is + * compatible with the version required by this server. + * + * The current schema version is retrieved from the + * public.info table. + * + * The wanted version is retrieved from package.json + * (config.db_schema). + * + * @returns true if the versions are compatible, + * false otherwise. + */ +function compatible () { + const { info } = require('./db'); + return new Promise ( async (resolve, reject) => { + const current = await info.get(null, "version/db_schema"); + const wanted = pkg.config.db_schema; + if (semver.satisfies(current, wanted)) { + resolve({current, wanted}); + } else { + reject({current, wanted}); + } + }); +} + + +/** Return software name. + * + */ +function name () { + const pkg = require("../package.json"); + return pkg.name ?? pkg.description ?? "Unknown"; +} + +/** Return software version, from Git if possible. + * + */ +async function describe () { + return new Promise( (resolve, reject) => { + const cmd = `git describe || echo git+$(git describe --always);`; + exec(cmd, {cwd: __dirname}, (error, stdout, stderr) => { + if (error) { + reject(error); + } + + if (stdout) { + resolve(stdout.trim()); + } else { + // Most likely not installed from Git, use the + // version number in package.json. + resolve(pkg.version ?? "Unknown") + } + }) + }); +} + +function version () { + return pkg.version; +} +version.compatible = compatible; +version.name = name; +version.describe = describe; + +module.exports = version; From 923ff1aceaa3078484128711a41a00495b74d073 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 22:50:44 +0100 Subject: [PATCH 4/8] Add more details to package.json --- lib/www/server/package.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/www/server/package.json b/lib/www/server/package.json index c44c2b0..f6f4c13 100644 --- a/lib/www/server/package.json +++ b/lib/www/server/package.json @@ -9,6 +9,13 @@ }, "author": "Aaltronav s.r.o.", "license": "UNLICENSED", + "private": true, + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "linux" + ], "dependencies": { "cookie-parser": "^1.4.5", "express": "^4.17.1", From 81ce6346b9bab650b342f9f91d6b110e4d597005 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 22:51:25 +0100 Subject: [PATCH 5/8] Add database schema information to package.json. Used to determine if the actual schema on the database is compatible with the version of the server we're attempting to run. --- lib/www/server/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/www/server/package.json b/lib/www/server/package.json index f6f4c13..dd473c0 100644 --- a/lib/www/server/package.json +++ b/lib/www/server/package.json @@ -10,6 +10,9 @@ "author": "Aaltronav s.r.o.", "license": "UNLICENSED", "private": true, + "config": { + "db_schema": "^0.1.0" + }, "engines": { "node": ">=14.0.0" }, From 83be83e4bd2aa2bfa55dacc35cf38859370e8d94 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 22:52:45 +0100 Subject: [PATCH 6/8] Check database schema compatibility. The server will not start unless it satisfies itself that we're running against a compatible database schema. --- lib/www/server/index.js | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/www/server/index.js b/lib/www/server/index.js index 2df46ee..191bde6 100755 --- a/lib/www/server/index.js +++ b/lib/www/server/index.js @@ -1,18 +1,28 @@ #!/usr/bin/node -const api = require('./api'); -const ws = require('./ws'); +async function main () { + // Check that we're running against the correct database version + const version = require('./lib/version'); + console.log("Running version", await version.describe()); + version.compatible() + .then( () => { + const api = require('./api'); + const ws = require('./ws'); -const { fork } = require('child_process'); + const { fork } = require('child_process'); -// const em = require('./events'); + const server = api.start(process.env.HTTP_PORT || 3000, process.env.HTTP_PATH); + ws.start(server); -const server = api.start(process.env.HTTP_PORT || 3000, process.env.HTTP_PATH); -ws.start(server); + const eventManagerPath = [__dirname, "events"].join("/"); + const eventManager = fork(eventManagerPath, /*{ stdio: 'ignore' }*/); -const eventManagerPath = [__dirname, "events"].join("/"); -const eventManager = fork(eventManagerPath, /*{ stdio: 'ignore' }*/); + process.on('exit', () => eventManager.kill()); + }) + .catch( ({current, wanted}) => { + console.error(`Fatal error: incompatible database schema version ${current} (wanted: ${wanted})`); + process.exit(1); + }); +} -process.on('exit', () => eventManager.kill()); - -// em.start(); +main(); From e7fa74326dba86bac8172fcd8ab19ba690828e6f Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 23:24:24 +0100 Subject: [PATCH 7/8] Add README to database upgrades directory --- etc/db/upgrades/README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 etc/db/upgrades/README.md diff --git a/etc/db/upgrades/README.md b/etc/db/upgrades/README.md new file mode 100644 index 0000000..d33fd2d --- /dev/null +++ b/etc/db/upgrades/README.md @@ -0,0 +1,34 @@ +# Database schema upgrades + +When the database schema needs to be upgraded in order to provide new functionality, fix errors, etc., an upgrade script should be added to this directory. + +The script can be SQL (preferred) or anything else (Bash, Python, …) in the event of complex upgrades. + +The script itself should: + +* document what the intended changes are; +* contain instructions on how to run it; +* make the user aware of any non-obvious side effects; and +* say if it is safe to run the script multiple times on the +* same schema / database. + +## Naming + +Script files should be named `upgrade----v.sql`, where: + +* `` is a correlative two-digit index. When reaching 99, existing files will be renamed to a three digit index (001-099) and new files will use three digits. +* `` is the ID of the Git commit that last introduced a schema change. +* `` is the ID of the first Git commit expecting the updated schema. +* `` is the version of the schema. + +Note: the `` value should be updated with every change and it should be the same as reported by: + +```sql +select value->>'db_schema' as db_schema from public.info where key = 'version'; +``` + +If necessary, the wanted schema version must also be updated in `package.json`. + +## Running + +Schema upgrades are always run manually. From 36e7b1fe21bf149268302d8e9ef81530d1610422 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Sun, 6 Feb 2022 23:25:05 +0100 Subject: [PATCH 8/8] Add database upgrade file 09 --- .../upgrade09-74b3de5c→83be83e4-v0.1.0.sql | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 etc/db/upgrades/upgrade09-74b3de5c→83be83e4-v0.1.0.sql diff --git a/etc/db/upgrades/upgrade09-74b3de5c→83be83e4-v0.1.0.sql b/etc/db/upgrades/upgrade09-74b3de5c→83be83e4-v0.1.0.sql new file mode 100644 index 0000000..934af61 --- /dev/null +++ b/etc/db/upgrades/upgrade09-74b3de5c→83be83e4-v0.1.0.sql @@ -0,0 +1,24 @@ +-- Upgrade the database from commit 74b3de5c to commit 83be83e4. +-- +-- NOTE: This upgrade only affects the `public` schema. +-- +-- This inserts a database schema version into the database. +-- Note that we are not otherwise changing the schema, so older +-- server code will continue to run against this version. +-- +-- ATTENTION! +-- +-- This value should be incremented every time that the database +-- schema changes (either `public` or any of the survey schemas) +-- and is used by the server at start-up to detect if it is +-- running against a compatible schema version. +-- +-- To apply, run as the dougal user: +-- +-- psql < $THIS_FILE +-- +-- NOTE: It can be applied multiple times without ill effect. + +INSERT INTO public.info VALUES ('version', '{"db_schema": "0.1.0"}') +ON CONFLICT (key) DO UPDATE + SET value = public.info.value || '{"db_schema": "0.1.0"}' WHERE public.info.key = 'version';