openvibers/openvibe.wiki Commits
App and module tokens act only for their on_behalf_of person; sandbox tokens refused
Changes
@@@ -9,6 +9,8 @@* { kind: 'service', service: 'svc:x', claims, subject }* A Network client-credentials token for audience openvibe.wiki. 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.wiki');++ }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 }) {@@@ -447,8 +447,10 @@ function createWikiService({ db, stores, outbox, config, community = null, now =if (!content.SPACE_SLUG_RE.test(cleanSlug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');if (!SPACE_KINDS.includes(kind)) fail(422, 'space.invalid_kind', 'kind is official or user');const vis = checkVisibility(visibility, 'public');-- // Official (editorial) spaces: Network staff, this service, or a service acting as itself.-- if (kind === 'official' && !(actor && (actor.kind === 'system' || (actor.kind === 'user' && actor.staff) || (actor.kind === 'service' && !actor.subject)))) {++ // Official (editorial) spaces: Network staff, this service, or a first-party service (svc:…)++ // acting as itself. A developer app or module (app:…, mod:…) never does.++ const firstPartyService = actor && actor.kind === 'service' && !actor.subject && /^svc:/.test(String(actor.service));++ if (kind === 'official' && !(actor && (actor.kind === 'system' || (actor.kind === 'user' && actor.staff) || firstPartyService))) {fail(403, 'space.official_staff_only', 'Only OpenVibe staff create official spaces');}if (kind === 'user') requirePerson(actor);nnew file mode 100644@@@ -0,0 +1,69 @@++'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 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, and only they may act as themselves to create official spaces.++ */++const assert = require('assert');++const H = require('./helpers');++++const APP = 'app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';++const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';++const CAPS = ['wiki.space.create', 'wiki.page.create', 'wiki.page.read'];++++(async () => {++ const h = await H.boot();++ const victim = H.subject();++ const appUser = H.subject();++ const token = (sub, actorType, extra) => H.serviceToken({ sub, actorType, cap: CAPS, extra });++ try {++ const victimTok = H.userToken({ subject: victim });++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: victimTok, body: { name: 'Private notes', slug: 'victim-notes', visibility: 'private' } });++ assert.strictEqual(r.status, 201, r.text);++ r = await H.req(h, 'POST', '/api/v1/spaces/victim-notes/pages', { token: victimTok, body: { title: 'Secret', body: H.LONG } });++ assert.strictEqual(r.status, 201, r.text);++ const secretId = r.json.page.id;++++ // An app or module cannot name the victim in X-OV-Subject, with or without its own on_behalf_of.++ for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {++ for (const extra of [{ on_behalf_of: appUser }, {}]) {++ r = await H.req(h, 'POST', '/api/v1/spaces/victim-notes/pages', { token: token(sub, type, extra), headers: { 'X-OV-Subject': victim }, body: { title: `Planted ${type}`, body: H.LONG } });++ assert.strictEqual(r.status, 403, `${type} ${JSON.stringify(extra)}: ${r.text}`);++ assert.strictEqual(r.json.code, 'subject.not_delegated');++ r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: token(sub, type, extra), headers: { 'X-OV-Subject': victim } });++ assert.strictEqual(r.status, 403, `${type} read as the victim`);++ }++ }++ assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get().n, 0);++++ // An app acts for its on_behalf_of person (the header is optional and must match).++ const appTok = token(APP, 'app', { on_behalf_of: appUser });++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: appTok, body: { name: 'App user space', slug: 'app-user' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(h.svc.findSpace('app-user').owner, appUser);++ r = await H.req(h, 'POST', '/api/v1/spaces/app-user/pages', { token: appTok, headers: { 'X-OV-Subject': appUser }, body: { title: 'Mine', body: H.LONG } });++ assert.strictEqual(r.status, 201, r.text);++ r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: appTok });++ assert.strictEqual(r.status, 404, 'the app user has no access to the victim\'s private space');++++ // Sandbox app tokens are refused.++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: token(APP, 'app', { on_behalf_of: appUser, env: 'sandbox' }), body: { name: 'Sandbox', slug: 'sandbox-space' } });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json.code, 'token.sandbox_refused');++++ // Only a first-party service acting as itself creates an official space; an app never does.++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: token(APP, 'app', {}), body: { name: 'Official by app', slug: 'app-official', kind: 'official' } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'space.official_staff_only');++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: H.serviceToken({ client: 'tools', cap: CAPS }), body: { name: 'Official', slug: 'svc-official', kind: 'official' } });++ assert.strictEqual(r.status, 201, r.text);++++ // First-party services still name the person they act for.++ r = await H.req(h, 'POST', '/api/v1/spaces/victim-notes/pages', { token: H.serviceToken({ client: 'tools', cap: CAPS }), headers: { 'X-OV-Subject': victim }, body: { title: 'For the owner', body: H.LONG } });++ assert.strictEqual(r.status, 201, r.text);++ console.log('delegation: ok');++ } finally {++ await h.stop();++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -22,9 +22,10 @@ function userToken({ subject: sub = subject(), username = 'someone', role = 'usereturn serviceAuth.signServiceToken({ iss: issuer, aud, sub: 42, id: 42, subject_id: sub, username, display_name: username, role, iat: now, exp: now + exp }, privateKey);}--function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', 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.wiki', 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() {} };
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
* { kind: 'service', service: 'svc:x', claims, subject }
* { kind: 'service', service: 'svc:x', claims, subject }
* A Network client-credentials token for audience openvibe.wiki. It names the person it acts
* A Network client-credentials token for audience openvibe.wiki. It names the person it acts
* for in X-OV-Subject (usr_…); every route checks one capability against the token.
* 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
* 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.
* on that token alone: a bad one is refused, never downgraded to anonymous.
@@ -44,13 +46,26 @@ function createViewerResolver({ keys, config }) {
@@ -44,13 +46,26 @@ function createViewerResolver({ keys, config }) {
if (!publicKey) throw new AuthError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
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 });
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
if (!r.ok) throw new AuthError(401, r.code, r.reason);
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.wiki');
+ }
const header = req.get('x-ov-subject');
const header = req.get('x-ov-subject');
let subject = null;
let subject = null;
if (header) {
if (header) {
if (!ids.isSubjectId('user', header)) throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
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;
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 }) {
async function fromUserToken(token, { strict }) {
@@ -447,8 +447,10 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -447,8 +447,10 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (!content.SPACE_SLUG_RE.test(cleanSlug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
if (!content.SPACE_SLUG_RE.test(cleanSlug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
if (!SPACE_KINDS.includes(kind)) fail(422, 'space.invalid_kind', 'kind is official or user');
if (!SPACE_KINDS.includes(kind)) fail(422, 'space.invalid_kind', 'kind is official or user');
const vis = checkVisibility(visibility, 'public');
const vis = checkVisibility(visibility, 'public');
- // Official (editorial) spaces: Network staff, this service, or a service acting as itself.
- if (kind === 'official' && !(actor && (actor.kind === 'system' || (actor.kind === 'user' && actor.staff) || (actor.kind === 'service' && !actor.subject)))) {
+ // Official (editorial) spaces: Network staff, this service, or a first-party service (svc:…)
+ // acting as itself. A developer app or module (app:…, mod:…) never does.
+ const firstPartyService = actor && actor.kind === 'service' && !actor.subject && /^svc:/.test(String(actor.service));
+ if (kind === 'official' && !(actor && (actor.kind === 'system' || (actor.kind === 'user' && actor.staff) || firstPartyService))) {
fail(403, 'space.official_staff_only', 'Only OpenVibe staff create official spaces');
fail(403, 'space.official_staff_only', 'Only OpenVibe staff create official spaces');
}
}
if (kind === 'user') requirePerson(actor);
if (kind === 'user') requirePerson(actor);
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'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 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, and only they may act as themselves to create official spaces.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+
+const APP = 'app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
+const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
+const CAPS = ['wiki.space.create', 'wiki.page.create', 'wiki.page.read'];
+
+(async () => {
+ const h = await H.boot();
+ const victim = H.subject();
+ const appUser = H.subject();
+ const token = (sub, actorType, extra) => H.serviceToken({ sub, actorType, cap: CAPS, extra });
+ try {
+ const victimTok = H.userToken({ subject: victim });
+ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: victimTok, body: { name: 'Private notes', slug: 'victim-notes', visibility: 'private' } });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await H.req(h, 'POST', '/api/v1/spaces/victim-notes/pages', { token: victimTok, body: { title: 'Secret', body: H.LONG } });
+ assert.strictEqual(r.status, 201, r.text);
+ const secretId = r.json.page.id;
+
+ // An app or module cannot name the victim in X-OV-Subject, with or without its own on_behalf_of.
+ for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
+ for (const extra of [{ on_behalf_of: appUser }, {}]) {
+ r = await H.req(h, 'POST', '/api/v1/spaces/victim-notes/pages', { token: token(sub, type, extra), headers: { 'X-OV-Subject': victim }, body: { title: `Planted ${type}`, body: H.LONG } });
+ assert.strictEqual(r.status, 403, `${type} ${JSON.stringify(extra)}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'subject.not_delegated');
+ r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: token(sub, type, extra), headers: { 'X-OV-Subject': victim } });
+ assert.strictEqual(r.status, 403, `${type} read as the victim`);
+ }
+ }
+ assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get().n, 0);
+
+ // An app acts for its on_behalf_of person (the header is optional and must match).
+ const appTok = token(APP, 'app', { on_behalf_of: appUser });
+ r = await H.req(h, 'POST', '/api/v1/spaces', { token: appTok, body: { name: 'App user space', slug: 'app-user' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(h.svc.findSpace('app-user').owner, appUser);
+ r = await H.req(h, 'POST', '/api/v1/spaces/app-user/pages', { token: appTok, headers: { 'X-OV-Subject': appUser }, body: { title: 'Mine', body: H.LONG } });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: appTok });
+ assert.strictEqual(r.status, 404, 'the app user has no access to the victim\'s private space');
+
+ // Sandbox app tokens are refused.
+ r = await H.req(h, 'POST', '/api/v1/spaces', { token: token(APP, 'app', { on_behalf_of: appUser, env: 'sandbox' }), body: { name: 'Sandbox', slug: 'sandbox-space' } });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json.code, 'token.sandbox_refused');
+
+ // Only a first-party service acting as itself creates an official space; an app never does.
+ r = await H.req(h, 'POST', '/api/v1/spaces', { token: token(APP, 'app', {}), body: { name: 'Official by app', slug: 'app-official', kind: 'official' } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'space.official_staff_only');
+ r = await H.req(h, 'POST', '/api/v1/spaces', { token: H.serviceToken({ client: 'tools', cap: CAPS }), body: { name: 'Official', slug: 'svc-official', kind: 'official' } });
+ assert.strictEqual(r.status, 201, r.text);
+
+ // First-party services still name the person they act for.
+ r = await H.req(h, 'POST', '/api/v1/spaces/victim-notes/pages', { token: H.serviceToken({ client: 'tools', cap: CAPS }), headers: { 'X-OV-Subject': victim }, body: { title: 'For the owner', body: H.LONG } });
+ assert.strictEqual(r.status, 201, r.text);
+ console.log('delegation: ok');
+ } finally {
+ await h.stop();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -22,9 +22,10 @@ function userToken({ subject: sub = subject(), username = 'someone', role = 'use
@@ -22,9 +22,10 @@ function userToken({ subject: sub = subject(), username = 'someone', role = 'use
return serviceAuth.signServiceToken({ iss: issuer, aud, sub: 42, id: 42, subject_id: sub, username, display_name: username, role, iat: now, exp: now + exp }, privateKey);
return serviceAuth.signServiceToken({ iss: issuer, aud, sub: 42, id: 42, subject_id: sub, username, display_name: username, role, iat: now, exp: now + exp }, privateKey);
}
}
-function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', 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.wiki', exp = 300, sub, actorType = 'service', extra = {} } = {}) {
const now = Math.floor(Date.now() / 1000);
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() {} };
const quiet = { log() {}, warn() {}, error() {} };