openvibers/openvibe.billing Commits

Staff console access from the contracts staff map (WS-D, ADR-022; Contracts 0.38.0): a listed subject must also hold staff.money.cashouts (money is the owner's), not role admin; the session keeps the effective role and is re-checked against the map on every request, so existing admin-role sessions end and the owner signs in again. test/staff-map.test.js fails on raw role checks

OP OpenVibers committed 24 Sept 2026 · 85fd198ddb · 9 files, +58 −22


Changes

ddiff --git a/.env.example b/.env.example
iindex e00565f..8515918 100644
---- a/.env.example
++++ b/.env.example
@@@ -39,8 +39,9 @@ OV_OAUTH_CLIENT_SECRET=change-me
# ── Staff console (Network SSO; see README "Staff console") ──
# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
# as a redirect URI of OAuth client `billing`.
--# Comma-separated usr_ subject ids allowed to use the console. Each must ALSO be a Network admin
--# (role admin in their Network token). Empty = nobody can sign in.
++# Comma-separated usr_ subject ids allowed to use the console. Each must ALSO hold staff.money.cashouts
++# in the contracts staff map (money is the owner's: role admin with is_owner in the Network token).
++# Empty = nobody can sign in.
BILLING_STAFF_SUBJECTS=
# Signs the sign-in flow cookie and keys the IP hashes in staff_audit: at least 32 random characters
# (e.g. `openssl rand -hex 32`). Required in production — the console answers 503 without it.
ddiff --git a/package-lock.json b/package-lock.json
iindex 979360f..fd8f6a9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
},
"engines": {
@@@ -967,9 +967,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.33.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
++ "version": "0.38.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
++ "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex f484f70..c5f018d 100644
---- a/package.json
++++ b/package.json
@@@ -19,7 +19,7 @@
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
},
"engines": {
ddiff --git a/server/config.js b/server/config.js
iindex 5d6c2ab..2f73b0e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -172,7 +172,8 @@ function loadConfig(env = process.env) {
// Audience a Network user token must carry (Network user tokens are not minted for
// openvibe.billing; the Network's own audience is always present).
ssoAudience: env.BILLING_SSO_AUDIENCE || 'openvibe.network',
-- staffRole: 'admin',
++ // Staff also hold this capability of the contracts staff map (owner-only: money, ADR-012).
++ staffCapability: 'staff.money.cashouts',
},
};
}
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 572e3a7..b022ebe 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -30,7 +30,7 @@
*/
const crypto = require('crypto');
const express = require('express');
--const { capabilities } = require('openvibe-contracts');
++const { capabilities, staff: staffMap } = require('openvibe-contracts');
const { BillingError, balance } = require('../ledger');
const { A, assertNotFrozen, isFrozen, text } = require('../ops/common');
const cashouts = require('../ops/cashouts');
@@@ -129,10 +129,10 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
req.staff = null;
const row = sessions.read(req);
if (!row) return next();
-- if (!cc.staffSubjects.includes(row.subject) || row.role !== cc.staffRole) {
++ if (!cc.staffSubjects.includes(row.subject) || !staffMap.can(row.role || 'user', cc.staffCapability)) {
sessions.revoke(row);
sessions.clear(res);
-- audit.record(ctx, { actor: { subject: row.subject, username: row.username }, action: 'session.use', outcome: 'refused', reason: 'no longer listed in BILLING_STAFF_SUBJECTS', requestId: requestId(req), ipHash: ipHash(req) });
++ audit.record(ctx, { actor: { subject: row.subject, username: row.username }, action: 'session.use', outcome: 'refused', reason: cc.staffSubjects.includes(row.subject) ? `the session's role does not hold ${cc.staffCapability}` : 'no longer listed in BILLING_STAFF_SUBJECTS', requestId: requestId(req), ipHash: ipHash(req) });
return send(res, 403, pages.message({ title: 'Not authorized', text: 'Your account is no longer Billing staff. The session was ended.' }));
}
req.staff = { subject: row.subject, username: row.username, role: row.role, csrf: row.csrf, session: row, principal: { sub: row.subject, cap: STAFF_CAPABILITIES } };
@@@ -194,10 +194,10 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
}
const listed = !!who.subject && cc.staffSubjects.includes(who.subject);
-- if (!listed || who.role !== cc.staffRole) {
++ if (!listed || !who.money) {
audit.record(ctx, {
actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', outcome: 'refused',
-- reason: !who.subject ? 'token carries no subject' : who.role !== cc.staffRole ? `role ${who.role || 'none'} is not ${cc.staffRole}` : 'not listed in BILLING_STAFF_SUBJECTS',
++ reason: !who.subject ? 'token carries no subject' : !who.money ? `the token does not hold ${cc.staffCapability} (role ${who.role || 'none'})` : 'not listed in BILLING_STAFF_SUBJECTS',
requestId: requestId(req), ipHash: ipHash(req),
});
return send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network admins who are Billing staff. Your sign-in was recorded.' }));
ddiff --git a/server/console/sso.js b/server/console/sso.js
iindex ff9af77..4a17ca1 100644
---- a/server/console/sso.js
++++ b/server/console/sso.js
@@@ -1,4 +1,5 @@
'use strict';
++const { staff: staffMap } = require('openvibe-contracts');
/**
* OpenVibe.Network single sign-on for the staff console: OAuth 2 authorization code with PKCE
@@@ -96,7 +97,10 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
return {
subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
-- role: typeof claims.role === 'string' ? claims.role : null,
++ // The effective role ('owner' for role admin with is_owner) and whether the staff map lets
++ // this token act on money (staff.money.cashouts; ADR-022).
++ role: staffMap.effectiveRole(claims),
++ money: staffMap.can(claims, 'staff.money.cashouts'),
};
}
ddiff --git a/test/console.test.js b/test/console.test.js
iindex a263d48..93a4865 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * The staff console: Network SSO with PKCE, staff-only access (Network role admin AND listed in
-- * BILLING_STAFF_SUBJECTS), session cookie attributes, CSRF on every form, cashout approve/deny
++ * The staff console: Network SSO with PKCE, staff-only access (staff.money.cashouts in the contracts
++ * staff map, i.e. the owner, AND listed in BILLING_STAFF_SUBJECTS), session cookie attributes, CSRF on every form, cashout approve/deny
* through ops/cashouts with the payout reference and escrow enforced, the freeze, reconciliation,
* review queue — every action audited once in staff_audit and announced as billing.staff.action —
* and no secrets or provider payloads on any page.
@@@ -36,13 +36,13 @@ const DAY = 86_400_000;
});
return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
}
-- async function signIn(subject, { role = 'admin', username = 'staffer', next = '/cashouts' } = {}) {
++ async function signIn(subject, { role = 'admin', is_owner = true, username = 'staffer', next = '/cashouts' } = {}) {
const login = await get(`/auth/login?next=${encodeURIComponent(next)}`);
assert.strictEqual(login.status, 302);
const loc = new URL(login.headers.get('location'));
const flow = login.cookies.find((c) => c.startsWith('ovb_flow='));
const code = network.authorize({
-- subject_id: subject, role, username, challenge: loc.searchParams.get('code_challenge'),
++ subject_id: subject, role, is_owner, username, challenge: loc.searchParams.get('code_challenge'),
redirect_uri: loc.searchParams.get('redirect_uri'), nowMs: Date.now() + t.clock.offset,
});
const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow.split(';')[0]);
@@@ -112,9 +112,11 @@ const DAY = 86_400_000;
const b = await signIn(listedNonAdmin, { role: 'user' });
assert.strictEqual(b.cb.status, 403);
assert.strictEqual(b.cookie, null);
++ const c = await signIn(listedNonAdmin, { role: 'admin', is_owner: false });
++ assert.strictEqual(c.cb.status, 403, 'a listed admin who is not the owner: money is the owner\'s (staff.money.cashouts)');
assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get().n, 0);
const refused = auditRows('session.sign_in', 'refused');
-- assert.deepStrictEqual(refused.map((r) => r.actor_subject).sort(), [listedNonAdmin, unlistedAdmin].sort());
++ assert.deepStrictEqual(refused.map((r) => r.actor_subject).sort(), [listedNonAdmin, listedNonAdmin, unlistedAdmin].sort());
assert.strictEqual(staffEvents('session.sign_in').length, 0, 'refusals are not published');
});
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 949d210..886e5db 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -39,15 +39,15 @@ async function startNetwork() {
// returns a user access token shaped like Network's issueTokenPair().
const codes = new Map();
const revoked = [];
-- function authorize({ subject_id, role = 'user', username = 'someone', challenge, redirect_uri, nowMs = Date.now() }) {
++ function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', challenge, redirect_uri, nowMs = Date.now() }) {
const code = crypto.randomBytes(16).toString('hex');
-- codes.set(code, { subject_id, role, username, challenge, redirect_uri, nowMs });
++ codes.set(code, { subject_id, role, is_owner, username, challenge, redirect_uri, nowMs });
return code;
}
function userToken(u) {
const now = Math.floor(u.nowMs / 1000);
return serviceAuth.signServiceToken({
-- sub: 42, id: 42, subject_id: u.subject_id, username: u.username, display_name: u.username, role: u.role,
++ sub: 42, id: 42, subject_id: u.subject_id, username: u.username, display_name: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),
iss: issuer, aud: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'], iat: now, exp: now + 86400,
}, privatePem);
}
ddiff --git a/test/staff-map.test.js b/test/staff-map.test.js
nnew file mode 100644
iindex 0000000..d693dd9
---- /dev/null
++++ b/test/staff-map.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++// Staff powers come from the openvibe-contracts staff map (ADR-022): the staff console needs staff.money.cashouts (the owner) and a listed subject.
++// No server file compares a person's role by hand.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { staff } = require('openvibe-contracts');
++
++const CAPS = ["staff.money.cashouts"];
++for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [false]], [{"role": "admin"}, [false]], [{"role": "admin", "is_owner": true}, [true]]]) {
++ CAPS.forEach((cap, i) => assert.strictEqual(staff.can(claims, cap), want[i], `${JSON.stringify(claims)} ${cap}`));
++}
++
++// Not staff decisions (display reads, an entity's own roles), allowed by file and snippet.
++const ALLOW = [];
++const offenders = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const f = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(f); continue; }
++ if (!/\.(js|ts)$/.test(e.name)) continue;
++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
++ if (/STAFF_ROLES|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) { const rel = path.relative(path.join(__dirname, '..'), f).split(path.sep).join('/'); if (!ALLOW.some(([file, snip]) => file === rel && line.includes(snip))) offenders.push(`${rel}:${i + 1}`); }
++ });
++ }
++})(path.join(__dirname, '..', "server"));
++assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');
++console.log('staff map: all checks passed');