openvibers/openvibe.watch Commits
Watch: a watch can use its project's own secrets (credential revealed for its origin, webhook signed by Network) (#63)
Changes
@@@ -398,6 +398,15 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:that names one is refused with 422 `watch.secret_not_granted` (the form shows why), and the carrierand the action worker check again at use. `OV_OAUTH_CLIENT_SECRET`, the credentials and the grantslive in `/etc/openvibe/watch.env` (0600).++- **Project secrets (ADR-056).** A watch that names a project may use that project's own secrets instead:++ `source.auth.secret_ref` for its credential, and `secret_ref` on a webhook action for its signing key,++ looked up in the watch's `project_env` (production by default). OpenVibe.Network keeps them and the project++ grants each to `watch` (on openvibe.services, the project's Secrets page). A credential is **revealed** by++ Network for the source URL's origin only, at fetch time, and used for that one request. A webhook is++ **signed** by Network over `"<t>.<raw body>"` in the usual headers, so Watch never holds that key. A secret++ Network refuses leaves the credential missing (the check says so), or makes the action unavailable.++ Network unreachable is retried. Watch holds `network.secret.use` (Network's default grant), and naming a++ project secret needs `project_id` (422 `watch.project_required`).- **Data.** An observation records what the source stated, never a guess; snapshots are capped andpruned by retention; a failed check never records an observation.nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- ADR-056: a watch that names a project looks its project secrets (secret_ref: a source credential, a webhook action's++-- signing key) up in this environment of that project. production by default when a project is named, NULL without one.++-- Additive: an older release never reads the column.++ALTER TABLE watches ADD COLUMN IF NOT EXISTS project_env text COLLATE "C";@@@ -12,7 +12,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7","pg": "^8.23.0"@@@ -769,9 +769,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.167.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",-- "integrity": "sha512-CVV3IX+jS6WJM5+7lYS3KK8ZJUNkTyBY55Pal3CG0hZlSDpIqGa/wFw1uSVgoo4vE4vkljE6hIjz6cGvopLrnQ==",++ "version": "0.179.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",++ "integrity": "sha512-36eR/FYklPbEOOqzGPN1QM8zvDLLnxTemFPQVVzUNCG/jKBflRExv53AFl2p9nb1s+KrCgSDZJUHhniw9wqfxw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7","pg": "^8.23.0"@@@ -62,6 +62,7 @@* status() says delivery is off.*/++const { SecretRefused } = require('./project-secrets');const { signDeliveryHeaders } = require('openvibe-sdk/events');const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -177,7 +178,7 @@ function actorAgentInputFor(args) {return prefix + actorContextFor(args, 8000 - prefix.length);}--function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, now = () => Date.now(), log = console }) {++function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, projectSecrets = null, now = () => Date.now(), log = console }) {// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.const grants = () => config.secretGrants;const enabled = Boolean(notifications) && config.actions.enabled;@@@ -213,6 +214,10 @@ function createActions({ db, config, notifications = null, post = null, actor =if (action.event_type !== TRIGGERED) { state = 'unavailable'; error = `watch.action_unavailable: ${action.event_type || 'an event'} is not an event Watch declares (only ${TRIGGERED})`; }else if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = `watch.action_no_person: ${TRIGGERED} names a person, and this watch belongs to an agent or a service`; }else { state = 'sent'; error = 'emitted with the check run'; }++ } else if (kind === 'webhook' && action.secret_ref) {++ // A project secret (ADR-056): Network signs at delivery; the project's grant to watch decides then.++ if (!watch.project_id || !watch.project_env) { state = 'unavailable'; error = 'watch.action_secret_unavailable: a project secret needs the watch to name its project'; }++ else request = { url: action.url, secret_ref: action.secret_ref, project_id: watch.project_id, project_env: watch.project_env, body: webhookFor({ config, watch, run, value, previousValue, observedAt }) };} else if (kind === 'webhook') {const why = grants() ? grants().refusal(action.secret_env, owner) : `${action.secret_env} is not granted to any owner`;if (why) { state = 'unavailable'; error = `watch.action_secret_not_granted: ${why}`; }@@@ -241,17 +246,35 @@ function createActions({ db, config, notifications = null, post = null, actor =}}-- async function deliverWebhook(row, request, attempt) {++ /** The signature headers: the operator-granted key secret_env names, or Network's signature with the project secret. */++ async function webhookSignature(row, request, body) {++ if (request.secret_ref) {++ if (!projectSecrets) return { refused: 'watch.action_secret_unavailable: project secrets are not configured in this Watch' };++ const t = Math.floor(now() / 1000);++ try {++ const { signature } = await projectSecrets.sign({ project_id: request.project_id, environment: request.project_env, name: request.secret_ref, message: `${t}.${body}` });++ return { headers: { 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}` } };++ } catch (err) {++ if (err instanceof SecretRefused) return { refused: `watch.action_secret_not_granted: ${err.message}` };++ throw err; // Network unreachable: the worker's backoff tries again++ }++ }const why = grants() ? grants().refusal(request.secret_env, row.owner_sub) : 'no grants';-- if (why) return { state: 'unavailable', error: `watch.action_secret_not_granted: ${why}` };++ if (why) return { refused: `watch.action_secret_not_granted: ${why}` };const secret = config.secrets ? config.secrets[request.secret_env] : null;if (!secret || String(secret).length < MIN_HOOK_SECRET) {-- return { state: 'unavailable', error: `watch.action_secret_missing: ${request.secret_env} is not set (${MIN_HOOK_SECRET} characters or more)` };++ return { refused: `watch.action_secret_missing: ${request.secret_env} is not set (${MIN_HOOK_SECRET} characters or more)` };}-- if (!post) throw Object.assign(new Error('webhook delivery is not configured in this process'), { code: 'webhook_unconfigured' });++ return { headers: signDeliveryHeaders(body, String(secret), { now: now() }) };++ }++++ async function deliverWebhook(row, request, attempt) {const body = JSON.stringify(request.body);++ const signed = await webhookSignature(row, request, body);++ if (signed.refused) return { state: 'unavailable', error: signed.refused };++ if (!post) throw Object.assign(new Error('webhook delivery is not configured in this process'), { code: 'webhook_unconfigured' });const headers = {-- ...signDeliveryHeaders(body, String(secret), { now: now() }),++ ...signed.headers,'X-OpenVibe-Event-Type': TRIGGERED,'X-OpenVibe-Delivery-Id': `${row.check_run_id}.${row.action_index}`,'X-OpenVibe-Delivery-Attempt': String(attempt),@@@ -15,7 +15,7 @@*/const { parseXml, decodeBody, ParseError } = require('../util');const { at } = require('../extract');--const { conditionalGet, onlyChange, ACCEPT } = require('./http');++const { conditionalGet, onlyChange, secretsFor, ACCEPT } = require('./http');const { mapFields } = require('./feed');const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;@@@ -30,13 +30,13 @@ function mapped(document, itemsPath) {return at(document, itemsPath);}--function createApiCarrier({ fetcher, config }) {++function createApiCarrier({ fetcher, config, projectSecrets = null }) {return {name: 'api',run: async ({ watch, state }) => {const source = watch.source;const out = await conditionalGet({-- source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.api,++ source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.api,onlyChangeNeeded: onlyChange(watch),});if (out.state !== 'ok' || !out.body) return out;@@@ -13,7 +13,7 @@*/const { parseXml, toText, toIsoDate, canonicalUrl, toId, asArray, pick, decodeBody, ParseError } = require('../util');const { at } = require('../extract');--const { conditionalGet, onlyChange, ACCEPT } = require('./http');++const { conditionalGet, onlyChange, secretsFor, ACCEPT } = require('./http');function atomLink(entry, base) {const links = asArray(entry.link);@@@ -120,12 +120,12 @@ function parseFeed(text, url, { summaryMax = 1000, maxItems = 500 } = {}) {return { ...meta, items, skipped };}--function createFeedCarrier({ fetcher, config }) {++function createFeedCarrier({ fetcher, config, projectSecrets = null }) {return {name: 'feed',run: async ({ watch, state }) => {const out = await conditionalGet({-- source: watch.source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.feed,++ source: watch.source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.feed,onlyChangeNeeded: onlyChange(watch),});if (out.state !== 'ok' || !out.body) return out;@@@ -18,6 +18,7 @@* read from the environment by NAME at fetch time (config.secrets[auth.env]) and never stored,* logged, or sent to another origin than the one it was configured for.*/++const { SecretRefused } = require('../project-secrets');const { sha256, decodeBody } = require('../util');const ACCEPT = {@@@ -46,8 +47,10 @@ function requestFor(source, secrets, accept) {if (accept) headers.Accept = accept;const auth = source.auth;if (auth && auth.mode && auth.mode !== 'none') {-- const secret = secrets ? secrets[auth.env] : null;-- if (!secret) return { missing: auth.env };++ // A project secret (secret_ref, ADR-056) arrives under its own key, revealed for this source's origin (secretsFor).++ const key = auth.secret_ref ? `ref:${auth.secret_ref}` : auth.env;++ const secret = secrets ? secrets[key] : null;++ if (!secret) return { missing: auth.secret_ref ? `project secret ${auth.secret_ref}` : auth.env };if (auth.mode === 'bearer') { headers.Authorization = `Bearer ${secret}`; sensitive.push('Authorization'); }else if (auth.mode === 'header' && auth.header) { headers[auth.header] = secret; sensitive.push(auth.header); }else if (auth.mode === 'query' && auth.param) { const u = new URL(url); u.searchParams.set(auth.param, secret); url = u.toString(); }@@@ -71,7 +74,7 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, noif (req.missing) {return {state: 'disabled', error_code: 'credential_missing',-- detail: `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,++ detail: /^project secret /.test(req.missing) ? `${req.missing} is not available: not set, or not granted to watch for this source's origin` : `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,etag: null, last_modified: null, body: null, content_type: null, notBefore: null,};}@@@ -147,14 +150,34 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, noreturn { ...out, state: 'ok', body: res.body };}--function createHttpCarrier({ fetcher, config }) {++/**++ * The credentials a carrier may send for this watch: its granted environment credential (secret-grants viewFor), or the++ * project secret its source names (secret_ref), revealed by Network for the source URL's origin only. A secret Network++ * refuses is simply absent (the check reports the credential missing); Network unreachable throws, and the check retries.++ */++async function secretsFor(watch, config, projectSecrets) {++ const base = config.secretGrants ? config.secretGrants.viewFor(watch, config.secrets) : {};++ const auth = watch && watch.source && watch.source.auth;++ if (!auth || !auth.secret_ref || !auth.mode || auth.mode === 'none' || !projectSecrets || !watch.project_id || !watch.project_env) return base;++ let origin;++ try { origin = new URL(watch.source.url).origin; } catch { return base; }++ try {++ const { value } = await projectSecrets.reveal({ project_id: watch.project_id, environment: watch.project_env, name: auth.secret_ref, origin });++ return { ...base, [`ref:${auth.secret_ref}`]: value };++ } catch (err) {++ if (err instanceof SecretRefused) return base;++ throw err;++ }++}++++function createHttpCarrier({ fetcher, config, projectSecrets = null }) {return {name: 'http',run: async ({ watch, state }) => conditionalGet({-- source: watch.source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.http,++ source: watch.source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.http,onlyChangeNeeded: onlyChange(watch),}),};}--module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, ACCEPT, decodeBody };++module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, secretsFor, ACCEPT, decodeBody };@@@ -48,11 +48,11 @@ function unavailable(kind) {};}--function createCarriers({ fetcher, config, log = console, machines = null }) {++function createCarriers({ fetcher, config, log = console, machines = null, projectSecrets = null }) {const carriers = {-- http: createHttpCarrier({ fetcher, config, log }),-- feed: createFeedCarrier({ fetcher, config, log }),-- api: createApiCarrier({ fetcher, config, log }),++ http: createHttpCarrier({ fetcher, config, log, projectSecrets }),++ feed: createFeedCarrier({ fetcher, config, log, projectSecrets }),++ api: createApiCarrier({ fetcher, config, log, projectSecrets }),event: createEventCarrier(),webhook: createWebhookCarrier(),node: createNodeCarrier({ machines }),@@@ -44,7 +44,7 @@ const { createApp } = require('./app');const accountDataLib = require('./account-data');const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null, machines: givenMachines = null } = {}) {++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null, machines: givenMachines = null, projectSecrets: givenProjectSecrets = null } = {}) {config = config || load();// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.const db = givenDb || await openDb(config, { log });@@@ -78,14 +78,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch// Every carrier goes out through the spacer, so two watches over one host never hammer it.// A node source probes from its owner's own machine through OpenVibe.Bot (carriers/node.js).const machines = givenMachines || (client ? createMachines(client, { baseUrl: config.actions.botUrl }) : null);-- const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log, machines });++ // Project secrets (ADR-056): a source credential or a webhook signature by secret_ref, through Network.++ const projectSecrets = givenProjectSecrets || (client ? require('./project-secrets').createProjectSecrets(client, { networkUrl: config.networkInternalUrl }) : null);++ const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log, machines, projectSecrets });const notifications = client ? createNotificationsClient(client) : null;const actorTasks = client ? createActorTasks(client, { baseUrl: config.actions.actorUrl }) : null;// Webhook actions leave through the same guard as every fetch (server/net/post.js).const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;const codesTasks = client ? createCodesTasks(client, { baseUrl: config.actions.codesUrl }) : null;-- const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, now, log });++ const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, projectSecrets, now, log });// Usage readings (plan T5 step 14): a completed check queues one check-operation reading for Billing in its own// commit; off unless WATCH_BILLING_INTERVAL_MS is set (server/billing.js).const billing = createWatchBilling({ db, config: config.billing, networkUrl: config.networkInternalUrl, clock: { now }, fetchImpl, log });nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++/**++ * Project secrets (ADR-056) through OpenVibe.Network's POST /internal/secrets/use, with Watch's own service token++ * (network.secret.use, audience openvibe.network). The project's grant of each secret to watch decides:++ *++ * reveal({ project_id, environment, name, origin }) → { value, version } a source credential, for the source's origin++ * sign({ project_id, environment, name, message }) → { signature, version } a webhook action's delivery signature++ *++ * Network answers 404 for a secret that does not exist, or is not granted to watch for that purpose (and origin): that++ * is SecretRefused, final for this attempt. Anything else (Network unreachable) is thrown as it is and retried.++ * A revealed value is used for the one request it is for and never kept.++ */++class SecretRefused extends Error {}++++function createProjectSecrets(client, { networkUrl }) {++ async function use(body) {++ try {++ return await client.json({++ service: 'network', baseUrl: networkUrl, audience: 'openvibe.network', method: 'POST', path: '/internal/secrets/use',++ json: body, retries: 0, idempotencyKey: false,++ });++ } catch (err) {++ if (err && err.status === 404) throw new SecretRefused(`project secret ${body.name} (${body.environment}) does not exist or is not granted to watch for ${body.purpose}${body.origin ? ` to ${body.origin}` : ''}`);++ throw err;++ }++ }++ return {++ async reveal({ project_id, environment, name, origin }) {++ const out = await use({ project_id, environment, name, purpose: 'reveal', origin });++ if (!out || typeof out.value !== 'string' || !out.value) throw new Error('Network answered no value');++ return { value: out.value, version: out.version };++ },++ async sign({ project_id, environment, name, message }) {++ const out = await use({ project_id, environment, name, purpose: 'sign', message });++ if (!out || !/^[0-9a-f]{64}$/.test(String(out.signature || ''))) throw new Error('Network answered no signature');++ return { signature: out.signature, version: out.version };++ },++ };++}++++module.exports = { createProjectSecrets, SecretRefused };@@@ -170,6 +170,13 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxconst bad = secretGrants.check(owner, rec);if (bad.length) throw new RegistryError(`${bad[0].path}: ${bad[0].reason}`, 'watch.secret_not_granted');}++ /** A project secret (ADR-056, secret_ref) lives in a project: a watch naming one must name its project. */++ function checkProjectSecrets(rec) {++ const auth = rec.source && rec.source.auth;++ const refs = (auth && auth.mode && auth.mode !== 'none' && auth.secret_ref ? 1 : 0)++ + (Array.isArray(rec.action) ? rec.action.filter((a) => a && a.kind === 'webhook' && a.secret_ref).length : 0);++ if (refs && !rec.project_id) throw new RegistryError('a project secret (secret_ref) lives in a project: set project_id to a project you are an admin or owner of', 'watch.project_required');++ }function checkActorAgents(owner, rec) {if (!PERSON.test(String(owner || '')) && Array.isArray(rec.action) && rec.action.some((a) => a && a.kind === 'actor-agent')) {throw new RegistryError('watch.action_no_person: an Actor agent runs for a person, and this watch belongs to an agent or a service');@@@ -197,9 +204,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxconst st = {get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),-- insert: db.prepare(`INSERT INTO watches (id, project_id, owner_sub, name, status, source, cadence, extraction, comparison, condition,++ insert: db.prepare(`INSERT INTO watches (id, project_id, project_env, owner_sub, name, status, source, cadence, extraction, comparison, condition,action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)-- VALUES (@id, @project_id, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,++ VALUES (@id, @project_id, @project_env, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,@action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),};@@@ -217,6 +224,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxbudget: (v) => JSON.stringify(v || {}),retention: (v) => JSON.stringify(v || {}),project_id: (v) => v,++ project_env: (v) => v,};/** The stored record, request-shaped (what a PATCH merges into). */@@@ -225,6 +233,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxreturn {id: row.id,project_id: row.project_id,++ ...(row.project_env ? { project_env: row.project_env } : {}),owner: row.owner_sub,name: row.name,labels: parse(row.labels) || {},@@@ -269,6 +278,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxreturn validateView({id: rec.id,project_id: rec.project_id,++ project_env: rec.project_env || null,owner: rec.owner,name: rec.name,labels: rec.labels,@@@ -313,6 +323,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxconst owner = ownerOf(principal);if (!owner) throw new RegistryError('no acting subject', 'watch.invalid');checkSecrets(owner, req);++ checkProjectSecrets(req);checkActorAgents(owner, req);await checkProject(owner, req, true);// A per-owner cap, so one caller cannot fill the registry (the API and the form both come@@@ -339,6 +350,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxconst row = {id,project_id: req.project_id || null,++ project_env: req.project_id ? (req.project_env || 'production') : null,owner_sub: owner,name: rec.name,status: rec.status,@@@ -383,8 +395,12 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxif (merged.project_id === null) delete merged.project_id;validateRequest(merged);checkSecrets(row.owner_sub, merged);++ checkProjectSecrets(merged);checkActorAgents(row.owner_sub, merged);await checkProject(row.owner_sub, merged, changes.project_id !== undefined && changes.project_id !== row.project_id);++ // The project environment follows the project: production when a project is first named, gone with it.++ if (changes.project_id === null) changes = { ...changes, project_env: null };++ else if (changes.project_id && changes.project_env === undefined && !row.project_env) changes = { ...changes, project_env: 'production' };const sets = [];const args = { id: row.id, updated_at: now(), updated_by: ownerOf(principal) };for (const [k, v] of Object.entries(changes)) {@@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).*/--async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null, machines = null } = {}) {++async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null, machines = null, projectSecrets = null } = {}) {// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real// to fetch and verify against (no test touches the internet). Its URL is overridable via env.const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });@@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l});// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.const testdb = await require('./db').testDb();-- const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(machines ? { machines } : {}), ...(now ? { now } : {}) });++ const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(machines ? { machines } : {}), ...(projectSecrets ? { projectSecrets } : {}), ...(now ? { now } : {}) });const base = `http://127.0.0.1:${h.server.address().port}`;/** A session cookie for a person, as /auth/callback would have set it. */@@@ -66,6 +66,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>});t('rows wait with the relay off and an aborted change has no event', async () => {++ // The rest of the schema (0005 on): the service writes today's columns (watches.project_env since 0010).++ await db.migrate({ dir: migrations, log: silent });svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });await assert.rejects(db.tx(async () => {nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * Project secrets in Watch (ADR-056, Contracts 0.179.0): a watch in a project names a secret instead of an operator's++ * environment variable.++ * - a source credential (auth.secret_ref) is revealed by Network for the source URL's origin at fetch time and sent as++ * the mode says; a secret Network refuses leaves the credential missing (the check says so), never a guess;++ * - a webhook action (secret_ref) is signed by Network over "<t>.<raw body>" in the usual headers, so the receiver++ * verifies with verifyDeliveryV2 and its own secret; Watch never holds the value; a refused secret makes the action++ * unavailable, Network unreachable is retried;++ * - a watch naming a project secret must name its project; project_env is production by default and shown.++ */++const assert = require('assert');++const crypto = require('crypto');++const { verifyDeliveryV2 } = require('openvibe-sdk/events');++const { validate } = require('openvibe-contracts');++const { boot, site, suite, newUser } = require('./helpers');++const { SecretRefused } = require('../server/project-secrets');++++const t = suite('project-secrets');++const PROJECT = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';++const SIGNING = `ovs_${crypto.randomBytes(24).toString('hex')}`;++const TOKEN = 't'.repeat(40);++const person = { sub: newUser() };++let svc;++let web;++let price = '19.99';++const seen = []; // what the source saw: its Authorization header++const posts = [];++const uses = [];++let networkDown = false;++// OpenVibe.Network's secrets/use, as the project granted them: api_token revealed to the source's origin only,++// webhook-signing for sign. Production only.++const projectSecrets = {++ async reveal({ project_id, environment, name, origin }) {++ uses.push({ purpose: 'reveal', project_id, environment, name, origin });++ if (networkDown) throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });++ if (project_id !== PROJECT || environment !== 'production' || name !== 'api_token' || origin !== web.origin) throw new SecretRefused(`project secret ${name} is not granted to watch for reveal to ${origin}`);++ return { value: TOKEN, version: 1 };++ },++ async sign({ project_id, environment, name, message }) {++ uses.push({ purpose: 'sign', project_id, environment, name, message });++ if (networkDown) throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });++ if (project_id !== PROJECT || environment !== 'production' || name !== 'webhook-signing') throw new SecretRefused(`project secret ${name} is not granted to watch for sign`);++ return { signature: crypto.createHmac('sha256', SIGNING).update(message).digest('hex'), version: 2 };++ },++};++async function post(url, { headers, body }) { posts.push({ url, headers, body }); return { status: 204 }; }++const projects = { async check() { return { ok: true }; } };++++const def = (overrides = {}) => ({++ name: 'Private price',++ project_id: PROJECT,++ source: { kind: 'http', url: `${web.origin}/price`, format: null, auth: { mode: 'bearer', secret_ref: 'api_token' } },++ cadence: { every_sec: 900 },++ extraction: { kind: 'text' },++ condition: { op: 'lt', value: 10 },++ action: [{ kind: 'webhook', url: 'https://hooks.example.org/w', secret_ref: 'webhook-signing' }],++ ...overrides,++});++const check = async (id) => await svc.check.run(id, { trigger: 'schedule' });++const acts = async (id) => await svc.db.prepare('SELECT * FROM action_runs WHERE watch_id = ? ORDER BY rid').all(id);++++t('boot', async () => {++ web = await site({++ '/price': (req) => { seen.push(req.headers.authorization || null); return { headers: { 'content-type': 'text/plain' }, body: price }; },++ });++ svc = await boot({ post, projects, projectSecrets });++});++++t('a watch naming a project secret must name its project; project_env is production by default and shown', async () => {++ await assert.rejects(svc.registry.create(def({ project_id: undefined }), person), (e) => e.code === 'watch.project_required');++ const row = await svc.registry.create(def(), person);++ const view = svc.registry.view(row);++ assert.strictEqual(view.project_env, 'production');++ assert.ok(validate('watch.watch@1', view).valid, JSON.stringify(validate('watch.watch@1', view).errors));++ assert.strictEqual(view.source.auth.secret_ref, 'api_token');++ assert.ok(!JSON.stringify(view).includes(TOKEN), 'no value is stored or shown');++});++++t('the source credential is revealed for the source\'s origin and sent as a bearer token', async () => {++ const row = await svc.registry.create(def({ name: 'Bearer check', action: [{ kind: 'notification', priority: 'low' }] }), person);++ seen.length = 0;++ const out = await check(row.id);++ assert.strictEqual(out.run.state === 'failed', false, JSON.stringify(out.run).slice(0, 300));++ assert.deepStrictEqual(seen, [`Bearer ${TOKEN}`]);++ const asked = uses.filter((u) => u.purpose === 'reveal').pop();++ assert.deepStrictEqual([asked.project_id, asked.environment, asked.name, asked.origin], [PROJECT, 'production', 'api_token', web.origin]);++ const stored = JSON.stringify(await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(row.id));++ assert.ok(!stored.includes(TOKEN));++});++++t('a credential Network refuses is missing: nothing is sent with a guessed value, and the check says why', async () => {++ const row = await svc.registry.create(def({ name: 'Wrong env', project_env: 'sandbox', action: [{ kind: 'notification', priority: 'low' }] }), person);++ seen.length = 0;++ const out = await check(row.id);++ assert.deepStrictEqual(seen, [], 'the source was not asked without its credential');++ assert.match(JSON.stringify(out.run), /project secret api_token is not available/);++});++++t('the webhook is signed by Network and verifies with the project secret; refused and unreachable behave', async () => {++ const row = await svc.registry.create(def({ name: 'Signed hook' }), person);++ price = '19.99'; await check(row.id);++ price = '7.50'; const out = await check(row.id);++ assert.strictEqual(out.run.state, 'condition_met');++ posts.length = 0;++ await svc.actions.tick();++ assert.strictEqual(posts.length, 1);++ const d = posts[0];++ assert.ok(verifyDeliveryV2(d.body, d.headers, SIGNING), 'verifies with the project secret');++ const signed = uses.filter((u) => u.purpose === 'sign').pop();++ assert.strictEqual(signed.message, `${d.headers['X-OpenVibe-Timestamp']}.${d.body}`, 'Network signed exactly what was sent');++ assert.strictEqual((await acts(row.id))[0].state, 'sent');++++ const refused = await svc.registry.create(def({ name: 'Ungranted hook', action: [{ kind: 'webhook', url: 'https://hooks.example.org/w', secret_ref: 'not-granted' }] }), person);++ price = '19.99'; await check(refused.id);++ price = '7.50'; await check(refused.id);++ await svc.actions.tick();++ const r = (await acts(refused.id))[0];++ assert.strictEqual(r.state, 'unavailable');++ assert.match(r.last_error, /watch\.action_secret_not_granted/);++++ const later = await svc.registry.create(def({ name: 'Network down hook' }), person);++ price = '19.99'; await check(later.id);++ price = '7.50'; await check(later.id);++ networkDown = true;++ posts.length = 0;++ await svc.actions.tick();++ networkDown = false;++ const l = (await acts(later.id))[0];++ assert.strictEqual(posts.length, 0, 'nothing unsigned was sent');++ assert.ok(['queued', 'retrying'].includes(l.state), `retried later (${l.state}: ${l.last_error})`);++});++++t('done', async () => { await web.close(); await svc.stop(); });++t.run();@@@ -111,7 +111,7 @@ t('the view is validated against watch.watch@1, and the health tells the runtimeassert.ok(validate('watch.watch@1', view).valid);assert.deepStrictEqual(Object.keys(view).sort(), ['action', 'budget', 'cadence', 'comparison', 'condition', 'created_at', 'extraction', 'health', 'id',-- 'labels', 'name', 'owner', 'project_id', 'retention', 'source', 'status', 'updated_at',++ 'labels', 'name', 'owner', 'project_env', 'project_id', 'retention', 'source', 'status', 'updated_at',]);assert.deepStrictEqual(view.health.status, 'never_checked');assert.strictEqual(view.health.next_due_at != null, true);
@@ -398,6 +398,15 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -398,6 +398,15 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
that names one is refused with 422 `watch.secret_not_granted` (the form shows why), and the carrier
that names one is refused with 422 `watch.secret_not_granted` (the form shows why), and the carrier
and the action worker check again at use. `OV_OAUTH_CLIENT_SECRET`, the credentials and the grants
and the action worker check again at use. `OV_OAUTH_CLIENT_SECRET`, the credentials and the grants
live in `/etc/openvibe/watch.env` (0600).
live in `/etc/openvibe/watch.env` (0600).
+- **Project secrets (ADR-056).** A watch that names a project may use that project's own secrets instead:
+ `source.auth.secret_ref` for its credential, and `secret_ref` on a webhook action for its signing key,
+ looked up in the watch's `project_env` (production by default). OpenVibe.Network keeps them and the project
+ grants each to `watch` (on openvibe.services, the project's Secrets page). A credential is **revealed** by
+ Network for the source URL's origin only, at fetch time, and used for that one request. A webhook is
+ **signed** by Network over `"<t>.<raw body>"` in the usual headers, so Watch never holds that key. A secret
+ Network refuses leaves the credential missing (the check says so), or makes the action unavailable.
+ Network unreachable is retried. Watch holds `network.secret.use` (Network's default grant), and naming a
+ project secret needs `project_id` (422 `watch.project_required`).
- **Data.** An observation records what the source stated, never a guess; snapshots are capped and
- **Data.** An observation records what the source stated, never a guess; snapshots are capped and
pruned by retention; a failed check never records an observation.
pruned by retention; a failed check never records an observation.
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- ADR-056: a watch that names a project looks its project secrets (secret_ref: a source credential, a webhook action's
+-- signing key) up in this environment of that project. production by default when a project is named, NULL without one.
+-- Additive: an older release never reads the column.
+ALTER TABLE watches ADD COLUMN IF NOT EXISTS project_env text COLLATE "C";
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.167.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
- "integrity": "sha512-CVV3IX+jS6WJM5+7lYS3KK8ZJUNkTyBY55Pal3CG0hZlSDpIqGa/wFw1uSVgoo4vE4vkljE6hIjz6cGvopLrnQ==",
+ "version": "0.179.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",
+ "integrity": "sha512-36eR/FYklPbEOOqzGPN1QM8zvDLLnxTemFPQVVzUNCG/jKBflRExv53AFl2p9nb1s+KrCgSDZJUHhniw9wqfxw==",
"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 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -62,6 +62,7 @@
@@ -62,6 +62,7 @@
* status() says delivery is off.
* status() says delivery is off.
*/
*/
+const { SecretRefused } = require('./project-secrets');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -177,7 +178,7 @@ function actorAgentInputFor(args) {
@@ -177,7 +178,7 @@ function actorAgentInputFor(args) {
return prefix + actorContextFor(args, 8000 - prefix.length);
return prefix + actorContextFor(args, 8000 - prefix.length);
}
}
-function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, now = () => Date.now(), log = console }) {
+function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, projectSecrets = null, now = () => Date.now(), log = console }) {
// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.
// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.
const grants = () => config.secretGrants;
const grants = () => config.secretGrants;
const enabled = Boolean(notifications) && config.actions.enabled;
const enabled = Boolean(notifications) && config.actions.enabled;
@@ -213,6 +214,10 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -213,6 +214,10 @@ function createActions({ db, config, notifications = null, post = null, actor =
if (action.event_type !== TRIGGERED) { state = 'unavailable'; error = `watch.action_unavailable: ${action.event_type || 'an event'} is not an event Watch declares (only ${TRIGGERED})`; }
if (action.event_type !== TRIGGERED) { state = 'unavailable'; error = `watch.action_unavailable: ${action.event_type || 'an event'} is not an event Watch declares (only ${TRIGGERED})`; }
else if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = `watch.action_no_person: ${TRIGGERED} names a person, and this watch belongs to an agent or a service`; }
else if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = `watch.action_no_person: ${TRIGGERED} names a person, and this watch belongs to an agent or a service`; }
else { state = 'sent'; error = 'emitted with the check run'; }
else { state = 'sent'; error = 'emitted with the check run'; }
+ } else if (kind === 'webhook' && action.secret_ref) {
+ // A project secret (ADR-056): Network signs at delivery; the project's grant to watch decides then.
+ if (!watch.project_id || !watch.project_env) { state = 'unavailable'; error = 'watch.action_secret_unavailable: a project secret needs the watch to name its project'; }
+ else request = { url: action.url, secret_ref: action.secret_ref, project_id: watch.project_id, project_env: watch.project_env, body: webhookFor({ config, watch, run, value, previousValue, observedAt }) };
} else if (kind === 'webhook') {
} else if (kind === 'webhook') {
const why = grants() ? grants().refusal(action.secret_env, owner) : `${action.secret_env} is not granted to any owner`;
const why = grants() ? grants().refusal(action.secret_env, owner) : `${action.secret_env} is not granted to any owner`;
if (why) { state = 'unavailable'; error = `watch.action_secret_not_granted: ${why}`; }
if (why) { state = 'unavailable'; error = `watch.action_secret_not_granted: ${why}`; }
@@ -241,17 +246,35 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -241,17 +246,35 @@ function createActions({ db, config, notifications = null, post = null, actor =
}
}
}
}
- async function deliverWebhook(row, request, attempt) {
+ /** The signature headers: the operator-granted key secret_env names, or Network's signature with the project secret. */
+ async function webhookSignature(row, request, body) {
+ if (request.secret_ref) {
+ if (!projectSecrets) return { refused: 'watch.action_secret_unavailable: project secrets are not configured in this Watch' };
+ const t = Math.floor(now() / 1000);
+ try {
+ const { signature } = await projectSecrets.sign({ project_id: request.project_id, environment: request.project_env, name: request.secret_ref, message: `${t}.${body}` });
+ return { headers: { 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}` } };
+ } catch (err) {
+ if (err instanceof SecretRefused) return { refused: `watch.action_secret_not_granted: ${err.message}` };
+ throw err; // Network unreachable: the worker's backoff tries again
+ }
+ }
const why = grants() ? grants().refusal(request.secret_env, row.owner_sub) : 'no grants';
const why = grants() ? grants().refusal(request.secret_env, row.owner_sub) : 'no grants';
- if (why) return { state: 'unavailable', error: `watch.action_secret_not_granted: ${why}` };
+ if (why) return { refused: `watch.action_secret_not_granted: ${why}` };
const secret = config.secrets ? config.secrets[request.secret_env] : null;
const secret = config.secrets ? config.secrets[request.secret_env] : null;
if (!secret || String(secret).length < MIN_HOOK_SECRET) {
if (!secret || String(secret).length < MIN_HOOK_SECRET) {
- return { state: 'unavailable', error: `watch.action_secret_missing: ${request.secret_env} is not set (${MIN_HOOK_SECRET} characters or more)` };
+ return { refused: `watch.action_secret_missing: ${request.secret_env} is not set (${MIN_HOOK_SECRET} characters or more)` };
}
}
- if (!post) throw Object.assign(new Error('webhook delivery is not configured in this process'), { code: 'webhook_unconfigured' });
+ return { headers: signDeliveryHeaders(body, String(secret), { now: now() }) };
+ }
+
+ async function deliverWebhook(row, request, attempt) {
const body = JSON.stringify(request.body);
const body = JSON.stringify(request.body);
+ const signed = await webhookSignature(row, request, body);
+ if (signed.refused) return { state: 'unavailable', error: signed.refused };
+ if (!post) throw Object.assign(new Error('webhook delivery is not configured in this process'), { code: 'webhook_unconfigured' });
const headers = {
const headers = {
- ...signDeliveryHeaders(body, String(secret), { now: now() }),
+ ...signed.headers,
'X-OpenVibe-Event-Type': TRIGGERED,
'X-OpenVibe-Event-Type': TRIGGERED,
'X-OpenVibe-Delivery-Id': `${row.check_run_id}.${row.action_index}`,
'X-OpenVibe-Delivery-Id': `${row.check_run_id}.${row.action_index}`,
'X-OpenVibe-Delivery-Attempt': String(attempt),
'X-OpenVibe-Delivery-Attempt': String(attempt),
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
*/
*/
const { parseXml, decodeBody, ParseError } = require('../util');
const { parseXml, decodeBody, ParseError } = require('../util');
const { at } = require('../extract');
const { at } = require('../extract');
-const { conditionalGet, onlyChange, ACCEPT } = require('./http');
+const { conditionalGet, onlyChange, secretsFor, ACCEPT } = require('./http');
const { mapFields } = require('./feed');
const { mapFields } = require('./feed');
const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;
const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;
@@ -30,13 +30,13 @@ function mapped(document, itemsPath) {
@@ -30,13 +30,13 @@ function mapped(document, itemsPath) {
return at(document, itemsPath);
return at(document, itemsPath);
}
}
-function createApiCarrier({ fetcher, config }) {
+function createApiCarrier({ fetcher, config, projectSecrets = null }) {
return {
return {
name: 'api',
name: 'api',
run: async ({ watch, state }) => {
run: async ({ watch, state }) => {
const source = watch.source;
const source = watch.source;
const out = await conditionalGet({
const out = await conditionalGet({
- source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.api,
+ source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.api,
onlyChangeNeeded: onlyChange(watch),
onlyChangeNeeded: onlyChange(watch),
});
});
if (out.state !== 'ok' || !out.body) return out;
if (out.state !== 'ok' || !out.body) return out;
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
*/
*/
const { parseXml, toText, toIsoDate, canonicalUrl, toId, asArray, pick, decodeBody, ParseError } = require('../util');
const { parseXml, toText, toIsoDate, canonicalUrl, toId, asArray, pick, decodeBody, ParseError } = require('../util');
const { at } = require('../extract');
const { at } = require('../extract');
-const { conditionalGet, onlyChange, ACCEPT } = require('./http');
+const { conditionalGet, onlyChange, secretsFor, ACCEPT } = require('./http');
function atomLink(entry, base) {
function atomLink(entry, base) {
const links = asArray(entry.link);
const links = asArray(entry.link);
@@ -120,12 +120,12 @@ function parseFeed(text, url, { summaryMax = 1000, maxItems = 500 } = {}) {
@@ -120,12 +120,12 @@ function parseFeed(text, url, { summaryMax = 1000, maxItems = 500 } = {}) {
return { ...meta, items, skipped };
return { ...meta, items, skipped };
}
}
-function createFeedCarrier({ fetcher, config }) {
+function createFeedCarrier({ fetcher, config, projectSecrets = null }) {
return {
return {
name: 'feed',
name: 'feed',
run: async ({ watch, state }) => {
run: async ({ watch, state }) => {
const out = await conditionalGet({
const out = await conditionalGet({
- source: watch.source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.feed,
+ source: watch.source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.feed,
onlyChangeNeeded: onlyChange(watch),
onlyChangeNeeded: onlyChange(watch),
});
});
if (out.state !== 'ok' || !out.body) return out;
if (out.state !== 'ok' || !out.body) return out;
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
* read from the environment by NAME at fetch time (config.secrets[auth.env]) and never stored,
* read from the environment by NAME at fetch time (config.secrets[auth.env]) and never stored,
* logged, or sent to another origin than the one it was configured for.
* logged, or sent to another origin than the one it was configured for.
*/
*/
+const { SecretRefused } = require('../project-secrets');
const { sha256, decodeBody } = require('../util');
const { sha256, decodeBody } = require('../util');
const ACCEPT = {
const ACCEPT = {
@@ -46,8 +47,10 @@ function requestFor(source, secrets, accept) {
@@ -46,8 +47,10 @@ function requestFor(source, secrets, accept) {
if (accept) headers.Accept = accept;
if (accept) headers.Accept = accept;
const auth = source.auth;
const auth = source.auth;
if (auth && auth.mode && auth.mode !== 'none') {
if (auth && auth.mode && auth.mode !== 'none') {
- const secret = secrets ? secrets[auth.env] : null;
- if (!secret) return { missing: auth.env };
+ // A project secret (secret_ref, ADR-056) arrives under its own key, revealed for this source's origin (secretsFor).
+ const key = auth.secret_ref ? `ref:${auth.secret_ref}` : auth.env;
+ const secret = secrets ? secrets[key] : null;
+ if (!secret) return { missing: auth.secret_ref ? `project secret ${auth.secret_ref}` : auth.env };
if (auth.mode === 'bearer') { headers.Authorization = `Bearer ${secret}`; sensitive.push('Authorization'); }
if (auth.mode === 'bearer') { headers.Authorization = `Bearer ${secret}`; sensitive.push('Authorization'); }
else if (auth.mode === 'header' && auth.header) { headers[auth.header] = secret; sensitive.push(auth.header); }
else if (auth.mode === 'header' && auth.header) { headers[auth.header] = secret; sensitive.push(auth.header); }
else if (auth.mode === 'query' && auth.param) { const u = new URL(url); u.searchParams.set(auth.param, secret); url = u.toString(); }
else if (auth.mode === 'query' && auth.param) { const u = new URL(url); u.searchParams.set(auth.param, secret); url = u.toString(); }
@@ -71,7 +74,7 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, no
@@ -71,7 +74,7 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, no
if (req.missing) {
if (req.missing) {
return {
return {
state: 'disabled', error_code: 'credential_missing',
state: 'disabled', error_code: 'credential_missing',
- detail: `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,
+ detail: /^project secret /.test(req.missing) ? `${req.missing} is not available: not set, or not granted to watch for this source's origin` : `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,
etag: null, last_modified: null, body: null, content_type: null, notBefore: null,
etag: null, last_modified: null, body: null, content_type: null, notBefore: null,
};
};
}
}
@@ -147,14 +150,34 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, no
@@ -147,14 +150,34 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, no
return { ...out, state: 'ok', body: res.body };
return { ...out, state: 'ok', body: res.body };
}
}
-function createHttpCarrier({ fetcher, config }) {
+/**
+ * The credentials a carrier may send for this watch: its granted environment credential (secret-grants viewFor), or the
+ * project secret its source names (secret_ref), revealed by Network for the source URL's origin only. A secret Network
+ * refuses is simply absent (the check reports the credential missing); Network unreachable throws, and the check retries.
+ */
+async function secretsFor(watch, config, projectSecrets) {
+ const base = config.secretGrants ? config.secretGrants.viewFor(watch, config.secrets) : {};
+ const auth = watch && watch.source && watch.source.auth;
+ if (!auth || !auth.secret_ref || !auth.mode || auth.mode === 'none' || !projectSecrets || !watch.project_id || !watch.project_env) return base;
+ let origin;
+ try { origin = new URL(watch.source.url).origin; } catch { return base; }
+ try {
+ const { value } = await projectSecrets.reveal({ project_id: watch.project_id, environment: watch.project_env, name: auth.secret_ref, origin });
+ return { ...base, [`ref:${auth.secret_ref}`]: value };
+ } catch (err) {
+ if (err instanceof SecretRefused) return base;
+ throw err;
+ }
+}
+
+function createHttpCarrier({ fetcher, config, projectSecrets = null }) {
return {
return {
name: 'http',
name: 'http',
run: async ({ watch, state }) => conditionalGet({
run: async ({ watch, state }) => conditionalGet({
- source: watch.source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.http,
+ source: watch.source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.http,
onlyChangeNeeded: onlyChange(watch),
onlyChangeNeeded: onlyChange(watch),
}),
}),
};
};
}
}
-module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, ACCEPT, decodeBody };
+module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, secretsFor, ACCEPT, decodeBody };
@@ -48,11 +48,11 @@ function unavailable(kind) {
@@ -48,11 +48,11 @@ function unavailable(kind) {
};
};
}
}
-function createCarriers({ fetcher, config, log = console, machines = null }) {
+function createCarriers({ fetcher, config, log = console, machines = null, projectSecrets = null }) {
const carriers = {
const carriers = {
- http: createHttpCarrier({ fetcher, config, log }),
- feed: createFeedCarrier({ fetcher, config, log }),
- api: createApiCarrier({ fetcher, config, log }),
+ http: createHttpCarrier({ fetcher, config, log, projectSecrets }),
+ feed: createFeedCarrier({ fetcher, config, log, projectSecrets }),
+ api: createApiCarrier({ fetcher, config, log, projectSecrets }),
event: createEventCarrier(),
event: createEventCarrier(),
webhook: createWebhookCarrier(),
webhook: createWebhookCarrier(),
node: createNodeCarrier({ machines }),
node: createNodeCarrier({ machines }),
@@ -44,7 +44,7 @@ const { createApp } = require('./app');
@@ -44,7 +44,7 @@ const { createApp } = require('./app');
const accountDataLib = require('./account-data');
const accountDataLib = require('./account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
-async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null, machines: givenMachines = null } = {}) {
+async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null, machines: givenMachines = null, projectSecrets: givenProjectSecrets = null } = {}) {
config = config || load();
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
@@ -78,14 +78,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -78,14 +78,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
// Every carrier goes out through the spacer, so two watches over one host never hammer it.
// Every carrier goes out through the spacer, so two watches over one host never hammer it.
// A node source probes from its owner's own machine through OpenVibe.Bot (carriers/node.js).
// A node source probes from its owner's own machine through OpenVibe.Bot (carriers/node.js).
const machines = givenMachines || (client ? createMachines(client, { baseUrl: config.actions.botUrl }) : null);
const machines = givenMachines || (client ? createMachines(client, { baseUrl: config.actions.botUrl }) : null);
- const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log, machines });
+ // Project secrets (ADR-056): a source credential or a webhook signature by secret_ref, through Network.
+ const projectSecrets = givenProjectSecrets || (client ? require('./project-secrets').createProjectSecrets(client, { networkUrl: config.networkInternalUrl }) : null);
+ const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log, machines, projectSecrets });
const notifications = client ? createNotificationsClient(client) : null;
const notifications = client ? createNotificationsClient(client) : null;
const actorTasks = client ? createActorTasks(client, { baseUrl: config.actions.actorUrl }) : null;
const actorTasks = client ? createActorTasks(client, { baseUrl: config.actions.actorUrl }) : null;
// Webhook actions leave through the same guard as every fetch (server/net/post.js).
// Webhook actions leave through the same guard as every fetch (server/net/post.js).
const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });
const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });
const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;
const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;
const codesTasks = client ? createCodesTasks(client, { baseUrl: config.actions.codesUrl }) : null;
const codesTasks = client ? createCodesTasks(client, { baseUrl: config.actions.codesUrl }) : null;
- const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, now, log });
+ const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, projectSecrets, now, log });
// Usage readings (plan T5 step 14): a completed check queues one check-operation reading for Billing in its own
// Usage readings (plan T5 step 14): a completed check queues one check-operation reading for Billing in its own
// commit; off unless WATCH_BILLING_INTERVAL_MS is set (server/billing.js).
// commit; off unless WATCH_BILLING_INTERVAL_MS is set (server/billing.js).
const billing = createWatchBilling({ db, config: config.billing, networkUrl: config.networkInternalUrl, clock: { now }, fetchImpl, log });
const billing = createWatchBilling({ db, config: config.billing, networkUrl: config.networkInternalUrl, clock: { now }, fetchImpl, log });
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+/**
+ * Project secrets (ADR-056) through OpenVibe.Network's POST /internal/secrets/use, with Watch's own service token
+ * (network.secret.use, audience openvibe.network). The project's grant of each secret to watch decides:
+ *
+ * reveal({ project_id, environment, name, origin }) → { value, version } a source credential, for the source's origin
+ * sign({ project_id, environment, name, message }) → { signature, version } a webhook action's delivery signature
+ *
+ * Network answers 404 for a secret that does not exist, or is not granted to watch for that purpose (and origin): that
+ * is SecretRefused, final for this attempt. Anything else (Network unreachable) is thrown as it is and retried.
+ * A revealed value is used for the one request it is for and never kept.
+ */
+class SecretRefused extends Error {}
+
+function createProjectSecrets(client, { networkUrl }) {
+ async function use(body) {
+ try {
+ return await client.json({
+ service: 'network', baseUrl: networkUrl, audience: 'openvibe.network', method: 'POST', path: '/internal/secrets/use',
+ json: body, retries: 0, idempotencyKey: false,
+ });
+ } catch (err) {
+ if (err && err.status === 404) throw new SecretRefused(`project secret ${body.name} (${body.environment}) does not exist or is not granted to watch for ${body.purpose}${body.origin ? ` to ${body.origin}` : ''}`);
+ throw err;
+ }
+ }
+ return {
+ async reveal({ project_id, environment, name, origin }) {
+ const out = await use({ project_id, environment, name, purpose: 'reveal', origin });
+ if (!out || typeof out.value !== 'string' || !out.value) throw new Error('Network answered no value');
+ return { value: out.value, version: out.version };
+ },
+ async sign({ project_id, environment, name, message }) {
+ const out = await use({ project_id, environment, name, purpose: 'sign', message });
+ if (!out || !/^[0-9a-f]{64}$/.test(String(out.signature || ''))) throw new Error('Network answered no signature');
+ return { signature: out.signature, version: out.version };
+ },
+ };
+}
+
+module.exports = { createProjectSecrets, SecretRefused };
@@ -170,6 +170,13 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -170,6 +170,13 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const bad = secretGrants.check(owner, rec);
const bad = secretGrants.check(owner, rec);
if (bad.length) throw new RegistryError(`${bad[0].path}: ${bad[0].reason}`, 'watch.secret_not_granted');
if (bad.length) throw new RegistryError(`${bad[0].path}: ${bad[0].reason}`, 'watch.secret_not_granted');
}
}
+ /** A project secret (ADR-056, secret_ref) lives in a project: a watch naming one must name its project. */
+ function checkProjectSecrets(rec) {
+ const auth = rec.source && rec.source.auth;
+ const refs = (auth && auth.mode && auth.mode !== 'none' && auth.secret_ref ? 1 : 0)
+ + (Array.isArray(rec.action) ? rec.action.filter((a) => a && a.kind === 'webhook' && a.secret_ref).length : 0);
+ if (refs && !rec.project_id) throw new RegistryError('a project secret (secret_ref) lives in a project: set project_id to a project you are an admin or owner of', 'watch.project_required');
+ }
function checkActorAgents(owner, rec) {
function checkActorAgents(owner, rec) {
if (!PERSON.test(String(owner || '')) && Array.isArray(rec.action) && rec.action.some((a) => a && a.kind === 'actor-agent')) {
if (!PERSON.test(String(owner || '')) && Array.isArray(rec.action) && rec.action.some((a) => a && a.kind === 'actor-agent')) {
throw new RegistryError('watch.action_no_person: an Actor agent runs for a person, and this watch belongs to an agent or a service');
throw new RegistryError('watch.action_no_person: an Actor agent runs for a person, and this watch belongs to an agent or a service');
@@ -197,9 +204,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -197,9 +204,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const st = {
const st = {
get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),
countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),
- insert: db.prepare(`INSERT INTO watches (id, project_id, owner_sub, name, status, source, cadence, extraction, comparison, condition,
+ insert: db.prepare(`INSERT INTO watches (id, project_id, project_env, owner_sub, name, status, source, cadence, extraction, comparison, condition,
action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)
action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)
- VALUES (@id, @project_id, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
+ VALUES (@id, @project_id, @project_env, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
@action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),
@action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),
};
};
@@ -217,6 +224,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -217,6 +224,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
budget: (v) => JSON.stringify(v || {}),
budget: (v) => JSON.stringify(v || {}),
retention: (v) => JSON.stringify(v || {}),
retention: (v) => JSON.stringify(v || {}),
project_id: (v) => v,
project_id: (v) => v,
+ project_env: (v) => v,
};
};
/** The stored record, request-shaped (what a PATCH merges into). */
/** The stored record, request-shaped (what a PATCH merges into). */
@@ -225,6 +233,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -225,6 +233,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
return {
return {
id: row.id,
id: row.id,
project_id: row.project_id,
project_id: row.project_id,
+ ...(row.project_env ? { project_env: row.project_env } : {}),
owner: row.owner_sub,
owner: row.owner_sub,
name: row.name,
name: row.name,
labels: parse(row.labels) || {},
labels: parse(row.labels) || {},
@@ -269,6 +278,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -269,6 +278,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
return validateView({
return validateView({
id: rec.id,
id: rec.id,
project_id: rec.project_id,
project_id: rec.project_id,
+ project_env: rec.project_env || null,
owner: rec.owner,
owner: rec.owner,
name: rec.name,
name: rec.name,
labels: rec.labels,
labels: rec.labels,
@@ -313,6 +323,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -313,6 +323,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const owner = ownerOf(principal);
const owner = ownerOf(principal);
if (!owner) throw new RegistryError('no acting subject', 'watch.invalid');
if (!owner) throw new RegistryError('no acting subject', 'watch.invalid');
checkSecrets(owner, req);
checkSecrets(owner, req);
+ checkProjectSecrets(req);
checkActorAgents(owner, req);
checkActorAgents(owner, req);
await checkProject(owner, req, true);
await checkProject(owner, req, true);
// A per-owner cap, so one caller cannot fill the registry (the API and the form both come
// A per-owner cap, so one caller cannot fill the registry (the API and the form both come
@@ -339,6 +350,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -339,6 +350,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const row = {
const row = {
id,
id,
project_id: req.project_id || null,
project_id: req.project_id || null,
+ project_env: req.project_id ? (req.project_env || 'production') : null,
owner_sub: owner,
owner_sub: owner,
name: rec.name,
name: rec.name,
status: rec.status,
status: rec.status,
@@ -383,8 +395,12 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -383,8 +395,12 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
if (merged.project_id === null) delete merged.project_id;
if (merged.project_id === null) delete merged.project_id;
validateRequest(merged);
validateRequest(merged);
checkSecrets(row.owner_sub, merged);
checkSecrets(row.owner_sub, merged);
+ checkProjectSecrets(merged);
checkActorAgents(row.owner_sub, merged);
checkActorAgents(row.owner_sub, merged);
await checkProject(row.owner_sub, merged, changes.project_id !== undefined && changes.project_id !== row.project_id);
await checkProject(row.owner_sub, merged, changes.project_id !== undefined && changes.project_id !== row.project_id);
+ // The project environment follows the project: production when a project is first named, gone with it.
+ if (changes.project_id === null) changes = { ...changes, project_env: null };
+ else if (changes.project_id && changes.project_env === undefined && !row.project_env) changes = { ...changes, project_env: 'production' };
const sets = [];
const sets = [];
const args = { id: row.id, updated_at: now(), updated_by: ownerOf(principal) };
const args = { id: row.id, updated_at: now(), updated_by: ownerOf(principal) };
for (const [k, v] of Object.entries(changes)) {
for (const [k, v] of Object.entries(changes)) {
@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
*/
*/
-async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null, machines = null } = {}) {
+async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null, machines = null, projectSecrets = null } = {}) {
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// to fetch and verify against (no test touches the internet). Its URL is overridable via env.
// to fetch and verify against (no test touches the internet). Its URL is overridable via env.
const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l
@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l
});
});
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(machines ? { machines } : {}), ...(now ? { now } : {}) });
+ const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(machines ? { machines } : {}), ...(projectSecrets ? { projectSecrets } : {}), ...(now ? { now } : {}) });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
/** A session cookie for a person, as /auth/callback would have set it. */
/** A session cookie for a person, as /auth/callback would have set it. */
@@ -66,6 +66,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>
@@ -66,6 +66,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>
});
});
t('rows wait with the relay off and an aborted change has no event', async () => {
t('rows wait with the relay off and an aborted change has no event', async () => {
+ // The rest of the schema (0005 on): the service writes today's columns (watches.project_env since 0010).
+ await db.migrate({ dir: migrations, log: silent });
svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });
svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });
assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });
assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });
await assert.rejects(db.tx(async () => {
await assert.rejects(db.tx(async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * Project secrets in Watch (ADR-056, Contracts 0.179.0): a watch in a project names a secret instead of an operator's
+ * environment variable.
+ * - a source credential (auth.secret_ref) is revealed by Network for the source URL's origin at fetch time and sent as
+ * the mode says; a secret Network refuses leaves the credential missing (the check says so), never a guess;
+ * - a webhook action (secret_ref) is signed by Network over "<t>.<raw body>" in the usual headers, so the receiver
+ * verifies with verifyDeliveryV2 and its own secret; Watch never holds the value; a refused secret makes the action
+ * unavailable, Network unreachable is retried;
+ * - a watch naming a project secret must name its project; project_env is production by default and shown.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { verifyDeliveryV2 } = require('openvibe-sdk/events');
+const { validate } = require('openvibe-contracts');
+const { boot, site, suite, newUser } = require('./helpers');
+const { SecretRefused } = require('../server/project-secrets');
+
+const t = suite('project-secrets');
+const PROJECT = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
+const SIGNING = `ovs_${crypto.randomBytes(24).toString('hex')}`;
+const TOKEN = 't'.repeat(40);
+const person = { sub: newUser() };
+let svc;
+let web;
+let price = '19.99';
+const seen = []; // what the source saw: its Authorization header
+const posts = [];
+const uses = [];
+let networkDown = false;
+// OpenVibe.Network's secrets/use, as the project granted them: api_token revealed to the source's origin only,
+// webhook-signing for sign. Production only.
+const projectSecrets = {
+ async reveal({ project_id, environment, name, origin }) {
+ uses.push({ purpose: 'reveal', project_id, environment, name, origin });
+ if (networkDown) throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
+ if (project_id !== PROJECT || environment !== 'production' || name !== 'api_token' || origin !== web.origin) throw new SecretRefused(`project secret ${name} is not granted to watch for reveal to ${origin}`);
+ return { value: TOKEN, version: 1 };
+ },
+ async sign({ project_id, environment, name, message }) {
+ uses.push({ purpose: 'sign', project_id, environment, name, message });
+ if (networkDown) throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
+ if (project_id !== PROJECT || environment !== 'production' || name !== 'webhook-signing') throw new SecretRefused(`project secret ${name} is not granted to watch for sign`);
+ return { signature: crypto.createHmac('sha256', SIGNING).update(message).digest('hex'), version: 2 };
+ },
+};
+async function post(url, { headers, body }) { posts.push({ url, headers, body }); return { status: 204 }; }
+const projects = { async check() { return { ok: true }; } };
+
+const def = (overrides = {}) => ({
+ name: 'Private price',
+ project_id: PROJECT,
+ source: { kind: 'http', url: `${web.origin}/price`, format: null, auth: { mode: 'bearer', secret_ref: 'api_token' } },
+ cadence: { every_sec: 900 },
+ extraction: { kind: 'text' },
+ condition: { op: 'lt', value: 10 },
+ action: [{ kind: 'webhook', url: 'https://hooks.example.org/w', secret_ref: 'webhook-signing' }],
+ ...overrides,
+});
+const check = async (id) => await svc.check.run(id, { trigger: 'schedule' });
+const acts = async (id) => await svc.db.prepare('SELECT * FROM action_runs WHERE watch_id = ? ORDER BY rid').all(id);
+
+t('boot', async () => {
+ web = await site({
+ '/price': (req) => { seen.push(req.headers.authorization || null); return { headers: { 'content-type': 'text/plain' }, body: price }; },
+ });
+ svc = await boot({ post, projects, projectSecrets });
+});
+
+t('a watch naming a project secret must name its project; project_env is production by default and shown', async () => {
+ await assert.rejects(svc.registry.create(def({ project_id: undefined }), person), (e) => e.code === 'watch.project_required');
+ const row = await svc.registry.create(def(), person);
+ const view = svc.registry.view(row);
+ assert.strictEqual(view.project_env, 'production');
+ assert.ok(validate('watch.watch@1', view).valid, JSON.stringify(validate('watch.watch@1', view).errors));
+ assert.strictEqual(view.source.auth.secret_ref, 'api_token');
+ assert.ok(!JSON.stringify(view).includes(TOKEN), 'no value is stored or shown');
+});
+
+t('the source credential is revealed for the source\'s origin and sent as a bearer token', async () => {
+ const row = await svc.registry.create(def({ name: 'Bearer check', action: [{ kind: 'notification', priority: 'low' }] }), person);
+ seen.length = 0;
+ const out = await check(row.id);
+ assert.strictEqual(out.run.state === 'failed', false, JSON.stringify(out.run).slice(0, 300));
+ assert.deepStrictEqual(seen, [`Bearer ${TOKEN}`]);
+ const asked = uses.filter((u) => u.purpose === 'reveal').pop();
+ assert.deepStrictEqual([asked.project_id, asked.environment, asked.name, asked.origin], [PROJECT, 'production', 'api_token', web.origin]);
+ const stored = JSON.stringify(await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(row.id));
+ assert.ok(!stored.includes(TOKEN));
+});
+
+t('a credential Network refuses is missing: nothing is sent with a guessed value, and the check says why', async () => {
+ const row = await svc.registry.create(def({ name: 'Wrong env', project_env: 'sandbox', action: [{ kind: 'notification', priority: 'low' }] }), person);
+ seen.length = 0;
+ const out = await check(row.id);
+ assert.deepStrictEqual(seen, [], 'the source was not asked without its credential');
+ assert.match(JSON.stringify(out.run), /project secret api_token is not available/);
+});
+
+t('the webhook is signed by Network and verifies with the project secret; refused and unreachable behave', async () => {
+ const row = await svc.registry.create(def({ name: 'Signed hook' }), person);
+ price = '19.99'; await check(row.id);
+ price = '7.50'; const out = await check(row.id);
+ assert.strictEqual(out.run.state, 'condition_met');
+ posts.length = 0;
+ await svc.actions.tick();
+ assert.strictEqual(posts.length, 1);
+ const d = posts[0];
+ assert.ok(verifyDeliveryV2(d.body, d.headers, SIGNING), 'verifies with the project secret');
+ const signed = uses.filter((u) => u.purpose === 'sign').pop();
+ assert.strictEqual(signed.message, `${d.headers['X-OpenVibe-Timestamp']}.${d.body}`, 'Network signed exactly what was sent');
+ assert.strictEqual((await acts(row.id))[0].state, 'sent');
+
+ const refused = await svc.registry.create(def({ name: 'Ungranted hook', action: [{ kind: 'webhook', url: 'https://hooks.example.org/w', secret_ref: 'not-granted' }] }), person);
+ price = '19.99'; await check(refused.id);
+ price = '7.50'; await check(refused.id);
+ await svc.actions.tick();
+ const r = (await acts(refused.id))[0];
+ assert.strictEqual(r.state, 'unavailable');
+ assert.match(r.last_error, /watch\.action_secret_not_granted/);
+
+ const later = await svc.registry.create(def({ name: 'Network down hook' }), person);
+ price = '19.99'; await check(later.id);
+ price = '7.50'; await check(later.id);
+ networkDown = true;
+ posts.length = 0;
+ await svc.actions.tick();
+ networkDown = false;
+ const l = (await acts(later.id))[0];
+ assert.strictEqual(posts.length, 0, 'nothing unsigned was sent');
+ assert.ok(['queued', 'retrying'].includes(l.state), `retried later (${l.state}: ${l.last_error})`);
+});
+
+t('done', async () => { await web.close(); await svc.stop(); });
+t.run();
@@ -111,7 +111,7 @@ t('the view is validated against watch.watch@1, and the health tells the runtime
@@ -111,7 +111,7 @@ t('the view is validated against watch.watch@1, and the health tells the runtime
assert.ok(validate('watch.watch@1', view).valid);
assert.ok(validate('watch.watch@1', view).valid);
assert.deepStrictEqual(Object.keys(view).sort(), [
assert.deepStrictEqual(Object.keys(view).sort(), [
'action', 'budget', 'cadence', 'comparison', 'condition', 'created_at', 'extraction', 'health', 'id',
'action', 'budget', 'cadence', 'comparison', 'condition', 'created_at', 'extraction', 'health', 'id',
- 'labels', 'name', 'owner', 'project_id', 'retention', 'source', 'status', 'updated_at',
+ 'labels', 'name', 'owner', 'project_env', 'project_id', 'retention', 'source', 'status', 'updated_at',
]);
]);
assert.deepStrictEqual(view.health.status, 'never_checked');
assert.deepStrictEqual(view.health.status, 'never_checked');
assert.strictEqual(view.health.next_due_at != null, true);
assert.strictEqual(view.health.next_due_at != null, true);