openvibers/openvibe.network Commits
Agent Streams S3: the report.session.write grant for Codes, Actor and Live (#599)
Changes
@@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody askeMIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.298.0++- openvibe-contracts: v0.300.0- openvibe-sdk: v0.91.0- openvibe-shared: v4.119.0<!-- versions:end -->@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.298.0",++ "contracts": "openvibe-contracts v0.300.0","sdk": "openvibe-sdk v0.91.0","shared": "openvibe-shared v4.119.0","capabilities": [@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.300.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.119.0","pg": "^8.23.0",@@@ -2303,9 +2303,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.298.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",-- "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",++ "version": "0.300.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.300.0",++ "integrity": "sha512-0Vw23wJc0G/EDQ8Hei3lbm5UdLzZ1njZBK9K1/yKwGpMJK/YYknaPgTkU+sOhIw/47kMm9VsUilNS7X+M3x6Lw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.300.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.119.0","pg": "^8.23.0",@@@ -810,8 +810,20 @@ const DEFAULT_GRANTS = [// The Agent Streams plan (S1, Contracts 0.298.0): Codes and Actor start and steer the person's agent stream on// OpenVibe.Live (live.agent_stream.publish, the audience openvibe.live). They act for the person and for nobody// else, so the grant is unscoped and the audience is Live's alone.++ // The Agent Streams plan (S1, Contracts 0.298.0): Codes and Actor start and steer the person's agent stream on++ // OpenVibe.Live (live.agent_stream.publish, the audience openvibe.live). They act for the person and for nobody++ // else, so the grant is unscoped and the audience is Live's alone.['codes', 'live.agent_stream.publish', 'openvibe.live', []],['actor', 'live.agent_stream.publish', 'openvibe.live', []],++ // The Agent Streams plan (S3, Contracts 0.300.0): Codes, Actor and Live write the report of an agent session on++ // OpenVibe.Report (report.session.write, the audience openvibe.report). They act for the person and for nobody++ // else, so the grant is unscoped and the audience is Report's alone. A desk session on a person's own machine++ // reports through Codes, which pairs the machine: no machine ever writes a report itself.++ // The grant is new (it was never granted and never revoked before), so it needs no RESTORED_GRANTS entry, and it++ // carries no namespaces, so it needs no CHANGED_DEFAULT_NAMESPACES entry either.++ ['codes', 'report.session.write', 'openvibe.report', []],++ ['actor', 'report.session.write', 'openvibe.report', []],++ ['live', 'report.session.write', 'openvibe.report', []],];// What a node principal (a person's own machine, paired with its credential) may do for its owner outside Network'snnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The Agent Streams plan (S3, Contracts 0.300.0): who may write an agent session's report on OpenVibe.Report.++ *++ * Codes, Actor and Live hold report.session.write for the audience openvibe.report as service principals: they act++ * for the person and for nobody else, so the grant is unscoped and Report's audience is the only one it is granted++ * for. A desk session on a person's own machine reports through Codes, which pairs the machine: no machine writes a++ * report itself, and no node principal holds the grant.++ *++ * node test/session-report-grants.test.js++ */++const assert = require('assert');++const crypto = require('crypto');++const contracts = require('openvibe-contracts');++const principals = require('../server/identity/principals');++++const CAP = 'report.session.write';++const AUDIENCE = 'openvibe.report';++const ISSUER = 'https://openvibe.network';++++// The service principals: Codes, Actor and Live write the person's session report on Report.++const grant = (client) => principals.DEFAULT_GRANTS.find((g) => g[0] === client && g[1] === CAP && g[2] === AUDIENCE);++for (const client of ['codes', 'actor', 'live']) {++ const g = grant(client);++ assert.ok(g, `${client} may write the person's session report on Report`);++ assert.deepStrictEqual(g[3], [], `${client} holds it for Report's audience alone`);++}++assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[1] === CAP && g[2] !== AUDIENCE),++ 'the capability is granted for Report and for no other audience');++assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[0] !== 'codes' && g[0] !== 'actor' && g[0] !== 'live' && g[1] === CAP),++ 'no other service principal holds it');++++// The seed's own rules: a grant that was never granted and never revoked needs no RESTORED_GRANTS entry, and one++// with no namespaces needs no CHANGED_DEFAULT_NAMESPACES entry. Neither list may name this grant.++assert.ok(!principals.REVOKED_GRANTS.some((g) => g[1] === CAP), 'the grant is not revoked');++assert.ok(!principals.RESTORED_GRANTS.some((g) => g[1] === CAP), 'a new grant needs no RESTORED_GRANTS entry');++assert.ok(!Array.isArray(principals.CHANGED_DEFAULT_NAMESPACES), 'the changed-namespaces list is not exported');++++// A node principal (a person's own machine) never writes a report: the desk reports through Codes.++assert.ok(!principals.NODE_GRANTS.some(([, cap]) => cap === CAP), 'a machine never writes a report itself');++++// The token: a service token for Report's audience carries the capability and nothing else.++const keys = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ publicKeyEncoding: { type: 'spki', format: 'pem' },++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++});++const rowsFor = (client) => principals.DEFAULT_GRANTS++ .filter((g) => g[0] === client && g[2] === AUDIENCE)++ .map((g) => ({ capability: g[1], namespaces: JSON.stringify(g[3] || []) }));++const dbFor = (client) => ({++ prepare: (sql) => ({++ get: async () => (sql.includes('oauth_clients')++ ? { client_id: client, client_secret: 'the-service-secret' }++ : null),++ all: async () => rowsFor(client),++ }),++});++++(async () => {++ for (const client of ['codes', 'actor', 'live']) {++ const r = await principals.issueToken(dbFor(client), {++ clientId: client, clientSecret: 'the-service-secret', audience: AUDIENCE,++ privateKey: keys.privateKey, issuer: ISSUER,++ });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.body.scope, CAP, `${client}'s token carries the report capability and nothing else`);++ const v = contracts.serviceAuth.verifyServiceToken(r.body.access_token, {++ publicKey: keys.publicKey, issuer: ISSUER, audience: AUDIENCE,++ });++ assert.ok(v.ok, v.reason);++ assert.deepStrictEqual([v.claims.sub, v.claims.actor_type, v.claims.aud, v.claims.cap],++ [`svc:${client}`, 'service', [AUDIENCE], [CAP]]);++ assert.ok(contracts.validate('identity.service-token-claims@1', v.claims).valid,++ 'the claims fit identity.service-token-claims@1');++ }++++ const known = contracts.capabilities.get(CAP);++ if (known) {++ assert.strictEqual(known.owner, 'report', `${CAP} is owned by report`);++ assert.strictEqual(known.status, 'active', `${CAP} is an active capability`);++ } else {++ console.log(`note: the pinned contracts do not know ${CAP} yet (the pin round lands it)`);++ }++ console.log('session report grants: codes, actor and live write the report; nobody else does');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.298.0
+- openvibe-contracts: v0.300.0
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
- openvibe-shared: v4.119.0
- openvibe-shared: v4.119.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.298.0",
+ "contracts": "openvibe-contracts v0.300.0",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
"shared": "openvibe-shared v4.119.0",
"shared": "openvibe-shared v4.119.0",
"capabilities": [
"capabilities": [
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.300.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.119.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.119.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.298.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
- "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",
+ "version": "0.300.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.300.0",
+ "integrity": "sha512-0Vw23wJc0G/EDQ8Hei3lbm5UdLzZ1njZBK9K1/yKwGpMJK/YYknaPgTkU+sOhIw/47kMm9VsUilNS7X+M3x6Lw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.300.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.119.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.119.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -810,8 +810,20 @@ const DEFAULT_GRANTS = [
@@ -810,8 +810,20 @@ const DEFAULT_GRANTS = [
// The Agent Streams plan (S1, Contracts 0.298.0): Codes and Actor start and steer the person's agent stream on
// The Agent Streams plan (S1, Contracts 0.298.0): Codes and Actor start and steer the person's agent stream on
// OpenVibe.Live (live.agent_stream.publish, the audience openvibe.live). They act for the person and for nobody
// OpenVibe.Live (live.agent_stream.publish, the audience openvibe.live). They act for the person and for nobody
// else, so the grant is unscoped and the audience is Live's alone.
// else, so the grant is unscoped and the audience is Live's alone.
+ // The Agent Streams plan (S1, Contracts 0.298.0): Codes and Actor start and steer the person's agent stream on
+ // OpenVibe.Live (live.agent_stream.publish, the audience openvibe.live). They act for the person and for nobody
+ // else, so the grant is unscoped and the audience is Live's alone.
['codes', 'live.agent_stream.publish', 'openvibe.live', []],
['codes', 'live.agent_stream.publish', 'openvibe.live', []],
['actor', 'live.agent_stream.publish', 'openvibe.live', []],
['actor', 'live.agent_stream.publish', 'openvibe.live', []],
+ // The Agent Streams plan (S3, Contracts 0.300.0): Codes, Actor and Live write the report of an agent session on
+ // OpenVibe.Report (report.session.write, the audience openvibe.report). They act for the person and for nobody
+ // else, so the grant is unscoped and the audience is Report's alone. A desk session on a person's own machine
+ // reports through Codes, which pairs the machine: no machine ever writes a report itself.
+ // The grant is new (it was never granted and never revoked before), so it needs no RESTORED_GRANTS entry, and it
+ // carries no namespaces, so it needs no CHANGED_DEFAULT_NAMESPACES entry either.
+ ['codes', 'report.session.write', 'openvibe.report', []],
+ ['actor', 'report.session.write', 'openvibe.report', []],
+ ['live', 'report.session.write', 'openvibe.report', []],
];
];
// What a node principal (a person's own machine, paired with its credential) may do for its owner outside Network's
// What a node principal (a person's own machine, paired with its credential) may do for its owner outside Network's
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The Agent Streams plan (S3, Contracts 0.300.0): who may write an agent session's report on OpenVibe.Report.
+ *
+ * Codes, Actor and Live hold report.session.write for the audience openvibe.report as service principals: they act
+ * for the person and for nobody else, so the grant is unscoped and Report's audience is the only one it is granted
+ * for. A desk session on a person's own machine reports through Codes, which pairs the machine: no machine writes a
+ * report itself, and no node principal holds the grant.
+ *
+ * node test/session-report-grants.test.js
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const contracts = require('openvibe-contracts');
+const principals = require('../server/identity/principals');
+
+const CAP = 'report.session.write';
+const AUDIENCE = 'openvibe.report';
+const ISSUER = 'https://openvibe.network';
+
+// The service principals: Codes, Actor and Live write the person's session report on Report.
+const grant = (client) => principals.DEFAULT_GRANTS.find((g) => g[0] === client && g[1] === CAP && g[2] === AUDIENCE);
+for (const client of ['codes', 'actor', 'live']) {
+ const g = grant(client);
+ assert.ok(g, `${client} may write the person's session report on Report`);
+ assert.deepStrictEqual(g[3], [], `${client} holds it for Report's audience alone`);
+}
+assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[1] === CAP && g[2] !== AUDIENCE),
+ 'the capability is granted for Report and for no other audience');
+assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[0] !== 'codes' && g[0] !== 'actor' && g[0] !== 'live' && g[1] === CAP),
+ 'no other service principal holds it');
+
+// The seed's own rules: a grant that was never granted and never revoked needs no RESTORED_GRANTS entry, and one
+// with no namespaces needs no CHANGED_DEFAULT_NAMESPACES entry. Neither list may name this grant.
+assert.ok(!principals.REVOKED_GRANTS.some((g) => g[1] === CAP), 'the grant is not revoked');
+assert.ok(!principals.RESTORED_GRANTS.some((g) => g[1] === CAP), 'a new grant needs no RESTORED_GRANTS entry');
+assert.ok(!Array.isArray(principals.CHANGED_DEFAULT_NAMESPACES), 'the changed-namespaces list is not exported');
+
+// A node principal (a person's own machine) never writes a report: the desk reports through Codes.
+assert.ok(!principals.NODE_GRANTS.some(([, cap]) => cap === CAP), 'a machine never writes a report itself');
+
+// The token: a service token for Report's audience carries the capability and nothing else.
+const keys = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+const rowsFor = (client) => principals.DEFAULT_GRANTS
+ .filter((g) => g[0] === client && g[2] === AUDIENCE)
+ .map((g) => ({ capability: g[1], namespaces: JSON.stringify(g[3] || []) }));
+const dbFor = (client) => ({
+ prepare: (sql) => ({
+ get: async () => (sql.includes('oauth_clients')
+ ? { client_id: client, client_secret: 'the-service-secret' }
+ : null),
+ all: async () => rowsFor(client),
+ }),
+});
+
+(async () => {
+ for (const client of ['codes', 'actor', 'live']) {
+ const r = await principals.issueToken(dbFor(client), {
+ clientId: client, clientSecret: 'the-service-secret', audience: AUDIENCE,
+ privateKey: keys.privateKey, issuer: ISSUER,
+ });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.body.scope, CAP, `${client}'s token carries the report capability and nothing else`);
+ const v = contracts.serviceAuth.verifyServiceToken(r.body.access_token, {
+ publicKey: keys.publicKey, issuer: ISSUER, audience: AUDIENCE,
+ });
+ assert.ok(v.ok, v.reason);
+ assert.deepStrictEqual([v.claims.sub, v.claims.actor_type, v.claims.aud, v.claims.cap],
+ [`svc:${client}`, 'service', [AUDIENCE], [CAP]]);
+ assert.ok(contracts.validate('identity.service-token-claims@1', v.claims).valid,
+ 'the claims fit identity.service-token-claims@1');
+ }
+
+ const known = contracts.capabilities.get(CAP);
+ if (known) {
+ assert.strictEqual(known.owner, 'report', `${CAP} is owned by report`);
+ assert.strictEqual(known.status, 'active', `${CAP} is an active capability`);
+ } else {
+ console.log(`note: the pinned contracts do not know ${CAP} yet (the pin round lands it)`);
+ }
+ console.log('session report grants: codes, actor and live write the report; nobody else does');
+})().catch((e) => { console.error(e); process.exit(1); });