openvibers/openvibe.ai Commits
Namespaces fail closed: a token without an ns claim no longer runs every namespace's workflows. A service token (svc:<id>) without ns falls back to its AI_NS_FALLBACK entry (default live=live.*|network.site_copy, since Network grants Live's ai.run.create with no namespaces), else <id>.*; app and module tokens get nothing; AI_NS_REQUIRED=false is a rollback lever
Changes
@@@ -18,6 +18,11 @@ AI_DB_PATH=./data/ai.dbOV_NETWORK_URL=https://openvibe.networkOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000#OV_NETWORK_ISSUER=https://openvibe.network++# Namespaces fail closed: a token with no `ns` claim may only run its fallback namespaces. A service++# token (svc:<id>) without ns gets its entry here (service=ns|ns,...), else <id>.*; `none` = no fallback.++#AI_NS_FALLBACK=live=live.*|network.site_copy++# Rollback lever only: false restores the old rule (a token without ns may run every namespace).++#AI_NS_REQUIRED=true#OV_NETWORK_PUBLIC_KEY=# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──@@@ -70,7 +70,12 @@ produce a real answer ends `failed` with an explicit code — `provider.unavailaThe capability ids are proposed for openvibe-contracts in `docs/capabilities-proposal/` (with thereplacement `ai` service manifest). Until a contracts release defines them, `server/auth.js` checksthem with the contracts grant rule (exact id or `family.*`). A token's `ns` claim limits which--workflow namespaces it may run (`live.*`, `wiki.*`, …).++workflow namespaces it may run (`live.*`, `wiki.*`, …), and namespaces fail closed: a token with++no `ns` runs nothing outside a documented fallback. A first-party service token (`svc:<id>`) without++`ns` gets its `AI_NS_FALLBACK` entry (default `live=live.*|network.site_copy`, because Network grants++Live's `ai.run.create` with no namespaces), else `<id>.*`; app and module tokens without `ns` get++nothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever to++the old rule (no `ns` = every namespace).## The eleven record groups@@@ -80,8 +80,9 @@ structured workflow, point Live's call at it, and the passthrough key stays for3. **Import**: the same command without `--dry-run`. Re-running is safe: the ledger skips what isalready imported and applies changed settings once. Review `import_holds`.4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for-- audience `openvibe.ai`, namespaces `live.*` and `network.*` (the latter only while Live still-- answers `/internal/ai/site-copy`).++ audience `openvibe.ai`, namespaces `live.*` and `network.site_copy` (the latter only while Live still++ answers `/internal/ai/site-copy`). Namespaces fail closed; a grant without namespaces is covered++ by the default `AI_NS_FALLBACK` (`live=live.*|network.site_copy`).5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:```AI_SERVICE=remote@@@ -40,7 +40,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {const has = (req) => (id) => auth.principalHas(req, id);function checkNamespace(req, workflowKey) {-- if (!namespaceAllowed(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);++ if (!namespaceAllowed(req.principal, workflowKey, config.namespaces)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);}async function createAndRespond(req, res, body, waitMs) {@@@ -8,8 +8,12 @@* ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);* once contracts knows an id, contracts decides.*-- * Namespaces: a token whose `ns` claim is non-empty may only run workflows inside those namespaces-- * (the same matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate').++ * Namespaces fail closed: a token may only run workflows inside the namespaces it holds (the same++ * matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate'). The `ns` claim decides++ * when it is non-empty. A first-party service token WITHOUT one (Network grants Live's ai.run.create++ * with no namespaces) falls back to its entry in AI_NS_FALLBACK, else to its own `<service>.*`++ * (svc:news -> news.*). App and module tokens without `ns` run nothing. AI_NS_REQUIRED=false restores++ * the old rule (no `ns` = every namespace) and exists only as a rollback lever.*/const crypto = require('crypto');const { serviceAuth, capabilities, http } = require('openvibe-contracts');@@@ -86,10 +90,22 @@ function allows(claims, id) {return c;}--function namespaceAllowed(principal, workflowKey) {++const DEFAULT_NAMESPACES = Object.freeze({ required: true, fallback: Object.freeze({}), derive: true });++++/** The namespaces a principal may run: its `ns` claim, or (service tokens only) the documented fallback. */++function effectiveNamespaces(principal, nsConfig = DEFAULT_NAMESPACES) {++ const ns = principal && Array.isArray(principal.ns) ? principal.ns.filter(n => typeof n === 'string' && n) : [];++ if (ns.length) return ns;++ const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String((principal && principal.sub) || ''));++ if (!m || !nsConfig.derive) return [];++ const listed = nsConfig.fallback && Object.prototype.hasOwnProperty.call(nsConfig.fallback, m[1]) ? nsConfig.fallback[m[1]] : null;++ return listed && listed.length ? [...listed] : [`${m[1]}.*`];++}++++function namespaceAllowed(principal, workflowKey, nsConfig = DEFAULT_NAMESPACES) {const ns = principal && Array.isArray(principal.ns) ? principal.ns : [];-- if (!ns.length) return true;-- return capabilities.namespaceAllowed(ns, workflowKey);++ if (!ns.length && nsConfig.required === false) return true; // AI_NS_REQUIRED=false: the old, open rule++ return capabilities.namespaceAllowed(effectiveNamespaces(principal, nsConfig), workflowKey);}function bearer(req) {@@@ -140,4 +156,4 @@ function createAuth({ config, keys }) {return { verify, requireCap, principalHas };}--module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, principalSubject, bearer };++module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };@@@ -24,6 +24,25 @@ const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(Stconst list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));const trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');++/**++ * AI_NS_FALLBACK: namespaces for a service token that carries no `ns` claim, as++ * `service=ns|ns,service=ns`. A service not listed gets `<service>.*`; `none` turns the fallback off++ * (an ns-less token then runs nothing). The default covers Live, whose Network grant has no++ * namespaces: its own workflows, plus network.site_copy for its /internal/ai/site-copy fallback.++ */++const DEFAULT_NS_FALLBACK = 'live=live.*|network.site_copy';++function nsFallback(v) {++ const raw = v == null || v === '' ? DEFAULT_NS_FALLBACK : String(v).trim();++ if (/^(none|off|0|false)$/i.test(raw)) return { derive: false, fallback: {} };++ const fallback = {};++ for (const entry of raw.split(',').map(s => s.trim()).filter(Boolean)) {++ const m = /^([a-z][a-z0-9-]{1,39})\s*=\s*(.+)$/.exec(entry);++ if (!m) throw new Error(`AI_NS_FALLBACK: "${entry}" is not service=ns|ns`);++ fallback[m[1]] = m[2].split('|').map(s => s.trim()).filter(Boolean);++ }++ return { derive: true, fallback };++}++/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];@@@ -55,6 +74,9 @@ function load(env = process.env) {issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,audience: 'openvibe.ai',++ // Namespaces fail closed (server/auth.js): no `ns` claim, no namespaced run, except the++ // documented service fallback. AI_NS_REQUIRED=false is a rollback lever to the old open rule.++ namespaces: { required: bool(env.AI_NS_REQUIRED, true), ...nsFallback(env.AI_NS_FALLBACK) },dbPath: env.AI_DB_PATH || './data/ai.db',@@@ -63,6 +63,53 @@ t.test('a namespace-limited token may only run workflows in its namespaces', asyassert.strictEqual(r.body.code, 'capability.namespace_denied');});++t.test('namespaces fail closed: a token without ns runs nothing outside its own fallback', async () => {++ const run = (tok, workflow, input) => request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow, input } });++ const denied = (r, what) => { assert.strictEqual(r.status, 403, what); assert.strictEqual(r.body.code, 'capability.namespace_denied', what); };++ // A service with no ns claim (or an empty one) is held to <service>.*.++ denied(await run(token('games', ['ai.run.create'], { ns: null }), 'ai.generate', { prompt: 'hi' }), 'svc:games without ns -> ai.generate');++ denied(await run(token('games', ['ai.run.create'], { ns: [] }), 'wiki.generate_page', { title: 'x', sources: [{ source_type: 'web.page' }] }), 'svc:games with ns [] -> wiki.*');++ assert.notStrictEqual((await run(token('games', ['ai.run.create'], { ns: null }), 'games.generate_lore', {})).status, 403, 'svc:games -> games.*');++ // Live (Network grants it no namespaces): live.* and network.site_copy by the default AI_NS_FALLBACK, nothing else.++ const live = token('live', ['ai.run.create'], { ns: null });++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: live, body: { workflow: 'live.translate', input: { text: 'bonjour tout le monde', from: 'fr', to: 'en' } } })).status, 201);++ assert.notStrictEqual((await run(live, 'network.site_copy', { sites: [], links: [] })).status, 403, 'svc:live -> network.site_copy');++ denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live without ns -> ai.generate');++ denied(await run(live, 'network.other', {}), 'svc:live without ns -> network.other');++ denied(await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: 'hi' } }), 'direct operations are ai.* runs');++ // App and module tokens have no fallback.++ const app = token('x', ['ai.run.create'], { ns: null, sub: 'app:app_01HZZZZZZZZZZZZZZZZZZZZZZZ', actorType: 'app', extra: { project_id: 'prj_01HZZZZZZZZZZZZZZZZZZZZZZZ', env: 'production' } });++ denied(await run(app, 'ai.generate', { prompt: 'hi' }), 'app token without ns');++ // An explicit ns claim always decides (Network's own token: network.*).++ const network = token('network', ['ai.run.create'], { ns: ['network.*'] });++ denied(await run(network, 'live.translate', { text: 'hola', from: 'es', to: 'en' }), 'svc:network ns network.* -> live.*');++});++++t.test('AI_NS_FALLBACK and AI_NS_REQUIRED', () => {++ const { load } = require('../server/config');++ const { namespaceAllowed } = require('../server/auth');++ const svc = (id, ns) => ({ sub: `svc:${id}`, ns });++ const d = load({}).namespaces;++ assert.deepStrictEqual(d, { required: true, derive: true, fallback: { live: ['live.*', 'network.site_copy'] } });++ assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', d), true);++ assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', d), true);++ assert.strictEqual(namespaceAllowed(svc('live', []), 'ai.generate', d), false);++ assert.strictEqual(namespaceAllowed(svc('news', undefined), 'news.summarize_story', d), true);++ assert.strictEqual(namespaceAllowed({ sub: 'svc:live' }, 'wiki.generate_page', d), false);++ assert.strictEqual(namespaceAllowed(null, 'ai.generate', d), false);++ const strict = load({ AI_NS_FALLBACK: 'none' }).namespaces;++ assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', strict), false, 'none: no fallback at all');++ assert.strictEqual(namespaceAllowed(svc('live', ['live.*']), 'live.translate', strict), true);++ const custom = load({ AI_NS_FALLBACK: 'live=live.*, news=news.summarize_story|ai.summarize' }).namespaces;++ assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', custom), false);++ assert.strictEqual(namespaceAllowed(svc('news', []), 'ai.summarize', custom), true);++ assert.strictEqual(namespaceAllowed(svc('news', []), 'news.compare_perspectives', custom), false);++ assert.throws(() => load({ AI_NS_FALLBACK: 'live' }), /AI_NS_FALLBACK/);++ const open = load({ AI_NS_REQUIRED: 'false' }).namespaces;++ assert.strictEqual(namespaceAllowed(svc('games', []), 'ai.generate', open), true, 'rollback lever: the old open rule');++ assert.strictEqual(namespaceAllowed(svc('games', ['games.*']), 'ai.generate', open), false, 'an ns claim still decides');++});++t.test('provider responses never contain a secret value', async () => {const admin = token('ops', ALL);const list = await request(h.base, 'GET', '/api/v1/providers', { tok: admin });@@@ -21,10 +21,14 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {});const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };--function token(slug, cap, { aud = 'openvibe.ai', ns = [], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub } = {}) {++/**++ * Namespaces fail closed, so test tokens hold every namespace ('*') unless a test says otherwise;++ * `ns: null` leaves the claim out (what Network issues for a grant without namespaces).++ */++function token(slug, cap, { aud = 'openvibe.ai', ns = ['*'], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub, actorType = 'service', extra = {} } = {}) {const now = Math.floor(Date.now() / 1000);return serviceAuth.signServiceToken({-- iss: ISSUER, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns, iat: now, exp,++ iss: ISSUER, sub: sub || `svc:${slug}`, actor_type: actorType, aud: [aud], cap, ...(ns === null ? {} : { ns }), iat: now, exp, ...extra,jti: `tok_${crypto.randomBytes(8).toString('hex')}`,}, key);}
@@ -18,6 +18,11 @@ AI_DB_PATH=./data/ai.db
@@ -18,6 +18,11 @@ AI_DB_PATH=./data/ai.db
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#OV_NETWORK_ISSUER=https://openvibe.network
#OV_NETWORK_ISSUER=https://openvibe.network
+# Namespaces fail closed: a token with no `ns` claim may only run its fallback namespaces. A service
+# token (svc:<id>) without ns gets its entry here (service=ns|ns,...), else <id>.*; `none` = no fallback.
+#AI_NS_FALLBACK=live=live.*|network.site_copy
+# Rollback lever only: false restores the old rule (a token without ns may run every namespace).
+#AI_NS_REQUIRED=true
#OV_NETWORK_PUBLIC_KEY=
#OV_NETWORK_PUBLIC_KEY=
# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
@@ -70,7 +70,12 @@ produce a real answer ends `failed` with an explicit code — `provider.unavaila
@@ -70,7 +70,12 @@ produce a real answer ends `failed` with an explicit code — `provider.unavaila
The capability ids are proposed for openvibe-contracts in `docs/capabilities-proposal/` (with the
The capability ids are proposed for openvibe-contracts in `docs/capabilities-proposal/` (with the
replacement `ai` service manifest). Until a contracts release defines them, `server/auth.js` checks
replacement `ai` service manifest). Until a contracts release defines them, `server/auth.js` checks
them with the contracts grant rule (exact id or `family.*`). A token's `ns` claim limits which
them with the contracts grant rule (exact id or `family.*`). A token's `ns` claim limits which
-workflow namespaces it may run (`live.*`, `wiki.*`, …).
+workflow namespaces it may run (`live.*`, `wiki.*`, …), and namespaces fail closed: a token with
+no `ns` runs nothing outside a documented fallback. A first-party service token (`svc:<id>`) without
+`ns` gets its `AI_NS_FALLBACK` entry (default `live=live.*|network.site_copy`, because Network grants
+Live's `ai.run.create` with no namespaces), else `<id>.*`; app and module tokens without `ns` get
+nothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever to
+the old rule (no `ns` = every namespace).
## The eleven record groups
## The eleven record groups
@@ -80,8 +80,9 @@ structured workflow, point Live's call at it, and the passthrough key stays for
@@ -80,8 +80,9 @@ structured workflow, point Live's call at it, and the passthrough key stays for
3. **Import**: the same command without `--dry-run`. Re-running is safe: the ledger skips what is
3. **Import**: the same command without `--dry-run`. Re-running is safe: the ledger skips what is
already imported and applies changed settings once. Review `import_holds`.
already imported and applies changed settings once. Review `import_holds`.
4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for
4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for
- audience `openvibe.ai`, namespaces `live.*` and `network.*` (the latter only while Live still
- answers `/internal/ai/site-copy`).
+ audience `openvibe.ai`, namespaces `live.*` and `network.site_copy` (the latter only while Live still
+ answers `/internal/ai/site-copy`). Namespaces fail closed; a grant without namespaces is covered
+ by the default `AI_NS_FALLBACK` (`live=live.*|network.site_copy`).
5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:
5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:
```
```
AI_SERVICE=remote
AI_SERVICE=remote
@@ -40,7 +40,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -40,7 +40,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
const has = (req) => (id) => auth.principalHas(req, id);
const has = (req) => (id) => auth.principalHas(req, id);
function checkNamespace(req, workflowKey) {
function checkNamespace(req, workflowKey) {
- if (!namespaceAllowed(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
+ if (!namespaceAllowed(req.principal, workflowKey, config.namespaces)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
}
}
async function createAndRespond(req, res, body, waitMs) {
async function createAndRespond(req, res, body, waitMs) {
@@ -8,8 +8,12 @@
@@ -8,8 +8,12 @@
* ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);
* ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);
* once contracts knows an id, contracts decides.
* once contracts knows an id, contracts decides.
*
*
- * Namespaces: a token whose `ns` claim is non-empty may only run workflows inside those namespaces
- * (the same matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate').
+ * Namespaces fail closed: a token may only run workflows inside the namespaces it holds (the same
+ * matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate'). The `ns` claim decides
+ * when it is non-empty. A first-party service token WITHOUT one (Network grants Live's ai.run.create
+ * with no namespaces) falls back to its entry in AI_NS_FALLBACK, else to its own `<service>.*`
+ * (svc:news -> news.*). App and module tokens without `ns` run nothing. AI_NS_REQUIRED=false restores
+ * the old rule (no `ns` = every namespace) and exists only as a rollback lever.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
@@ -86,10 +90,22 @@ function allows(claims, id) {
@@ -86,10 +90,22 @@ function allows(claims, id) {
return c;
return c;
}
}
-function namespaceAllowed(principal, workflowKey) {
+const DEFAULT_NAMESPACES = Object.freeze({ required: true, fallback: Object.freeze({}), derive: true });
+
+/** The namespaces a principal may run: its `ns` claim, or (service tokens only) the documented fallback. */
+function effectiveNamespaces(principal, nsConfig = DEFAULT_NAMESPACES) {
+ const ns = principal && Array.isArray(principal.ns) ? principal.ns.filter(n => typeof n === 'string' && n) : [];
+ if (ns.length) return ns;
+ const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String((principal && principal.sub) || ''));
+ if (!m || !nsConfig.derive) return [];
+ const listed = nsConfig.fallback && Object.prototype.hasOwnProperty.call(nsConfig.fallback, m[1]) ? nsConfig.fallback[m[1]] : null;
+ return listed && listed.length ? [...listed] : [`${m[1]}.*`];
+}
+
+function namespaceAllowed(principal, workflowKey, nsConfig = DEFAULT_NAMESPACES) {
const ns = principal && Array.isArray(principal.ns) ? principal.ns : [];
const ns = principal && Array.isArray(principal.ns) ? principal.ns : [];
- if (!ns.length) return true;
- return capabilities.namespaceAllowed(ns, workflowKey);
+ if (!ns.length && nsConfig.required === false) return true; // AI_NS_REQUIRED=false: the old, open rule
+ return capabilities.namespaceAllowed(effectiveNamespaces(principal, nsConfig), workflowKey);
}
}
function bearer(req) {
function bearer(req) {
@@ -140,4 +156,4 @@ function createAuth({ config, keys }) {
@@ -140,4 +156,4 @@ function createAuth({ config, keys }) {
return { verify, requireCap, principalHas };
return { verify, requireCap, principalHas };
}
}
-module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, principalSubject, bearer };
+module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
@@ -24,6 +24,25 @@ const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(St
@@ -24,6 +24,25 @@ const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(St
const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
const trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');
const trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');
+/**
+ * AI_NS_FALLBACK: namespaces for a service token that carries no `ns` claim, as
+ * `service=ns|ns,service=ns`. A service not listed gets `<service>.*`; `none` turns the fallback off
+ * (an ns-less token then runs nothing). The default covers Live, whose Network grant has no
+ * namespaces: its own workflows, plus network.site_copy for its /internal/ai/site-copy fallback.
+ */
+const DEFAULT_NS_FALLBACK = 'live=live.*|network.site_copy';
+function nsFallback(v) {
+ const raw = v == null || v === '' ? DEFAULT_NS_FALLBACK : String(v).trim();
+ if (/^(none|off|0|false)$/i.test(raw)) return { derive: false, fallback: {} };
+ const fallback = {};
+ for (const entry of raw.split(',').map(s => s.trim()).filter(Boolean)) {
+ const m = /^([a-z][a-z0-9-]{1,39})\s*=\s*(.+)$/.exec(entry);
+ if (!m) throw new Error(`AI_NS_FALLBACK: "${entry}" is not service=ns|ns`);
+ fallback[m[1]] = m[2].split('|').map(s => s.trim()).filter(Boolean);
+ }
+ return { derive: true, fallback };
+}
+
/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */
/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */
const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
@@ -55,6 +74,9 @@ function load(env = process.env) {
@@ -55,6 +74,9 @@ function load(env = process.env) {
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
audience: 'openvibe.ai',
audience: 'openvibe.ai',
+ // Namespaces fail closed (server/auth.js): no `ns` claim, no namespaced run, except the
+ // documented service fallback. AI_NS_REQUIRED=false is a rollback lever to the old open rule.
+ namespaces: { required: bool(env.AI_NS_REQUIRED, true), ...nsFallback(env.AI_NS_FALLBACK) },
dbPath: env.AI_DB_PATH || './data/ai.db',
dbPath: env.AI_DB_PATH || './data/ai.db',
@@ -63,6 +63,53 @@ t.test('a namespace-limited token may only run workflows in its namespaces', asy
@@ -63,6 +63,53 @@ t.test('a namespace-limited token may only run workflows in its namespaces', asy
assert.strictEqual(r.body.code, 'capability.namespace_denied');
assert.strictEqual(r.body.code, 'capability.namespace_denied');
});
});
+t.test('namespaces fail closed: a token without ns runs nothing outside its own fallback', async () => {
+ const run = (tok, workflow, input) => request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow, input } });
+ const denied = (r, what) => { assert.strictEqual(r.status, 403, what); assert.strictEqual(r.body.code, 'capability.namespace_denied', what); };
+ // A service with no ns claim (or an empty one) is held to <service>.*.
+ denied(await run(token('games', ['ai.run.create'], { ns: null }), 'ai.generate', { prompt: 'hi' }), 'svc:games without ns -> ai.generate');
+ denied(await run(token('games', ['ai.run.create'], { ns: [] }), 'wiki.generate_page', { title: 'x', sources: [{ source_type: 'web.page' }] }), 'svc:games with ns [] -> wiki.*');
+ assert.notStrictEqual((await run(token('games', ['ai.run.create'], { ns: null }), 'games.generate_lore', {})).status, 403, 'svc:games -> games.*');
+ // Live (Network grants it no namespaces): live.* and network.site_copy by the default AI_NS_FALLBACK, nothing else.
+ const live = token('live', ['ai.run.create'], { ns: null });
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: live, body: { workflow: 'live.translate', input: { text: 'bonjour tout le monde', from: 'fr', to: 'en' } } })).status, 201);
+ assert.notStrictEqual((await run(live, 'network.site_copy', { sites: [], links: [] })).status, 403, 'svc:live -> network.site_copy');
+ denied(await run(live, 'ai.generate', { prompt: 'hi' }), 'svc:live without ns -> ai.generate');
+ denied(await run(live, 'network.other', {}), 'svc:live without ns -> network.other');
+ denied(await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: 'hi' } }), 'direct operations are ai.* runs');
+ // App and module tokens have no fallback.
+ const app = token('x', ['ai.run.create'], { ns: null, sub: 'app:app_01HZZZZZZZZZZZZZZZZZZZZZZZ', actorType: 'app', extra: { project_id: 'prj_01HZZZZZZZZZZZZZZZZZZZZZZZ', env: 'production' } });
+ denied(await run(app, 'ai.generate', { prompt: 'hi' }), 'app token without ns');
+ // An explicit ns claim always decides (Network's own token: network.*).
+ const network = token('network', ['ai.run.create'], { ns: ['network.*'] });
+ denied(await run(network, 'live.translate', { text: 'hola', from: 'es', to: 'en' }), 'svc:network ns network.* -> live.*');
+});
+
+t.test('AI_NS_FALLBACK and AI_NS_REQUIRED', () => {
+ const { load } = require('../server/config');
+ const { namespaceAllowed } = require('../server/auth');
+ const svc = (id, ns) => ({ sub: `svc:${id}`, ns });
+ const d = load({}).namespaces;
+ assert.deepStrictEqual(d, { required: true, derive: true, fallback: { live: ['live.*', 'network.site_copy'] } });
+ assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', d), true);
+ assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', d), true);
+ assert.strictEqual(namespaceAllowed(svc('live', []), 'ai.generate', d), false);
+ assert.strictEqual(namespaceAllowed(svc('news', undefined), 'news.summarize_story', d), true);
+ assert.strictEqual(namespaceAllowed({ sub: 'svc:live' }, 'wiki.generate_page', d), false);
+ assert.strictEqual(namespaceAllowed(null, 'ai.generate', d), false);
+ const strict = load({ AI_NS_FALLBACK: 'none' }).namespaces;
+ assert.strictEqual(namespaceAllowed(svc('live', []), 'live.translate', strict), false, 'none: no fallback at all');
+ assert.strictEqual(namespaceAllowed(svc('live', ['live.*']), 'live.translate', strict), true);
+ const custom = load({ AI_NS_FALLBACK: 'live=live.*, news=news.summarize_story|ai.summarize' }).namespaces;
+ assert.strictEqual(namespaceAllowed(svc('live', []), 'network.site_copy', custom), false);
+ assert.strictEqual(namespaceAllowed(svc('news', []), 'ai.summarize', custom), true);
+ assert.strictEqual(namespaceAllowed(svc('news', []), 'news.compare_perspectives', custom), false);
+ assert.throws(() => load({ AI_NS_FALLBACK: 'live' }), /AI_NS_FALLBACK/);
+ const open = load({ AI_NS_REQUIRED: 'false' }).namespaces;
+ assert.strictEqual(namespaceAllowed(svc('games', []), 'ai.generate', open), true, 'rollback lever: the old open rule');
+ assert.strictEqual(namespaceAllowed(svc('games', ['games.*']), 'ai.generate', open), false, 'an ns claim still decides');
+});
+
t.test('provider responses never contain a secret value', async () => {
t.test('provider responses never contain a secret value', async () => {
const admin = token('ops', ALL);
const admin = token('ops', ALL);
const list = await request(h.base, 'GET', '/api/v1/providers', { tok: admin });
const list = await request(h.base, 'GET', '/api/v1/providers', { tok: admin });
@@ -21,10 +21,14 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
@@ -21,10 +21,14 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
});
});
const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
-function token(slug, cap, { aud = 'openvibe.ai', ns = [], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub } = {}) {
+/**
+ * Namespaces fail closed, so test tokens hold every namespace ('*') unless a test says otherwise;
+ * `ns: null` leaves the claim out (what Network issues for a grant without namespaces).
+ */
+function token(slug, cap, { aud = 'openvibe.ai', ns = ['*'], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub, actorType = 'service', extra = {} } = {}) {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({
return serviceAuth.signServiceToken({
- iss: ISSUER, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns, iat: now, exp,
+ iss: ISSUER, sub: sub || `svc:${slug}`, actor_type: actorType, aud: [aud], cap, ...(ns === null ? {} : { ns }), iat: now, exp, ...extra,
jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
}, key);
}, key);
}
}