openvibers/openvibe.network Commits
Give Actor private Media access for sandbox skill code (#824)
Changes
@@@ -146,11 +146,11 @@ const DEFAULT_GRANTS = [// Compatibility register C-71: Inventory (Workshop badge images) and MediaHub (its library) reach Media with their// own tokens on their own tenants, not with app keys. Upload also covers the delete of an object.['inventory', 'media.object.upload', 'openvibe.media', ['inventory']],-- // Codes (C8 proof screenshots, the rich embeds of a task) keeps a person's pictures in Media as private objects on-- // its own tenant, reached with its own token (10-30). A grant on a host's own tenant never reaches the agents it-- // hosts: an agent's token names only its project's namespaces (agent-tokens.test.js), so it gets no `actor`.-- // Actor holds no Media grant for now all the same: its artifacts are in its own database, owner-only (Actor V2-4).++ // Codes keeps a person's task pictures in Media. Actor's sandbox skills keep scanned code resources there.++ // Each service reaches only its own tenant with its own token. A grant on Actor's tenant never reaches the agents++ // it hosts: an agent's token names only its project's namespaces (agent-tokens.test.js), so it gets no `actor`....['media.object.upload', 'media.object.read'].map((cap) => ['codes', cap, 'openvibe.media', ['codes']]),++ ...['media.object.upload', 'media.object.read'].map((cap) => ['actor', cap, 'openvibe.media', ['actor']]),['media-hub', 'media.object.upload', 'openvibe.media', ['media-hub']],['media-hub', 'media.object.read', 'openvibe.media', ['media-hub']],// OpenVibe.Video's AI edit (MediaHub server/video/ai-edit.js): media.analyze on finished uploads, local and unpaid;@@@ -24,6 +24,8 @@ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();// The seed creates Actor's client (server/db/database.js); this file sets the fields it relies on.await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, name = EXCLUDED.name, redirect_uris = EXCLUDED.redirect_uris, is_first_party = EXCLUDED.is_first_party").run();++// Exercise a service host without a Media grant; the default Actor grant is checked in media-private-grants.test.js.++await db.prepare("DELETE FROM principal_grants WHERE client_id = 'actor' AND audience = 'openvibe.media'").run();const S = {};for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));@@@ -121,7 +123,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privatconst chat = ok(await put('dev', svcBot, 'chat.message.send', { mode: 'confirm' }));assert.deepStrictEqual([chat.audience, chat.effective_mode, chat.within_host], ['openvibe.chat', 'confirm', true]);refused(await put('dev', svcBot, 'chat.moderation.read'), 403, 'grant.beyond_host'); // internal, though Actor holds it-- refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // Actor does not hold it++ refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // This service host has no Media grant// ── Read: members and staff ──assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.map((g) => g.capability), ['media.object.delete', 'media.object.upload']);@@@ -187,7 +187,7 @@ const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: '// ── Service host: ns bounded by the host's own principal_grants rows ──SECRETS.actor = 'actor-secret';-- const grantActor = (cap, ns) => db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('actor', ?, 'openvibe.media', ?, 'test')").run(cap, JSON.stringify(ns));++ const grantActor = (cap, ns) => db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('actor', ?, 'openvibe.media', ?, 'test') ON CONFLICT (client_id, capability, audience) DO UPDATE SET namespaces = EXCLUDED.namespaces").run(cap, JSON.stringify(ns));await grantActor('media.object.read', [P]);await grantActor('media.object.delete', ['app.*']);const S = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Actor agent', host: { type: 'service', id: 'actor' } }), 201).id;@@@ -113,7 +113,8 @@ function summary(service, namespace, extra = {}) {const compute = await ownerCall('GET','/api/admin/ai/config/compute/status');assert.strictEqual(compute.status,200);assert.strictEqual(compute.body.compute.budgets[0].near_cap,true);-- assert.ok(run.seen[0].headers['x-ov-subject']);++ const computeRequest = run.seen.find((request) => request.url === '/internal/config/compute-status');++ assert.ok(computeRequest && computeRequest.headers['x-ov-subject']);// ── Only the owner: a user and a plain admin are refused, and nothing reaches a service ──let r = await call('alice', 'GET', '/api/admin/ai/config/services');@@@ -1,8 +1,7 @@/**-- * OpenVibe.Codes keeps a person's pictures in Media as private objects (task screenshots, 10-30). Media takes no app-- * keys (C-71), so Codes reaches it with its own Network service token: upload and read, on its own tenant only, and-- * nothing else of Media's. Actor holds no Media grant: its artifacts live in its own database, owner-only (Actor V2-4).-- * Were it granted one, the agents it hosts would still not reach its tenant (agent-tokens.test.js).++ * Codes keeps task pictures in Media; Actor keeps scanned sandbox skill code there. Each uses its own Network service++ * token to upload and read private objects on its own tenant only. Actor's hosted agents do not inherit its tenant++ * grant (agent-tokens.test.js).** node test/media-private-grants.test.js*/@@@ -11,7 +10,7 @@ const assert = require('assert');const contracts = require('openvibe-contracts');const principals = require('../server/identity/principals');--for (const svc of ['codes']) {++for (const svc of ['codes', 'actor']) {const rows = principals.DEFAULT_GRANTS.filter((g) => g[0] === svc && g[2] === 'openvibe.media');const held = new Map(rows.map((g) => [g[1], g[3]]));assert.deepStrictEqual([...held.keys()].sort(), ['media.object.read', 'media.object.upload'], `${svc} uploads and reads, nothing more`);@@@ -21,5 +20,4 @@ for (const svc of ['codes']) {assert.ok(c && c.status === 'active', `${cap} is an active capability`);}}--assert.strictEqual(principals.DEFAULT_GRANTS.filter((g) => g[0] === 'actor' && g[2] === 'openvibe.media').length, 0, 'the agent host holds no Media grant');--console.log('media private grants: codes on its own tenant; none for the agent host');++console.log('media private grants: codes and actor on their own tenants only');@@@ -38,8 +38,9 @@ const { signToken } = require('../server/auth/routes');assert.strictEqual(d.enabled, true);assert.strictEqual(d.receipts.length, 1);assert.strictEqual(d.receipts[0].message_id, 'msg_1');-- assert.strictEqual(asked[0].url, '/api/v1/me/mail/receipts', 'the real app reached Mail');-- assert.strictEqual(asked[0].auth, `Bearer ${token}`, "with the person's own token");++ const receiptRequest = asked.find((request) => request.url === '/api/v1/me/mail/receipts');++ assert.ok(receiptRequest, 'the real app reached Mail');++ assert.strictEqual(receiptRequest.auth, `Bearer ${token}`, "with the person's own token");mode = 'off';r = await get({ authorization: `Bearer ${token}` });
@@ -146,11 +146,11 @@ const DEFAULT_GRANTS = [
@@ -146,11 +146,11 @@ const DEFAULT_GRANTS = [
// Compatibility register C-71: Inventory (Workshop badge images) and MediaHub (its library) reach Media with their
// Compatibility register C-71: Inventory (Workshop badge images) and MediaHub (its library) reach Media with their
// own tokens on their own tenants, not with app keys. Upload also covers the delete of an object.
// own tokens on their own tenants, not with app keys. Upload also covers the delete of an object.
['inventory', 'media.object.upload', 'openvibe.media', ['inventory']],
['inventory', 'media.object.upload', 'openvibe.media', ['inventory']],
- // Codes (C8 proof screenshots, the rich embeds of a task) keeps a person's pictures in Media as private objects on
- // its own tenant, reached with its own token (10-30). A grant on a host's own tenant never reaches the agents it
- // hosts: an agent's token names only its project's namespaces (agent-tokens.test.js), so it gets no `actor`.
- // Actor holds no Media grant for now all the same: its artifacts are in its own database, owner-only (Actor V2-4).
+ // Codes keeps a person's task pictures in Media. Actor's sandbox skills keep scanned code resources there.
+ // Each service reaches only its own tenant with its own token. A grant on Actor's tenant never reaches the agents
+ // it hosts: an agent's token names only its project's namespaces (agent-tokens.test.js), so it gets no `actor`.
...['media.object.upload', 'media.object.read'].map((cap) => ['codes', cap, 'openvibe.media', ['codes']]),
...['media.object.upload', 'media.object.read'].map((cap) => ['codes', cap, 'openvibe.media', ['codes']]),
+ ...['media.object.upload', 'media.object.read'].map((cap) => ['actor', cap, 'openvibe.media', ['actor']]),
['media-hub', 'media.object.upload', 'openvibe.media', ['media-hub']],
['media-hub', 'media.object.upload', 'openvibe.media', ['media-hub']],
['media-hub', 'media.object.read', 'openvibe.media', ['media-hub']],
['media-hub', 'media.object.read', 'openvibe.media', ['media-hub']],
// OpenVibe.Video's AI edit (MediaHub server/video/ai-edit.js): media.analyze on finished uploads, local and unpaid;
// OpenVibe.Video's AI edit (MediaHub server/video/ai-edit.js): media.analyze on finished uploads, local and unpaid;
@@ -24,6 +24,8 @@ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
@@ -24,6 +24,8 @@ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();
(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();
// The seed creates Actor's client (server/db/database.js); this file sets the fields it relies on.
// The seed creates Actor's client (server/db/database.js); this file sets the fields it relies on.
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, name = EXCLUDED.name, redirect_uris = EXCLUDED.redirect_uris, is_first_party = EXCLUDED.is_first_party").run();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1) ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret, name = EXCLUDED.name, redirect_uris = EXCLUDED.redirect_uris, is_first_party = EXCLUDED.is_first_party").run();
+// Exercise a service host without a Media grant; the default Actor grant is checked in media-private-grants.test.js.
+await db.prepare("DELETE FROM principal_grants WHERE client_id = 'actor' AND audience = 'openvibe.media'").run();
const S = {};
const S = {};
for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
@@ -121,7 +123,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
@@ -121,7 +123,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
const chat = ok(await put('dev', svcBot, 'chat.message.send', { mode: 'confirm' }));
const chat = ok(await put('dev', svcBot, 'chat.message.send', { mode: 'confirm' }));
assert.deepStrictEqual([chat.audience, chat.effective_mode, chat.within_host], ['openvibe.chat', 'confirm', true]);
assert.deepStrictEqual([chat.audience, chat.effective_mode, chat.within_host], ['openvibe.chat', 'confirm', true]);
refused(await put('dev', svcBot, 'chat.moderation.read'), 403, 'grant.beyond_host'); // internal, though Actor holds it
refused(await put('dev', svcBot, 'chat.moderation.read'), 403, 'grant.beyond_host'); // internal, though Actor holds it
- refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // Actor does not hold it
+ refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // This service host has no Media grant
// ── Read: members and staff ──
// ── Read: members and staff ──
assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.map((g) => g.capability), ['media.object.delete', 'media.object.upload']);
assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.map((g) => g.capability), ['media.object.delete', 'media.object.upload']);
@@ -187,7 +187,7 @@ const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: '
@@ -187,7 +187,7 @@ const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: '
// ── Service host: ns bounded by the host's own principal_grants rows ──
// ── Service host: ns bounded by the host's own principal_grants rows ──
SECRETS.actor = 'actor-secret';
SECRETS.actor = 'actor-secret';
- const grantActor = (cap, ns) => db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('actor', ?, 'openvibe.media', ?, 'test')").run(cap, JSON.stringify(ns));
+ const grantActor = (cap, ns) => db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('actor', ?, 'openvibe.media', ?, 'test') ON CONFLICT (client_id, capability, audience) DO UPDATE SET namespaces = EXCLUDED.namespaces").run(cap, JSON.stringify(ns));
await grantActor('media.object.read', [P]);
await grantActor('media.object.read', [P]);
await grantActor('media.object.delete', ['app.*']);
await grantActor('media.object.delete', ['app.*']);
const S = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Actor agent', host: { type: 'service', id: 'actor' } }), 201).id;
const S = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Actor agent', host: { type: 'service', id: 'actor' } }), 201).id;
@@ -113,7 +113,8 @@ function summary(service, namespace, extra = {}) {
@@ -113,7 +113,8 @@ function summary(service, namespace, extra = {}) {
const compute = await ownerCall('GET','/api/admin/ai/config/compute/status');
const compute = await ownerCall('GET','/api/admin/ai/config/compute/status');
assert.strictEqual(compute.status,200);
assert.strictEqual(compute.status,200);
assert.strictEqual(compute.body.compute.budgets[0].near_cap,true);
assert.strictEqual(compute.body.compute.budgets[0].near_cap,true);
- assert.ok(run.seen[0].headers['x-ov-subject']);
+ const computeRequest = run.seen.find((request) => request.url === '/internal/config/compute-status');
+ assert.ok(computeRequest && computeRequest.headers['x-ov-subject']);
// ── Only the owner: a user and a plain admin are refused, and nothing reaches a service ──
// ── Only the owner: a user and a plain admin are refused, and nothing reaches a service ──
let r = await call('alice', 'GET', '/api/admin/ai/config/services');
let r = await call('alice', 'GET', '/api/admin/ai/config/services');
@@ -1,8 +1,7 @@
@@ -1,8 +1,7 @@
/**
/**
- * OpenVibe.Codes keeps a person's pictures in Media as private objects (task screenshots, 10-30). Media takes no app
- * keys (C-71), so Codes reaches it with its own Network service token: upload and read, on its own tenant only, and
- * nothing else of Media's. Actor holds no Media grant: its artifacts live in its own database, owner-only (Actor V2-4).
- * Were it granted one, the agents it hosts would still not reach its tenant (agent-tokens.test.js).
+ * Codes keeps task pictures in Media; Actor keeps scanned sandbox skill code there. Each uses its own Network service
+ * token to upload and read private objects on its own tenant only. Actor's hosted agents do not inherit its tenant
+ * grant (agent-tokens.test.js).
*
*
* node test/media-private-grants.test.js
* node test/media-private-grants.test.js
*/
*/
@@ -11,7 +10,7 @@ const assert = require('assert');
@@ -11,7 +10,7 @@ const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
-for (const svc of ['codes']) {
+for (const svc of ['codes', 'actor']) {
const rows = principals.DEFAULT_GRANTS.filter((g) => g[0] === svc && g[2] === 'openvibe.media');
const rows = principals.DEFAULT_GRANTS.filter((g) => g[0] === svc && g[2] === 'openvibe.media');
const held = new Map(rows.map((g) => [g[1], g[3]]));
const held = new Map(rows.map((g) => [g[1], g[3]]));
assert.deepStrictEqual([...held.keys()].sort(), ['media.object.read', 'media.object.upload'], `${svc} uploads and reads, nothing more`);
assert.deepStrictEqual([...held.keys()].sort(), ['media.object.read', 'media.object.upload'], `${svc} uploads and reads, nothing more`);
@@ -21,5 +20,4 @@ for (const svc of ['codes']) {
@@ -21,5 +20,4 @@ for (const svc of ['codes']) {
assert.ok(c && c.status === 'active', `${cap} is an active capability`);
assert.ok(c && c.status === 'active', `${cap} is an active capability`);
}
}
}
}
-assert.strictEqual(principals.DEFAULT_GRANTS.filter((g) => g[0] === 'actor' && g[2] === 'openvibe.media').length, 0, 'the agent host holds no Media grant');
-console.log('media private grants: codes on its own tenant; none for the agent host');
+console.log('media private grants: codes and actor on their own tenants only');
@@ -38,8 +38,9 @@ const { signToken } = require('../server/auth/routes');
@@ -38,8 +38,9 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(d.enabled, true);
assert.strictEqual(d.enabled, true);
assert.strictEqual(d.receipts.length, 1);
assert.strictEqual(d.receipts.length, 1);
assert.strictEqual(d.receipts[0].message_id, 'msg_1');
assert.strictEqual(d.receipts[0].message_id, 'msg_1');
- assert.strictEqual(asked[0].url, '/api/v1/me/mail/receipts', 'the real app reached Mail');
- assert.strictEqual(asked[0].auth, `Bearer ${token}`, "with the person's own token");
+ const receiptRequest = asked.find((request) => request.url === '/api/v1/me/mail/receipts');
+ assert.ok(receiptRequest, 'the real app reached Mail');
+ assert.strictEqual(receiptRequest.auth, `Bearer ${token}`, "with the person's own token");
mode = 'off';
mode = 'off';
r = await get({ authorization: `Bearer ${token}` });
r = await get({ authorization: `Bearer ${token}` });