openvibers/openvibe.network Commits

Agent Streams S3: the report.session.write grant for Codes, Actor and Live (#599)

OP OpenVibers committed 7 days ago · 3baf1c4ba3 · 6 files, +105 −7

* Agent Streams S3: the report.session.write grant for Codes, Actor and Live

* Merge remote-tracking branch 'origin/main' into review-599

* openvibe-contracts v0.300.0 (report.session.write exists there)

Changes

ddiff --git a/README.md b/README.md
iindex 7222683b..dac0cf33 100644
---- a/README.md
++++ b/README.md
@@@ -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)).
<!-- versions:start -->
--- openvibe-contracts: v0.298.0
++- openvibe-contracts: v0.300.0
- openvibe-sdk: v0.91.0
- openvibe-shared: v4.119.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 9f312864..87924cdd 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 135014c6..c70eee52 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 383a6c22..0fad6459 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 26271768..f507daf1 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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's
ddiff --git a/test/session-report-grants.test.js b/test/session-report-grants.test.js
nnew file mode 100644
iindex 00000000..f06ffccd
---- /dev/null
++++ b/test/session-report-grants.test.js
@@@ -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); });