openvibers/openvibe.network Commits
ADR-012 rule 5: OpenCoins are not transferable between people — the live transfer grant is revoked at every boot
Changes
@@@ -20,7 +20,6 @@ const SELF_AUDIENCE = 'openvibe.network';const DEFAULT_GRANTS = [['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],-- ['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],// User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],@@@ -39,6 +38,9 @@ const DEFAULT_GRANTS = [['live', 'community.paste.moderate', 'openvibe.community', []],];++// Grants withdrawn by decision; applied at every boot so an old default can't come back.++const REVOKED_GRANTS = [['live', 'network.coins.transfer', SELF_AUDIENCE]];++function ensureSchema(db) {db.exec(`CREATE TABLE IF NOT EXISTS principal_grants (@@@ -64,6 +66,10 @@ function ensureSchema(db) {PRIMARY KEY (principal, route, auth, allowed, code));`);++ // ADR-012 rule 5: loyalty is not transferable between people, so nobody holds the transfer grant.++ for (const [client, cap, aud] of REVOKED_GRANTS) {++ db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);++ }const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {if (db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) seed.run(client, cap, aud, JSON.stringify(ns));@@@ -50,7 +50,7 @@ const server = http.createServer(app);const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });assert.ok(claims.ok, claims.reason);assert.strictEqual(claims.claims.sub, 'svc:live');-- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.coins.transfer', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);++ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');const full = t.body.access_token;@@@ -78,7 +78,7 @@ const server = http.createServer(app);r = await post('/internal/coins/debit', credit({ amount: 1 }), { authorization: `Bearer ${creditOnly}` });assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });-- assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.status, 403, 'ADR-012: loyalty is not transferable between people'); assert.strictEqual(r.body.code, 'capability.denied');r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });
@@ -20,7 +20,6 @@ const SELF_AUDIENCE = 'openvibe.network';
@@ -20,7 +20,6 @@ const SELF_AUDIENCE = 'openvibe.network';
const DEFAULT_GRANTS = [
const DEFAULT_GRANTS = [
['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
- ['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
// User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).
// User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).
['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],
['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],
@@ -39,6 +38,9 @@ const DEFAULT_GRANTS = [
@@ -39,6 +38,9 @@ const DEFAULT_GRANTS = [
['live', 'community.paste.moderate', 'openvibe.community', []],
['live', 'community.paste.moderate', 'openvibe.community', []],
];
];
+// Grants withdrawn by decision; applied at every boot so an old default can't come back.
+const REVOKED_GRANTS = [['live', 'network.coins.transfer', SELF_AUDIENCE]];
+
function ensureSchema(db) {
function ensureSchema(db) {
db.exec(`
db.exec(`
CREATE TABLE IF NOT EXISTS principal_grants (
CREATE TABLE IF NOT EXISTS principal_grants (
@@ -64,6 +66,10 @@ function ensureSchema(db) {
@@ -64,6 +66,10 @@ function ensureSchema(db) {
PRIMARY KEY (principal, route, auth, allowed, code)
PRIMARY KEY (principal, route, auth, allowed, code)
);
);
`);
`);
+ // ADR-012 rule 5: loyalty is not transferable between people, so nobody holds the transfer grant.
+ for (const [client, cap, aud] of REVOKED_GRANTS) {
+ db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);
+ }
const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");
const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");
for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {
for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {
if (db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) seed.run(client, cap, aud, JSON.stringify(ns));
if (db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) seed.run(client, cap, aud, JSON.stringify(ns));
@@ -50,7 +50,7 @@ const server = http.createServer(app);
@@ -50,7 +50,7 @@ const server = http.createServer(app);
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(claims.ok, claims.reason);
assert.ok(claims.ok, claims.reason);
assert.strictEqual(claims.claims.sub, 'svc:live');
assert.strictEqual(claims.claims.sub, 'svc:live');
- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.coins.transfer', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
+ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
const full = t.body.access_token;
const full = t.body.access_token;
@@ -78,7 +78,7 @@ const server = http.createServer(app);
@@ -78,7 +78,7 @@ const server = http.createServer(app);
r = await post('/internal/coins/debit', credit({ amount: 1 }), { authorization: `Bearer ${creditOnly}` });
r = await post('/internal/coins/debit', credit({ amount: 1 }), { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');
r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });
r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });
- assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.status, 403, 'ADR-012: loyalty is not transferable between people'); assert.strictEqual(r.body.code, 'capability.denied');
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });