openvibers/openvibe.network Commits
Guard service principal setup against target database selection (#20)
Changes
@@@ -126,6 +126,14 @@ After the first run the service will seed the URL registry with safe defaults anThe 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@@@ -378,7 +378,7 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.jsBot (T15) and Node (T14) follow, each step keeping Bot working (§9.2, §9.3). Owner steps: none for the Networkslices (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 laterflips `BOT_PAIRING_AUTHORITY=network`.## 9. Follow-up job briefs@@@ -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 };@@@ -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];
@@ -126,6 +126,14 @@ After the first run the service will seed the URL registry with safe defaults an
@@ -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)"`).
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
## Deploying
@@ -378,7 +378,7 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
@@ -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
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
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`.
flips `BOT_PAIRING_AUTHORITY=network`.
## 9. Follow-up job briefs
## 9. Follow-up job briefs
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
/**
/**
* Provision or rotate a service principal's client credentials (roadmap Wave 1, ADR-003).
* 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).
* 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
* The secret is written straight into the service's env file as OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET
@@ -13,11 +13,19 @@
@@ -13,11 +13,19 @@
* identity exist. The database is DATABASE_URL (PostgreSQL); there is no SQLite path (plan T2).
* identity exist. The database is DATABASE_URL (PostgreSQL); there is no SQLite path (plan T2).
*/
*/
const fs = require('fs');
const fs = require('fs');
+const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
const { initDb } = require('../db/database');
const { initDb } = require('../db/database');
+const config = require('../config');
const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
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) {
function upsertEnv(file, pairs) {
const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
const keys = new Set(Object.keys(pairs));
const keys = new Set(Object.keys(pairs));
@@ -31,7 +39,49 @@ function upsertEnv(file, pairs) {
@@ -31,7 +39,49 @@ function upsertEnv(file, pairs) {
async function main(argv) {
async function main(argv) {
const [cmd, id] = argv;
const [cmd, id] = argv;
const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
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();
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') {
if (cmd === 'list') {
for (const r of await db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {
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) {
@@ -40,12 +90,6 @@ async function main(argv) {
}
}
return 0;
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 secret = crypto.randomBytes(32).toString('base64url');
const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
if (cmd === 'create') {
if (cmd === 'create') {
@@ -63,4 +107,4 @@ async function main(argv) {
@@ -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; });
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 };
@@ -6,6 +6,7 @@ const fs = require('fs');
@@ -6,6 +6,7 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
+const config = require('../server/config');
const sp = require('../server/setup/service-principal');
const sp = require('../server/setup/service-principal');
(async () => {
(async () => {
@@ -13,14 +14,35 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));
@@ -13,14 +14,35 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));
const db = await getDb();
const db = await getDb();
const env = path.join(dir, 'events.env');
const env = path.join(dir, 'events.env');
fs.writeFileSync(env, 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');
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 out = [];
+const errors = [];
const log = console.log; const err = console.error;
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');
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;
console.log = log; console.error = err;
const second = fs.readFileSync(env, 'utf8');
const second = fs.readFileSync(env, 'utf8');
const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];
const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];