openvibers/openvibe.network Commits

Guard service principal setup against target database selection (#20)

OP OpenVibers committed 3 Oct 2026 · 482d4a1b7f · 4 files, +89 −15

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/20

Changes

ddiff --git a/README.md b/README.md
iindex de284927..a8cdbb8b 100644
---- a/README.md
++++ b/README.md
@@@ -126,6 +126,14 @@ After the first run the service will seed the URL registry with safe defaults an
The server applies `migrations/NNNN_*.sql` at boot (as the owner on `DATABASE_DIRECT_URL`) and seeds OAuth2 clients and site settings on first run into PostgreSQL. Client secrets are logged to console once on creation — copy them to the consuming services' env files (`/etc/openvibe/<svc>.env`). The tests run on PGlite (`npm test`) or the PostgreSQL + PgBouncer containers (`npm run test:pg`, after `eval "$(openvibe-sdk scripts/test-services.sh up)"`).
++To rotate a service principal, load **Network's** environment before the script name and pass the target service's file with `--write-env`:
++
++```bash
++sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate <id> --write-env /etc/openvibe/<id>.env
++```
++
++The script requires the database named `ov_network` (or an explicit `--database-name <name>` override). Do not use `--env-file` after the script name: Node loads it as environment and can select the target service's database.
++
---
## Deploying
ddiff --git a/docs/t2-cells-and-node-principal.md b/docs/t2-cells-and-node-principal.md
iindex 8ca0f509..59c4c2b3 100644
---- a/docs/t2-cells-and-node-principal.md
++++ b/docs/t2-cells-and-node-principal.md
@@@ -378,7 +378,7 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
Bot (T15) and Node (T14) follow, each step keeping Bot working (§9.2, §9.3). Owner steps: none for the Network
slices (main is not deployed and the PostgreSQL cutover waits on the owner); at Bot's deploy the owner provisions Bot's
--client secret (`sudo node server/setup/service-principal.js rotate bot --env-file /etc/openvibe/bot.env`) and later
++client secret (`sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate bot --write-env /etc/openvibe/bot.env`) and later
flips `BOT_PAIRING_AUTHORITY=network`.
## 9. Follow-up job briefs
ddiff --git a/server/setup/service-principal.js b/server/setup/service-principal.js
iindex 9fc255c1..a2411a2c 100644
---- a/server/setup/service-principal.js
++++ b/server/setup/service-principal.js
@@@ -3,9 +3,9 @@
/**
* Provision or rotate a service principal's client credentials (roadmap Wave 1, ADR-003).
*
-- * sudo node server/setup/service-principal.js create <id> --env-file /etc/openvibe/<id>.env
-- * sudo node server/setup/service-principal.js rotate <id> --env-file /etc/openvibe/<id>.env
-- * node server/setup/service-principal.js list
++ * sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js create <id> --write-env /etc/openvibe/<id>.env
++ * sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate <id> --write-env /etc/openvibe/<id>.env
++ * node --env-file=/etc/openvibe/network.env server/setup/service-principal.js list
*
* A service principal is an OAuth client with no redirect URIs (it can only use client_credentials).
* The secret is written straight into the service's env file as OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET
@@@ -13,11 +13,19 @@
* identity exist. The database is DATABASE_URL (PostgreSQL); there is no SQLite path (plan T2).
*/
const fs = require('fs');
++const path = require('path');
const crypto = require('crypto');
const { initDb } = require('../db/database');
++const config = require('../config');
const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
++function hasScriptEnvFile(argv, rawArgs = []) {
++ if (argv.some(arg => arg === '--env-file' || arg.startsWith('--env-file='))) return true;
++ const script = rawArgs.findIndex(arg => !arg.startsWith('-') && path.resolve(arg) === process.argv[1]);
++ return script >= 0 && rawArgs.slice(script + 1).some(arg => arg === '--env-file' || arg.startsWith('--env-file='));
++}
++
function upsertEnv(file, pairs) {
const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
const keys = new Set(Object.keys(pairs));
@@@ -31,7 +39,49 @@ function upsertEnv(file, pairs) {
async function main(argv) {
const [cmd, id] = argv;
const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
++ // Node consumes --env-file after the script name and removes it from process.argv.
++ // /proc/self/cmdline retains the launch order, so we can reject the unsafe form.
++ let rawArgs = [];
++ try { rawArgs = fs.readFileSync('/proc/self/cmdline', 'utf8').split('\0'); } catch { /* argv still catches direct callers */ }
++ if (hasScriptEnvFile(argv, rawArgs)) {
++ console.error('Use --write-env: Node loads --env-file as environment and can select the target service database.');
++ return 2;
++ }
++ if (cmd !== 'list' && (!['create', 'rotate'].includes(cmd) || !ID_RE.test(id || ''))) {
++ console.error('usage: service-principal.js create|rotate <id> --write-env <path> [--database-name <name>] | list');
++ return 2;
++ }
++ const envFile = opt('write-env');
++ if (cmd !== 'list' && (!envFile || envFile.startsWith('--'))) {
++ console.error('--write-env is required (the secret is written there, never printed)');
++ return 2;
++ }
++ const databaseName = opt('database-name');
++ const expectedName = databaseName || 'ov_network';
++ if ((argv.includes('--database-name') && (!databaseName || databaseName.startsWith('--')))
++ || !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(expectedName)) {
++ console.error('--database-name must be a database name');
++ return 2;
++ }
++ // Check both connections before initDb() runs migrations or seeds either database.
++ if (config.db.url) {
++ for (const [key, url] of [['DATABASE_URL', config.db.url], ['DATABASE_DIRECT_URL', config.db.directUrl]]) {
++ let name;
++ try { name = decodeURIComponent(new URL(url).pathname.slice(1)); } catch { /* invalid URL */ }
++ if (name !== expectedName) {
++ console.error(`${key} must select database ${expectedName}; use --database-name only for an intentional override.`);
++ return 2;
++ }
++ }
++ }
const db = await initDb();
++ if (config.db.url) {
++ const connected = await db.prepare('SELECT current_database() AS name').get();
++ if (connected.name !== expectedName) {
++ console.error(`Connected database must be ${expectedName}.`);
++ return 2;
++ }
++ }
{
if (cmd === 'list') {
for (const r of await db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {
@@@ -40,12 +90,6 @@ async function main(argv) {
}
return 0;
}
-- if (!['create', 'rotate'].includes(cmd) || !ID_RE.test(id || '')) {
-- console.error('usage: service-principal.js create|rotate <id> --env-file <path> | list');
-- return 2;
-- }
-- const envFile = opt('env-file');
-- if (!envFile) { console.error('--env-file is required (the secret is written there, never printed)'); return 2; }
const secret = crypto.randomBytes(32).toString('base64url');
const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
if (cmd === 'create') {
@@@ -63,4 +107,4 @@ async function main(argv) {
}
if (require.main === module) main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; });
--module.exports = { main, upsertEnv };
++module.exports = { main, upsertEnv, hasScriptEnvFile };
ddiff --git a/test/service-principal.test.js b/test/service-principal.test.js
iindex 82d3084b..cdfc61d5 100644
---- a/test/service-principal.test.js
++++ b/test/service-principal.test.js
@@@ -6,6 +6,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { getDb } = require('../server/db/database');
++const config = require('../server/config');
const sp = require('../server/setup/service-principal');
(async () => {
@@@ -13,14 +14,35 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));
const db = await getDb();
const env = path.join(dir, 'events.env');
fs.writeFileSync(env, 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');
++const dbArg = config.db.url ? ['--database-name', decodeURIComponent(new URL(config.db.url).pathname.slice(1))] : [];
const out = [];
++const errors = [];
const log = console.log; const err = console.error;
--console.log = (...a) => out.push(a.join(' ')); console.error = () => {};
--assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 0);
--assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 1, 'no double create');
++console.log = (...a) => out.push(a.join(' ')); console.error = (...a) => errors.push(a.join(' '));
++assert.strictEqual(sp.hasScriptEnvFile([], [process.execPath, process.argv[1], '--env-file', env]), true,
++ 'Node strips a trailing --env-file from argv');
++assert.strictEqual(sp.hasScriptEnvFile([], [process.execPath, '--env-file=/etc/openvibe/network.env', process.argv[1], '--write-env', env]), false);
++assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 2);
++assert.match(errors.pop(), /^Use --write-env: Node loads --env-file as environment/);
++assert.strictEqual(await sp.main(['create', 'events', `--env-file=${env}`]), 2);
++assert.match(errors.pop(), /^Use --write-env: Node loads --env-file as environment/);
++assert.strictEqual(fs.readFileSync(env, 'utf8'), 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');
++const originalUrl = config.db.url;
++const originalDirectUrl = config.db.directUrl;
++config.db.url = 'postgres://localhost/ov_events';
++assert.strictEqual(await sp.main(['create', 'events', '--write-env', env]), 2, 'wrong database refused before init');
++assert.match(errors.pop(), /^DATABASE_URL must select database ov_network/);
++config.db.url = 'postgres://localhost/ov_network';
++config.db.directUrl = 'postgres://localhost/ov_events';
++assert.strictEqual(await sp.main(['create', 'events', '--write-env', env]), 2, 'wrong migration database refused before init');
++assert.match(errors.pop(), /^DATABASE_DIRECT_URL must select database ov_network/);
++config.db.url = originalUrl;
++config.db.directUrl = originalDirectUrl;
++assert.strictEqual(await sp.main(['create', 'events', '--write-env', env, ...dbArg]), 0);
++assert.strictEqual(await sp.main(['create', 'events', '--write-env', env, ...dbArg]), 1, 'no double create');
const first = fs.readFileSync(env, 'utf8');
--assert.strictEqual(await sp.main(['rotate', 'events', '--env-file', env]), 0);
++assert.strictEqual(await sp.main(['rotate', 'events', '--write-env', env, ...dbArg]), 0);
console.log = log; console.error = err;
const second = fs.readFileSync(env, 'utf8');
const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];