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
Changes
@@@ -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.@@@ -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",@@@ -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": {@@@ -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',},};}@@@ -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.' }));@@@ -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 = globalThisreturn {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'),};}@@@ -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');});@@@ -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);}nnew file mode 100644@@@ -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');
@@ -39,8 +39,9 @@ OV_OAUTH_CLIENT_SECRET=change-me
@@ -39,8 +39,9 @@ OV_OAUTH_CLIENT_SECRET=change-me
# ── Staff console (Network SSO; see README "Staff console") ──
# ── Staff console (Network SSO; see README "Staff console") ──
# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
# as a redirect URI of OAuth client `billing`.
# 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=
BILLING_STAFF_SUBJECTS=
# Signs the sign-in flow cookie and keys the IP hashes in staff_audit: at least 32 random characters
# 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.
# (e.g. `openssl rand -hex 32`). Required in production — the console answers 503 without it.
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
},
},
"engines": {
"engines": {
@@ -967,9 +967,9 @@
@@ -967,9 +967,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0"
},
},
"engines": {
"engines": {
@@ -172,7 +172,8 @@ function loadConfig(env = process.env) {
@@ -172,7 +172,8 @@ function loadConfig(env = process.env) {
// Audience a Network user token must carry (Network user tokens are not minted for
// Audience a Network user token must carry (Network user tokens are not minted for
// openvibe.billing; the Network's own audience is always present).
// openvibe.billing; the Network's own audience is always present).
ssoAudience: env.BILLING_SSO_AUDIENCE || 'openvibe.network',
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',
},
},
};
};
}
}
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { capabilities } = require('openvibe-contracts');
+const { capabilities, staff: staffMap } = require('openvibe-contracts');
const { BillingError, balance } = require('../ledger');
const { BillingError, balance } = require('../ledger');
const { A, assertNotFrozen, isFrozen, text } = require('../ops/common');
const { A, assertNotFrozen, isFrozen, text } = require('../ops/common');
const cashouts = require('../ops/cashouts');
const cashouts = require('../ops/cashouts');
@@ -129,10 +129,10 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -129,10 +129,10 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
req.staff = null;
req.staff = null;
const row = sessions.read(req);
const row = sessions.read(req);
if (!row) return next();
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.revoke(row);
sessions.clear(res);
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.' }));
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 } };
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 }) {
@@ -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.' }));
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);
const listed = !!who.subject && cc.staffSubjects.includes(who.subject);
- if (!listed || who.role !== cc.staffRole) {
+ if (!listed || !who.money) {
audit.record(ctx, {
audit.record(ctx, {
actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', outcome: 'refused',
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),
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.' }));
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.' }));
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const { staff: staffMap } = require('openvibe-contracts');
/**
/**
* OpenVibe.Network single sign-on for the staff console: OAuth 2 authorization code with PKCE
* 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
@@ -96,7 +97,10 @@ async function exchange(ctx, { code, verifier, publicKey, fetchImpl = globalThis
return {
return {
subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : 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'),
};
};
}
}
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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,
* 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 —
* review queue — every action audited once in staff_audit and announced as billing.staff.action —
* and no secrets or provider payloads on any page.
* and no secrets or provider payloads on any page.
@@ -36,13 +36,13 @@ const DAY = 86_400_000;
@@ -36,13 +36,13 @@ const DAY = 86_400_000;
});
});
return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
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)}`);
const login = await get(`/auth/login?next=${encodeURIComponent(next)}`);
assert.strictEqual(login.status, 302);
assert.strictEqual(login.status, 302);
const loc = new URL(login.headers.get('location'));
const loc = new URL(login.headers.get('location'));
const flow = login.cookies.find((c) => c.startsWith('ovb_flow='));
const flow = login.cookies.find((c) => c.startsWith('ovb_flow='));
const code = network.authorize({
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,
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]);
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;
@@ -112,9 +112,11 @@ const DAY = 86_400_000;
const b = await signIn(listedNonAdmin, { role: 'user' });
const b = await signIn(listedNonAdmin, { role: 'user' });
assert.strictEqual(b.cb.status, 403);
assert.strictEqual(b.cb.status, 403);
assert.strictEqual(b.cookie, null);
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);
assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get().n, 0);
const refused = auditRows('session.sign_in', 'refused');
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');
assert.strictEqual(staffEvents('session.sign_in').length, 0, 'refusals are not published');
});
});
@@ -39,15 +39,15 @@ async function startNetwork() {
@@ -39,15 +39,15 @@ async function startNetwork() {
// returns a user access token shaped like Network's issueTokenPair().
// returns a user access token shaped like Network's issueTokenPair().
const codes = new Map();
const codes = new Map();
const revoked = [];
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');
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;
return code;
}
}
function userToken(u) {
function userToken(u) {
const now = Math.floor(u.nowMs / 1000);
const now = Math.floor(u.nowMs / 1000);
return serviceAuth.signServiceToken({
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,
iss: issuer, aud: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'], iat: now, exp: now + 86400,
}, privatePem);
}, privatePem);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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');