openvibers/openvibe.reviews Commits

App and module tokens act only for their on_behalf_of person; sandbox tokens refused

OP OpenVibers committed 23 Sept 2026 · ffcad934ad · 3 files, +74 −3

A developer-app (app:) or module (mod:) token granted a Reviews capability could send X-OV-Subject
naming any usr_ id, including a Reviews editor, and act with that person's rights (create, merge
and split entities, settle items). Third-party tokens now act for their on_behalf_of subject only
(a different X-OV-Subject is 403 subject.not_delegated) and non-production tokens are 401
token.sandbox_refused. First-party svc: principals are unchanged. Same fix as
OpenVibe.Community cac1df9.

Changes

ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 19efba9..17cc98f 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -9,6 +9,8 @@
* { kind: 'service', service: 'svc:x', claims, subject }
* A Network client-credentials token for audience openvibe.reviews. It names the person it acts
* for in X-OV-Subject (usr_…); every route checks one capability against the token.
++ * Developer-app (app:…) and module (mod:…) tokens act only for the person in their
++ * on_behalf_of claim: X-OV-Subject naming anyone else is 403, and sandbox tokens are 401.
*
* Identity never comes from a body or a query. A request that presents a service token is judged
* on that token alone: a bad one is refused, never downgraded to anonymous.
@@@ -44,13 +46,26 @@ function createViewerResolver({ keys, config }) {
if (!publicKey) throw new AuthError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
if (!r.ok) throw new AuthError(401, r.code, r.reason);
++ // Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person
++ // who authorized them (on_behalf_of), never for whoever X-OV-Subject names. Only first-party
++ // service principals (svc:…) are trusted to name the acting person.
++ const claims = r.claims;
++ const firstParty = claims.actor_type === 'service' && String(claims.sub).startsWith('svc:');
++ if (!firstParty && claims.env !== undefined && claims.env !== 'production') {
++ throw new AuthError(401, 'token.sandbox_refused', 'sandbox tokens are not accepted by openvibe.reviews');
++ }
const header = req.get('x-ov-subject');
let subject = null;
if (header) {
if (!ids.isSubjectId('user', header)) throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
++ if (!firstParty && header !== claims.on_behalf_of) {
++ throw new AuthError(403, 'subject.not_delegated', 'an app acts only for the person who authorized it (on_behalf_of)');
++ }
subject = header;
++ } else if (!firstParty && ids.isSubjectId('user', claims.on_behalf_of)) {
++ subject = claims.on_behalf_of;
}
-- return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, staff: false };
++ return { kind: 'service', service: claims.sub, claims, subject, staff: false };
}
async function fromUserToken(token, { strict }) {
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
nnew file mode 100644
iindex 0000000..86a689c
---- /dev/null
++++ b/test/delegation.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * Third-party principals: a developer app (app:…) or module (mod:…) token acts only for the person
++ * in its on_behalf_of claim. X-OV-Subject naming anyone else (here: a Reviews editor) is refused
++ * (403 subject.not_delegated), and so are sandbox tokens (401 token.sandbox_refused). First-party
++ * services (svc:…) still name the person they act for.
++ */
++const assert = require('assert');
++const { boot, req, suite, serviceToken, subject, EDITOR, READER } = require('./helpers');
++
++const t = suite('delegation');
++const APP = 'app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
++const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
++const CAPS = ['reviews.entity.manage'];
++const token = (sub, actorType, extra) => serviceToken({ sub, actorType, cap: CAPS, extra });
++const entity = (name) => ({ name, kind: 'product' });
++const count = (h, name) => h.db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE name = ?').get(name).n;
++
++t('an app or module cannot act as an editor by naming them in X-OV-Subject', async () => {
++ const h = await boot();
++ try {
++ for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
++ for (const extra of [{ on_behalf_of: READER }, {}]) {
++ const r = await req(h, 'POST', '/api/v1/entities', { token: token(sub, type, extra), headers: { 'X-OV-Subject': EDITOR }, body: entity(`Planted by ${type}`) });
++ assert.strictEqual(r.status, 403, `${type} ${JSON.stringify(extra)}: ${r.text}`);
++ assert.strictEqual(r.json.code, 'subject.not_delegated');
++ assert.strictEqual(count(h, `Planted by ${type}`), 0);
++ }
++ }
++ } finally { await h.stop(); }
++});
++
++t('an app acts for its on_behalf_of person, with that person\'s rights', async () => {
++ const h = await boot();
++ try {
++ let r = await req(h, 'POST', '/api/v1/entities', { token: token(APP, 'app', { on_behalf_of: READER }), body: entity('Reader attempt') });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.json.code, 'reviews.editor_required', 'acts as the reader, who is no editor');
++ r = await req(h, 'POST', '/api/v1/entities', { token: token(APP, 'app', { on_behalf_of: EDITOR }), headers: { 'X-OV-Subject': EDITOR }, body: entity('Editor via app') });
++ assert.strictEqual(r.status, 201, r.text);
++ } finally { await h.stop(); }
++});
++
++t('sandbox app tokens are refused; first-party services still name the person', async () => {
++ const h = await boot();
++ try {
++ let r = await req(h, 'POST', '/api/v1/entities', { token: token(APP, 'app', { on_behalf_of: EDITOR, env: 'sandbox' }), body: entity('Sandbox') });
++ assert.strictEqual(r.status, 401, r.text);
++ assert.strictEqual(r.json.code, 'token.sandbox_refused');
++ r = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ client: 'tools', cap: CAPS }), headers: { 'X-OV-Subject': EDITOR }, body: entity('Editor via svc') });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ client: 'tools', cap: CAPS }), headers: { 'X-OV-Subject': subject() }, body: entity('Stranger via svc') });
++ assert.strictEqual(r.status, 403, 'a first-party service acting for a non-editor is no editor');
++ } finally { await h.stop(); }
++});
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 4ca0ea0..eb169f1 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -31,9 +31,10 @@ function userToken({ subject: sub = READER, username = 'someone', role = 'user',
const editorToken = () => userToken({ subject: EDITOR, username: 'editor' });
const readerToken = () => userToken({ subject: READER, username: 'reader' });
--function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.reviews', exp = 300 } = {}) {
++/** A principal token: svc:<client> by default; `sub` + `actorType` for an app:/mod: principal, `extra` for on_behalf_of, env… */
++function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.reviews', exp = 300, sub, actorType = 'service', extra = {} } = {}) {
const now = Math.floor(Date.now() / 1000);
-- return serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${client}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privateKey);
++ return serviceAuth.signServiceToken({ iss: ISSUER, sub: sub || `svc:${client}`, actor_type: actorType, aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privateKey);
}
const quiet = { log() {}, warn() {}, error() {} };