openvibers/openvibe.codes Commits

Pictures reach Media with Codes' Network service token, not an app key (#191)

OP OpenVibers committed 14 days ago · d3b3488320 · 5 files, +41 −14

Media takes no app keys (C-71). Codes now asks Network for its own service
token (audience openvibe.media, media.object.upload and media.object.read,
granted on the codes tenant only by Network #432; Media #232 makes the
tenant), names the person in X-OV-Subject, and drops a cached token on 401.
CODES_MEDIA_KEY is gone; without a client secret there are no pictures.

session-lines checked media.configured (a function, always truthy), so with
Media off every picture logged a failed upload. It calls it now, and the
pictures are simply left out.

Changes

ddiff --git a/server/config.js b/server/config.js
iindex 69c8447..4284f2d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -113,10 +113,10 @@ function load(env = process.env) {
},
// OpenVibe.Media: the private objects a task's pictures live in. A proof screenshot can show anything on the
// person's screen, so it is never public: the bytes go to Media as a private object of the person, and the
-- // session shows a short-lived signed URL that only that person can use. Without a key there are no pictures.
++ // session shows a short-lived signed URL that only that person can use. Media takes no app keys (C-71): Codes
++ // uses its Network service token (server/media.js), so without a client secret there are no pictures.
media: {
url: trim(env.CODES_MEDIA_URL || 'https://openvibe.media'),
-- key: String(env.CODES_MEDIA_KEY || '').trim(),
app: trim(env.CODES_MEDIA_APP || 'codes'),
ttl: int(env.CODES_MEDIA_URL_TTL, 600),
},
ddiff --git a/server/domain/session-lines.js b/server/domain/session-lines.js
iindex 36c022d..c1d111b 100644
---- a/server/domain/session-lines.js
++++ b/server/domain/session-lines.js
@@@ -97,7 +97,7 @@ function lineOf(raw) {
function createSessionLines({ store, config = {}, log = console, media = null }) {
const db = store.db;
const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);
-- const pictures = media && media.configured ? media : null;
++ const pictures = media && typeof media.configured === 'function' && media.configured() ? media : null;
/** The bytes of a data URL, or null when it is not one. */
function bytesOf(url) {
ddiff --git a/server/media.js b/server/media.js
iindex 19fe366..ca320c8 100644
---- a/server/media.js
++++ b/server/media.js
@@@ -4,7 +4,9 @@
*
* A proof screenshot can show anything on the person's screen, so it never becomes a public file. The bytes go to
* Media as a private object owned by the person, and the session shows a short-lived signed URL that only that
-- * person (through Codes) can use. Without a key there are no attachments, and the session simply shows the text.
++ * person (through Codes) can use. Media takes no app keys (C-71): Codes reaches its own tenant with its Network service
++ * token (audience openvibe.media; media.object.upload and media.object.read, granted on the codes tenant only), the
++ * person named in X-OV-Subject. Without a client secret there are no attachments, and the session simply shows the text.
*
* createMedia({ config, fetchImpl })
* configured() is Media set up?
@@@ -13,22 +15,28 @@
* uploadAttachments(list, subject) → the list with the bytes swapped for object ids
* signAttachments(list, subject) → the list with the object ids swapped for URLs
*/
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
const DEFAULT_TTL = 900; // a signed URL lives fifteen minutes
const MAX_BYTES = 12 * 1024 * 1024;
++const SCOPE = 'media.object.upload media.object.read';
--function createMedia({ config, fetchImpl = globalThis.fetch, log = null } = {}) {
++function createMedia({ config, fetchImpl = globalThis.fetch, log = null, tokens = null } = {}) {
const media = (config && config.media) || {};
++ const oauth = (config && config.oauth) || {};
const url = String(media.url || '').replace(/\/+$/, '');
-- const key = String(media.key || '').trim();
const app = String(media.app || 'codes').trim() || 'codes';
const ttl = Math.min(3600, Math.max(60, Number(media.ttl) || DEFAULT_TTL));
const base = () => `${url}/api/v2/${encodeURIComponent(app)}/objects`;
-- const configured = () => !!(url && key);
++ const auth = tokens || (oauth.clientSecret && config.networkInternalUrl
++ ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: oauth.clientId, clientSecret: oauth.clientSecret, audience: 'openvibe.media', scope: SCOPE, fetch: fetchImpl })
++ : null);
++ const configured = () => !!(url && auth);
const warn = (msg) => { if (log && typeof log.warn === 'function') log.warn(msg); };
async function call(method, apiUrl, subject, body, raw) {
-- const headers = { authorization: `Bearer ${key}`, accept: 'application/json' };
++ const headers = { ...(await auth.authHeaders()), accept: 'application/json' };
if (subject) headers['x-ov-subject'] = String(subject);
if (body != null) headers['content-type'] = 'application/json';
const ctrl = new AbortController();
@@@ -39,6 +47,7 @@ function createMedia({ config, fetchImpl = globalThis.fetch, log = null } = {})
} finally {
clearTimeout(timer);
}
++ if (res.status === 401 && auth.invalidate) auth.invalidate();
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
ddiff --git a/test/media-embeds.test.js b/test/media-embeds.test.js
iindex e0aba5d..1877a9b 100644
---- a/test/media-embeds.test.js
++++ b/test/media-embeds.test.js
@@@ -2,7 +2,8 @@
/**
* The pictures of a task (Codes C10): the images a harness read and the screenshots the proof shot travel in the job
* result, and Codes puts them in Media as private objects of the person — never a public URL. The session shows a
-- * short-lived signed URL, and only the task's owner gets one: another person gets a 404.
++ * short-lived signed URL, and only the task's owner gets one: another person gets a 404. Media takes no app keys
++ * (C-71), so every call carries Codes' Network service token for openvibe.media, upload and read only.
*/
const assert = require('assert');
const http = require('http');
@@@ -48,7 +49,7 @@ const b64 = (bytes) => Buffer.alloc(bytes, 7).toString('base64');
await new Promise((ok) => mediaServer.listen(0, '127.0.0.1', ok));
const mediaUrl = `http://127.0.0.1:${mediaServer.address().port}`;
-- const t = await boot({ env: { CODES_MEDIA_URL: mediaUrl, CODES_MEDIA_KEY: 'codes-test-key' } });
++ const t = await boot({ env: { CODES_MEDIA_URL: mediaUrl } });
const db = t.ctx.store.db;
const ada = t.network.addUser('ada');
const bo = t.network.addUser('bo');
@@@ -58,7 +59,7 @@ const b64 = (bytes) => Buffer.alloc(bytes, 7).toString('base64');
VALUES ($1, $2, $3, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J55', 'https://github.com/a/b', 'look at the picture', 'running', 1, 2)`,
[taskId, ada.subject, project]);
-- const media = createMedia({ config: { media: { url: mediaUrl, key: 'codes-test-key', app: 'codes', ttl: 600 } }, log: console });
++ const media = createMedia({ config: t.ctx.config, log: console });
const lines = createSessionLines({ store: t.ctx.store, config: t.ctx.config, log: console, media });
const proof = createProof({ store: t.ctx.store, config: t.ctx.config, log: console });
@@@ -75,6 +76,10 @@ const b64 = (bytes) => Buffer.alloc(bytes, 7).toString('base64');
assert.strictEqual(body.visibility, 'private', 'a screenshot is never public');
assert.strictEqual(body.mime_type, 'image/png', 'Media is told what it is');
assert.ok(init.headers['x-ov-subject'] === ada.subject, 'the object belongs to the person the task belongs to');
++ assert.ok(/^Bearer ey/.test(String(init.headers.authorization)), 'Codes signs in to Media with a Network token, never an app key');
++ const asked = t.network.tokenRequests.filter((r) => r.audience === 'openvibe.media');
++ assert.ok(asked.length >= 1, 'the token is for Media');
++ assert.deepStrictEqual(asked.map((r) => [r.grant_type, r.client_id, r.scope, r.on_behalf_of]).pop(), ['client_credentials', 'codes', 'media.object.upload media.object.read', null], 'Codes\' own service token, upload and read only, acting for nobody');
assert.ok(calls.some((c) => c.method === 'PUT' && c.url.startsWith('/upload/')), 'the bytes were sent');
assert.ok(calls.some((c) => c.method === 'POST' && c.url.endsWith('/complete')), 'and the object was completed');
@@@ -96,8 +101,21 @@ const b64 = (bytes) => Buffer.alloc(bytes, 7).toString('base64');
await check('another person cannot read the task at all', async () => {
const res = await t.get(`/run/tasks/${taskId}/session.json`, { as: bo });
assert.strictEqual(res.status, 404, 'a task of yours is 404 for another person');
-- const row = await db.maybe('SELECT id, subject FROM run_tasks WHERE id = $1', [taskId]);
-- process.stderr.write(`[test] the row: ${JSON.stringify(row)}; ada: ${ada.subject}\n`);
++ });
++
++ await check('without a client secret Codes keeps no pictures, and never falls back to a key', async () => {
++ const off = createMedia({ config: { media: { url: mediaUrl, app: 'codes' }, oauth: { clientId: 'codes', clientSecret: '' }, networkInternalUrl: t.network.url } });
++ assert.strictEqual(off.configured(), false, 'no secret, no Media');
++ const offLines = createSessionLines({ store: t.ctx.store, config: t.ctx.config, log: console, media: off });
++ calls.length = 0;
++ await offLines.append(taskId, [{
++ seq: 2, at: new Date().toISOString(), kind: 'tool_result',
++ body: { t: 'tool_result', id: 'tool-2', ok: true, output: 'still here', attachments: [{ url: `data:image/png;base64,${PNG}`, name: 'b.png', mime: 'image/png', kind: 'image', role: 'read' }] },
++ }], null, ada.subject);
++ assert.strictEqual(calls.length, 0, 'Media is never called');
++ const row = await db.maybe("SELECT body FROM codes_session_lines WHERE task_id = $1 AND body->>'id' = 'tool-2'", [taskId]);
++ assert.ok(row && row.body.output === 'still here', 'the line keeps its words');
++ assert.ok(!row.body.attachments && !JSON.stringify(row.body).includes('base64'), 'and drops the picture, bytes and all');
});
await check('a proof item with a screenshot puts the picture on its line and keeps no bytes in the record', async () => {
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex c83979c..fb49a44 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -50,7 +50,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
-- 'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with the service key, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
++ 'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',