openvibers/openvibe.run Commits
Run: project delegation: a first-party service starts a job for a person in a project they run (X-OV-Subject + X-OV-Project, checked with Network) (#39)
Changes
@@@ -47,6 +47,12 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.- The project scope: a job's `project_id` and `requester` come from the caller's Network token(`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`.++- Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (`X-OV-Subject`) may++ name a project with `X-OV-Project`. Run asks Network whether the person is an admin or owner of the unarchived++ project (`network.project.member.read`, a yes cached 60 s), then the person is the requester and the service is++ stored as `via` (`migrations/0002_job_via.sql`). The service submits and reads only the jobs it started for that++ person; list, cancel and stream are `403 project.not_delegated`, and so is any other caller naming a project. Network++ unreachable is `503 project.check_unavailable`. Watch's `run-job` action is the first user.- The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading ofthe job is `run:<id>:<n>`).- Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body@@@ -80,7 +86,8 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs- PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot.- Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared.- OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; the offers Run places from-- (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token and its Bot token.++ (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token, its Bot token and the++ token for project memberships (`GET /internal/projects/:project/members/:subject`, project delegation).- OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`.- OpenVibe.Bot (`RUN_BOT_URL`, `RUN_POLL_MS`): the node link and the meter. Run sends each job to it(`bot.job.dispatch`, audience `openvibe.bot`, minted from `OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` againstnnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Project delegation (ADR-003 amendment 2026-10-12): the first-party service that started a job for a person in a++-- project, as a subject-ref string ('service:watch'); the person is the requester. NULL when the project's own++-- token started the job. A delegated caller reads only the jobs whose requester and via are its own.++ALTER TABLE run_jobs ADD COLUMN IF NOT EXISTS via text;@@@ -13,11 +13,20 @@** The project a job belongs to comes from the token's own project_id claim (a developer app or agent* token names one), never from the request body or a query: the requester and the billed project are-- * the token's, always.++ * the token's, always — with one exception, project delegation (ADR-003 amendment 2026-10-12): a++ * first-party service (svc:) acting for a person (X-OV-Subject: usr_…) may name a project with++ * X-OV-Project once Network confirms the person is an admin or owner of it. Such a principal is++ *++ * { kind: 'service', sub: 'svc:watch', ..., project_id: <X-OV-Project>, delegated: { subject: 'usr_…', via: 'service:watch' } }++ *++ * The person is the requester and the service is recorded as via; it may submit a job and read the jobs it++ * submitted for that person, and nothing else (403 project.not_delegated on list, cancel and stream). An++ * app, mod or agent token, or a service without X-OV-Subject, naming a project is 403 project.not_delegated.*/const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const ANON = Object.freeze({ kind: 'anonymous' });@@@ -51,8 +60,9 @@ function verifyService(token, { publicKey, issuer, audience }) {return { ok: true, claims: r.claims };}--/** The project a principal acts for: its token's project_id claim, or null (no project named). */++/** The project a principal acts for: its token's project_id claim (or its delegated project), or null (no project named). */function projectOf(principal) {++ if (principal && principal.delegated) return PROJECT_RE.test(String(principal.project_id)) ? principal.project_id : null;const id = principal && principal.claims && principal.claims.project_id;return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;}@@@ -65,13 +75,44 @@ function projectOf(principal) {function requesterOf(principal) {if (!principal) return null;if (principal.kind === 'user') return { type: 'user', id: principal.subject };++ if (principal.delegated) return { type: 'user', id: principal.delegated.subject }; // the person; the service is viaif (principal.kind !== 'service') return null;const sub = String((principal.claims && principal.claims.sub) || principal.sub || '');if (sub.startsWith('svc:')) return { type: 'service', id: sub.slice(4) };return ids.parseSubject(sub);}--function createApiAuth({ config, keys, userAuth }) {++/** The service a delegated principal acts through, as a subject-ref string ('service:watch'), or null. */++const viaOf = (principal) => (principal && principal.delegated ? principal.delegated.via : null);++++function createApiAuth({ config, keys, userAuth, membership = null, log = console }) {++ const notDelegated = (detail) => ({ error: [403, 'project.not_delegated', detail] });++++ /**++ * X-OV-Project on a verified token: only a first-party service naming the person may use it, and only for a++ * project Network says the person is an admin or owner of (not archived). The answer never says which check++ * failed, so it never tells a caller that a project exists.++ */++ async function delegate(req, claims) {++ const project = String(req.get('X-OV-Project') || '').trim();++ const subject = String(req.get('X-OV-Subject') || '').trim();++ const sub = String(claims.sub || '');++ const refuse = notDelegated('a project is named only by a first-party service acting for a person who is an admin or owner of it (ADR-003 amendment 2026-10-12)');++ if (!sub.startsWith('svc:') || claims.project_id || !USER_SUBJECT.test(subject) || !PROJECT_RE.test(project)) return refuse;++ if (!membership) return { error: [503, 'project.check_unavailable', 'Run cannot check project memberships here'] };++ let r;++ try { r = await membership.allows(project, subject); }++ catch (e) {++ log.warn(`[Run] project membership check failed: ${e.message}`);++ return { error: [503, 'project.check_unavailable', 'Network could not confirm the project membership just now; try again'] };++ }++ if (!r.ok) return refuse;++ return { principal: {++ kind: 'service', sub, cap: claims.cap || [], claims, project_id: project, jti: claims.jti,++ delegated: { subject, via: `service:${sub.slice(4)}` },++ } };++ }++async function resolve(req) {const header = String(req.headers.authorization || '');if (!header.startsWith('Bearer ')) return { principal: ANON };@@@ -82,10 +123,12 @@ function createApiAuth({ config, keys, userAuth }) {if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });if (!r.ok) return { error: [401, r.code, r.reason] };++ if (req.get('X-OV-Project') != null) return delegate(req, r.claims);return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };}const claims = await userAuth.verify(token);if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };++ if (req.get('X-OV-Project') != null) return notDelegated('a person\'s own token never names a project here');const p = userPrincipal(claims);if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };return { principal: p };@@@ -130,7 +173,13 @@ function createApiAuth({ config, keys, userAuth }) {* when the token names no project (a first-party service token without the project_id claim, or a user* token): jobs belong to a project, and the billed project is never taken from a request body.*/-- function requireProject(req) {++ function requireProject(req, { delegated = false } = {}) {++ if (req.principal && req.principal.delegated && !delegated) {++ const err = new Error('a delegated call submits jobs and reads the ones it submitted; listing, cancelling and streaming need a token that names the project');++ err.status = 403;++ err.code = 'project.not_delegated';++ throw err;++ }const project = projectOf(req.principal);const requester = requesterOf(req.principal);if (!project || !requester) {@@@ -139,10 +188,10 @@ function createApiAuth({ config, keys, userAuth }) {err.code = 'run.project_required';throw err;}-- return { project, requester: ids.formatSubject(requester) };++ return { project, requester: ids.formatSubject(requester), via: viaOf(req.principal) };}-- return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf };++ return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf, viaOf };}--module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };++module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, viaOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };@@@ -16,7 +16,11 @@* | GET /admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |** The project and the requester always come from the token (claims.project_id, claims.sub), NEVER from the-- * body: a body field is never read for either. run.job.admin additionally reads and cancels any project's++ * body: a body field is never read for either. The one exception is project delegation (ADR-003 amendment++ * 2026-10-12, server/api/auth.js): a first-party service acting for a person names the project in++ * X-OV-Project once Network confirms the person is an admin or owner of it; the person is the requester and++ * the service is stored as via. Such a caller submits and reads the jobs it submitted for that person (any++ * other job is the same 404); list, cancel and stream are 403 project.not_delegated. run.job.admin additionally reads and cancels any project's* job (the manifest says so) and is the only way to pass project_id to a list. Another project's job is* always 404 run.job_not_found, never 403: the answer must not say whether the id exists.*@@@ -125,23 +129,25 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now* One job, scoped to the token's project — or to any project with run.job.admin. Null when the id is* not a job id, does not exist, or belongs to another project: all three are the same 404 to the caller.*/-- async function getJob(req, id) {++ async function getJob(req, id, { delegated = false } = {}) {if (!isJobId(id)) return null;-- if (admin(req)) return store.get(db, id, null);-- return store.get(db, id, apiAuth.requireProject(req).project);++ if (admin(req) && !req.principal.delegated) return store.get(db, id, null);++ const scope = apiAuth.requireProject(req, { delegated });++ if (scope.via) return store.getOwn(db, id, { projectId: scope.project, requester: scope.requester, via: scope.via });++ return store.get(db, id, scope.project);}r.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });// ── POST /jobs — run.job.submit ────────────────────────────────────────────r.post('/jobs', guard('run.job.submit'), wrap(async (req, res) => {-- const { project: projectId, requester } = apiAuth.requireProject(req);++ const { project: projectId, requester, via } = apiAuth.requireProject(req, { delegated: true });const body = validCreate(req.body);checkLimits(body, config.limits);const key = idempotencyKey(req, body);const at = now();const out = await db.tx(async (t) => {-- const made = await store.mint(t, body, { projectId, requester, idempotencyKey: key, now: at });++ const made = await store.mint(t, body, { projectId, requester, via, idempotencyKey: key, now: at });// The queued event is written in the same transaction as the row (never for an idempotent repeat:// the row key run:<job id>:queued is already taken, and that replay is the row that exists).if (made.created) await events.queued(t, made.row, { traceparent: req.ov && req.ov.traceparent });@@@ -165,7 +171,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now// ── GET /jobs/:id — run.job.read ───────────────────────────────────────────r.get('/jobs/:id', guard('run.job.read'), wrap(async (req, res) => {-- const row = await getJob(req, req.params.id);++ const row = await getJob(req, req.params.id, { delegated: true });if (!row) fail(404, 'run.job_not_found', 'no such job');res.json(store.readResult(row));}));@@@ -220,6 +226,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now// door is a query string a middleware cannot see.const p = req.principal;if (p.kind !== 'service') fail(403, 'capability.denied', 'run.job.stream is granted to service principals only');++ if (p.delegated) fail(403, 'project.not_delegated', 'a delegated call does not stream; a token that names the project does');const decision = contracts.capabilities.check(p.claims, 'run.job.stream');if (!decision.allowed) fail(403, decision.code || 'capability.denied', decision.reason);const project = apiAuth.projectOf(p);@@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');const { isLoopbackDirect } = require('openvibe-shared/metrics');const { loadConfig } = require('./config');const { openDb } = require('./db');--const { createKeyProvider, createUserAuth } = require('./network');++const { createKeyProvider, createUserAuth, createProjectMembership } = require('./network');const { createRunOutbox } = require('./events/outbox');const { createStreamTickets } = require('./jobs/ticket');const { createJobStream } = require('./jobs/stream');@@@ -55,7 +55,8 @@ function createApp(opts = {}) {// offers and the job loop that sends, mirrors and cancels. It is off without OV_OAUTH_CLIENT_SECRET —// Run cannot mint the Bot token — and nothing runs until server/index.js calls its start().const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });-- const apiAuth = createApiAuth({ config, keys, userAuth });++ const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });++ const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });const app = express();@@@ -5,7 +5,7 @@** The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection* (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in-- * transaction mode). Run is new: migrations/0001_jobs.sql is written as the final schema, no window.++ * transaction mode). 0001_jobs.sql is the jobs schema; 0002 adds the nullable via column (project delegation).** run_jobs one row per job (plan T14 R1; run.job-create-request@1)* run_events_outbox the openvibe-sdk transactional outbox (run.job.* events)@@@ -74,7 +74,7 @@ function recordOf(row) {* inputs [], egress none, requirements the contract's default, ttl_ms limits.wall_ms + 600000), the id* minted by Run (job_<ULID>; Run owns run.job.* and passes it to Bot unchanged).*/--function normalize(body, { projectId, requester, idempotencyKey, now }) {++function normalize(body, { projectId, requester, via = null, idempotencyKey, now }) {const job = {id: newJobId(now),class: body.class,@@@ -93,6 +93,7 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {id: job.id,project_id: projectId,requester,++ via,idempotency_key: idempotencyKey,class: job.class,job,@@@ -108,11 +109,11 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {/** Insert a new job. The same (project_id, idempotency_key) already there returns that row, created:false. */async function put(db, row) {const inserted = await db.maybe(-- `INSERT INTO run_jobs (id, project_id, requester, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)++ `INSERT INTO run_jobs (id, project_id, requester, via, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)ON CONFLICT (project_id, idempotency_key) DO NOTHINGRETURNING *`,-- [row.id, row.project_id, row.requester, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);++ [row.id, row.project_id, row.requester, row.via, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);if (inserted) return { row: inserted, created: true };const existing = await db.maybe('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [row.project_id, row.idempotency_key]);if (!existing) throw new Error('run_jobs: the insert conflicted but no row is visible'); // only a crashed transaction does this@@@ -125,15 +126,22 @@ async function put(db, row) {* first job (created:false); with a different body it is 409 run.idempotency.conflict.** The unique index is permanent: a key is spent for its project for good, where run.job-create-request@1-- * promises the first-job answer "within 24 h". The 24 h window cannot be implemented on this table (no-- * migration may add a column or relax the index), and answering a repeat with the first job forever is the-- * safe direction — it can never run the work twice — so a key older than 24 h answers the same way rather-- * than being recycled. A caller that reuses a key for different work is refused, not silently served.++ * promises the first-job answer "within 24 h". The 24 h window is not implemented on this table (the++ * index is never relaxed), and answering a repeat with the first job forever is the safe direction — it++ * can never run the work twice — so a key older than 24 h answers the same way rather than being++ * recycled. A caller that reuses a key for different work is refused, not silently served.++ *++ * A delegated submit (via set: a first-party service acting for a person in the project) is answered the++ * first job only when that job is the same person's through the same service: a key another requester spent++ * is 409 without naming the job, so a delegated caller never learns of work it did not start.*/--async function mint(db, body, { projectId, requester, idempotencyKey, now }) {-- const row = normalize(body, { projectId, requester, idempotencyKey, now });++async function mint(db, body, { projectId, requester, via = null, idempotencyKey, now }) {++ const row = normalize(body, { projectId, requester, via, idempotencyKey, now });const out = await put(db, row);if (out.created) return out;++ if ((via || out.row.via) && (out.row.requester !== requester || (out.row.via || null) !== via)) {++ fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was already used in this project`);++ }if (canonical(recordOf(out.row)) !== canonical(recordOf(row))) {fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was first used by ${out.row.id} with a different body`);}@@@ -143,6 +151,10 @@ async function mint(db, body, { projectId, requester, idempotencyKey, now }) {/** Project-scoped get: null when the job does not exist or belongs to another project (→ 404, never 403). */const get = (db, id, projectId = null) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1${projectId ? ' AND project_id = $2' : ''}`, projectId ? [id, projectId] : [id]);++/** A delegated caller's get: the job only if it is this project's, this person's, through this service (else null → 404). */++const getOwn = (db, id, { projectId, requester, via }) => db.maybe(++ 'SELECT * FROM run_jobs WHERE id = $1 AND project_id = $2 AND requester = $3 AND via = $4', [id, projectId, requester, via]);++/** One page, newest first, keyset cursor (created_at, id) → { rows, nextCursor }. */async function page(db, where, values, limit, cursor) {const conds = [...where];@@@ -326,7 +338,7 @@ function readResult(row) {module.exports = {// The store surface the API and the dispatcher bridge (plan T14 step 6) use.-- put, mint, get, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,++ put, mint, get, getOwn, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,// The poller's reads (waiting → placed → active → ended).waiting, active, dueTtl,};@@@ -1,15 +1,21 @@'use strict';/**-- * Run's side of OpenVibe.Network: the signing key every token is verified against, and offline-- * verification of a person's user token.++ * Run's side of OpenVibe.Network: the signing key every token is verified against, offline verification++ * of a person's user token, and the one Network read Run makes — a person's membership of a project, for++ * project delegation.*-- * Run calls no Network route: it verifies service tokens (audience openvibe.run) with the JWKS key and-- * accepts a Network user token as a Bearer exactly as Bot does. Only a token that names a project-- * (an app, agent or service principal with project_id) can act on jobs — every route says so.++ * Run verifies service tokens (audience openvibe.run) with the JWKS key and accepts a Network user token++ * as a Bearer exactly as Bot does. Only a token that names a project (an app, agent or service principal++ * with project_id), or a first-party service delegated into a project (X-OV-Subject + X-OV-Project, see++ * createProjectMembership), can act on jobs — every route says so.*/const crypto = require('crypto');--const { verifyUserToken } = require('openvibe-sdk/auth');++const { verifyUserToken, createServiceTokenClient } = require('openvibe-sdk/auth');++++const MEMBER_READ = 'network.project.member.read';++const DELEGABLE_ROLES = new Set(['admin', 'owner']);++const MEMBER_TTL_MS = 60_000; // the amendment's ceiling: a removed member or an archived project stops new jobs within a minute/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {@@@ -60,4 +66,39 @@ function createUserAuth(config, keys) {return { verify };}--module.exports = { createKeyProvider, createUserAuth };++/**++ * Project delegation (ADR-003 amendment 2026-10-12): may a first-party service start a job for this person in++ * this project? Network states the membership (GET /internal/projects/:project/members/:subject, capability++ * network.project.member.read, minted through Run's own client); the decision is Run's: only an admin or owner of++ * a project that is not archived may be acted for. A yes is cached for MEMBER_TTL_MS, a no never (the person may++ * have just been added). → { ok: true } | { ok: false } (a 404, another role, an archived project), and throws++ * when Network cannot be asked (no client secret, no token, an error answer, a timeout): the caller answers 503.++ */++function createProjectMembership(config, { fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {++ const { clientId, clientSecret } = config.oauth;++ const tokens = clientSecret++ ? createServiceTokenClient({ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', scope: MEMBER_READ, fetch: fetchImpl, now })++ : null;++ const yes = new Map(); // `${project} ${subject}` → when the yes expires++ async function allows(project, subject) {++ const key = `${project} ${subject}`;++ const until = yes.get(key);++ if (until && until > now()) return { ok: true };++ yes.delete(key);++ if (!tokens) throw new Error('OV_OAUTH_CLIENT_SECRET is unset: Run cannot ask Network about a project membership');++ const url = `${config.network.internalUrl}/internal/projects/${encodeURIComponent(project)}/members/${encodeURIComponent(subject)}`;++ const res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });++ if (res.status === 404) return { ok: false };++ if (!res.ok) throw new Error(`Network answered ${res.status} to the membership read`);++ const body = await res.json();++ const ok = !!body && body.project_id === project && body.subject === subject && DELEGABLE_ROLES.has(body.role) && body.archived === false;++ if (ok) {++ if (yes.size > 10_000) yes.clear();++ yes.set(key, now() + MEMBER_TTL_MS);++ }++ return { ok };++ }++ return { allows, enabled: Boolean(tokens) };++}++++module.exports = { createKeyProvider, createUserAuth, createProjectMembership, MEMBER_TTL_MS, DELEGABLE_ROLES };nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++/**++ * Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (X-OV-Subject)++ * names a project with X-OV-Project, and Run asks Network (the stub's members route, through Run's own client++ * credentials) whether the person is an admin or owner of it. The person is the requester, the service is via;++ * the service submits and reads only its own jobs for that person; everyone else naming a project is refused.++ *++ * node test/delegation.test.js++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++const { ids } = contracts;++++async function checks(t) {++ const P = ids.newId('project');++ const Q = ids.newId('project');++ const ada = ids.newId('user');++ const bo = ids.newId('user');++ const dev = ids.newId('user');++ const viewer = ids.newId('user');++ const old = ids.newId('user');++ t.network.members.set(`${P} ${ada}`, { role: 'admin' });++ t.network.members.set(`${P} ${bo}`, { role: 'owner' });++ t.network.members.set(`${P} ${dev}`, { role: 'developer' });++ t.network.members.set(`${P} ${viewer}`, { role: 'viewer' });++ t.network.members.set(`${Q} ${old}`, { role: 'owner', archived: true });++++ // A first-party service token (no project of its own) acting for `subject` in `project`.++ const as = (subject, project, { sub = 'svc:watch', cap = ['run.job.submit', 'run.job.read', 'run.job.list', 'run.job.cancel', 'run.job.stream'] } = {}) => ({++ token: t.network.signService({ sub, cap }),++ headers: { 'X-OV-Subject': subject, 'X-OV-Project': project },++ });++ const refused = (r, what) => {++ assert.strictEqual(r.status, 403, `${what}: ${r.text}`);++ assert.strictEqual(r.json.code, 'project.not_delegated', what);++ };++++ let jobId;++ await check('an admin member: the job is the project\'s, the person is the requester, the service is via', async () => {++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P), key: 'watch.chk_1.0' });++ assert.strictEqual(r.status, 201, r.text);++ assert.ok(contracts.validate('run.job-create-result@1', r.json).valid, JSON.stringify(r.json));++ jobId = r.json.job.id;++ assert.strictEqual(r.json.job.project_id, P);++ assert.deepStrictEqual(r.json.job.requester, { type: 'user', id: ada });++ const [row] = await t.db.many('SELECT requester, via FROM run_jobs WHERE id = $1', [jobId]);++ assert.deepStrictEqual({ ...row }, { requester: `user:${ada}`, via: 'service:watch' });++ const queued = await t.outboxRows('run.job.queued', jobId);++ assert.strictEqual(queued.length, 1);++ assert.ok(contracts.validate('run.job.queued@1', queued[0].payload).valid);++ // Run asked Network as itself, for exactly the capability the read needs.++ assert.ok(t.network.tokenRequests.scopes.includes('network.project.member.read'));++ assert.ok(t.network.tokenRequests.audiences.includes('openvibe.network'));++ assert.match(String(t.network.memberState.lastAuth), /^Bearer /);++ // An owner is accepted too.++ const owner = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) });++ assert.strictEqual(owner.status, 201, owner.text);++ });++++ await check('the delegated caller reads its own job; another person, another service: the same 404', async () => {++ const mine = await t.call('GET', `/api/v1/jobs/${jobId}`, as(ada, P));++ assert.strictEqual(mine.status, 200, mine.text);++ assert.strictEqual(mine.json.id, jobId);++ const otherPerson = await t.call('GET', `/api/v1/jobs/${jobId}`, as(bo, P));++ const otherService = await t.call('GET', `/api/v1/jobs/${jobId}`, as(ada, P, { sub: 'svc:actor' }));++ const missing = await t.call('GET', `/api/v1/jobs/${ids.newId('job')}`, as(ada, P));++ for (const r of [otherPerson, otherService]) { assert.strictEqual(r.status, 404, r.text); assert.strictEqual(r.json.code, 'run.job_not_found'); }++ assert.strictEqual(otherPerson.text.replace(/"request_id":"[^"]*"|"trace_id":"[^"]*"/g, ''), missing.text.replace(/"request_id":"[^"]*"|"trace_id":"[^"]*"/g, ''), 'never says the job exists');++ // The project's own token still sees every job of the project.++ const project = await t.call('GET', `/api/v1/jobs/${jobId}`, { token: t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.read'], project_id: P }) });++ assert.strictEqual(project.status, 200, project.text);++ });++++ await check('list, cancel and stream stay with project tokens', async () => {++ refused(await t.call('GET', '/api/v1/jobs', as(ada, P)), 'list');++ refused(await t.call('POST', `/api/v1/jobs/${jobId}/cancel`, as(ada, P)), 'cancel');++ refused(await t.call('POST', `/api/v1/jobs/${jobId}/stream/ticket`, as(ada, P)), 'stream ticket');++ const d = as(ada, P);++ const stream = await t.sse(`/api/v1/jobs/${jobId}/stream`, { headers: { Authorization: `Bearer ${d.token}`, ...d.headers } });++ assert.strictEqual(stream.status, 403, stream.text);++ assert.strictEqual(stream.json.code, 'project.not_delegated');++ const [row] = await t.db.many('SELECT state FROM run_jobs WHERE id = $1', [jobId]);++ assert.strictEqual(row.state, 'queued', 'the refused cancel changed nothing');++ });++++ await check('a developer, a viewer, a non-member and an archived project are refused alike', async () => {++ const stranger = ids.newId('user');++ for (const [subject, project, what] of [[dev, P, 'developer'], [viewer, P, 'viewer'], [stranger, P, 'non-member'], [old, Q, 'archived project'], [ada, ids.newId('project'), 'unknown project']]) {++ refused(await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(subject, project) }), what);++ }++ const made = await t.db.many('SELECT id FROM run_jobs WHERE project_id = $1 AND requester = $2', [P, `user:${dev}`]);++ assert.strictEqual(made.length, 0, 'no job was made for a refused delegation');++ });++++ await check('only a first-party service naming a person may name a project', async () => {++ const body = t.request();++ // An app (a mod or an agent is refused by the same svc: test): its project is its token's own.++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.submit'], project_id: P }), headers: { 'X-OV-Subject': ada, 'X-OV-Project': P } }), 'an app token');++ // A service with no person to act for.++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: 'svc:watch', cap: ['run.job.submit'] }), headers: { 'X-OV-Project': P } }), 'no X-OV-Subject');++ // A token that already names a project cannot name another.++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: 'svc:watch', cap: ['run.job.submit'], project_id: Q }), headers: { 'X-OV-Subject': ada, 'X-OV-Project': P } }), 'a token with project_id');++ // Malformed headers.++ refused(await t.call('POST', '/api/v1/jobs', { body, ...as(ada, 'prj_nope') }), 'a malformed project');++ refused(await t.call('POST', '/api/v1/jobs', { body, ...as('usr_nope', P) }), 'a malformed subject');++ // A person's own token.++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signUser({ subject: ada }), headers: { 'X-OV-Project': P } }), 'a user token');++ // The service still needs its own grant: delegation adds no capability.++ const noGrant = await t.call('POST', '/api/v1/jobs', { body, ...as(ada, P, { cap: ['run.job.read'] }) });++ assert.strictEqual(noGrant.status, 403, noGrant.text);++ assert.strictEqual(noGrant.json.code, 'capability.denied');++ });++++ await check('a yes is cached at most 60 s; a removed member is refused after it', async () => {++ const before = t.network.memberState.reads;++ assert.strictEqual((await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) })).status, 201);++ assert.strictEqual(t.network.memberState.reads, before, 'served from the cache (bo was confirmed by an earlier check)');++ t.network.members.delete(`${P} ${bo}`);++ assert.strictEqual((await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) })).status, 201, 'within the minute');++ t.clock.offset += 61_000;++ try {++ refused(await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) }), 'after the minute');++ assert.ok(t.network.memberState.reads > before, 'asked Network again');++ } finally { t.clock.offset -= 61_000; }++ });++++ await check('Network unavailable: 503 project.check_unavailable, never a guess', async () => {++ t.clock.offset += 61_000; // past every cached yes++ t.network.memberState.fail = true;++ try {++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P) });++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual(r.json.code, 'project.check_unavailable');++ } finally { t.network.memberState.fail = false; t.clock.offset -= 61_000; }++ });++++ await check('a delegated repeat answers its own first job; a key someone else spent is 409 without naming it', async () => {++ const again = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P), key: 'watch.chk_1.0' });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.json.created, false);++ assert.strictEqual(again.json.job.id, jobId);++ const theirs = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P, { sub: 'svc:actor' }), key: 'watch.chk_1.0' });++ assert.strictEqual(theirs.status, 409, theirs.text);++ assert.strictEqual(theirs.json.code, 'run.idempotency.conflict');++ assert.ok(!theirs.text.includes(jobId), 'the conflict does not name the job');++ // The project's own token reusing a delegated key is refused the same way (the key is spent).++ const project = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], project: P, sub: 'svc:builder', key: 'watch.chk_1.0' });++ assert.strictEqual(project.status, 409, project.text);++ });++}++++async function main() {++ // Run's own client secret is set: the membership read goes through the real client-credentials path.++ const t = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'run-test-secret' } });++ try {++ await checks(t);++ } finally {++ await t.close();++ }++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });@@@ -40,7 +40,23 @@ async function startNetwork() {const issuer = 'http://network.test';const tokenRequests = { n: 0, audiences: [], scopes: [] };const offers = []; // what the registry publishes; a test replaces its contents (t.setOffers)++ // Project memberships Network states (GET /internal/projects/:project/members/:subject, project delegation):++ // `${project} ${subject}` → { role, archived }; memberReads counts the reads, memberFail makes them answer 500.++ const members = new Map();++ const memberState = { reads: 0, fail: false, lastAuth: null };const server = http.createServer((req, res) => {++ const member = /^\/internal\/projects\/([^/]+)\/members\/([^/?]+)$/.exec(req.url);++ if (req.method === 'GET' && member) {++ memberState.reads++;++ memberState.lastAuth = req.headers.authorization || null;++ if (memberState.fail) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end('{}'); }++ const project = decodeURIComponent(member[1]);++ const subject = decodeURIComponent(member[2]);++ const m = members.get(`${project} ${subject}`);++ if (!m) { res.writeHead(404, { 'Content-Type': 'application/problem+json' }); return res.end(JSON.stringify({ code: 'project.member_not_found' })); }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ project_id: project, subject, role: m.role, archived: !!m.archived, added_at: null }));++ }if (req.url === '/api/.well-known/jwks') {res.writeHead(200, { 'Content-Type': 'application/json' });return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));@@@ -84,7 +100,7 @@ async function startNetwork() {});const url = await listen(server);return {-- url, issuer, publicPem, privatePem, tokenRequests, offers,++ url, issuer, publicPem, privatePem, tokenRequests, offers, members, memberState,/** Publish exactly these platform.resource-offer@1 documents (the array keeps its identity). */setOffers(list) { offers.splice(0, offers.length, ...list); },close: () => new Promise((r) => server.close(r)),
@@ -47,6 +47,12 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
@@ -47,6 +47,12 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
(`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`.
(`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`.
+- Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (`X-OV-Subject`) may
+ name a project with `X-OV-Project`. Run asks Network whether the person is an admin or owner of the unarchived
+ project (`network.project.member.read`, a yes cached 60 s), then the person is the requester and the service is
+ stored as `via` (`migrations/0002_job_via.sql`). The service submits and reads only the jobs it started for that
+ person; list, cancel and stream are `403 project.not_delegated`, and so is any other caller naming a project. Network
+ unreachable is `503 project.check_unavailable`. Watch's `run-job` action is the first user.
- The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading of
- The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading of
the job is `run:<id>:<n>`).
the job is `run:<id>:<n>`).
- Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body
- Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body
@@ -80,7 +86,8 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
@@ -80,7 +86,8 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
- PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot.
- PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot.
- Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared.
- Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared.
- OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; the offers Run places from
- OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; the offers Run places from
- (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token and its Bot token.
+ (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token, its Bot token and the
+ token for project memberships (`GET /internal/projects/:project/members/:subject`, project delegation).
- OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`.
- OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`.
- OpenVibe.Bot (`RUN_BOT_URL`, `RUN_POLL_MS`): the node link and the meter. Run sends each job to it
- OpenVibe.Bot (`RUN_BOT_URL`, `RUN_POLL_MS`): the node link and the meter. Run sends each job to it
(`bot.job.dispatch`, audience `openvibe.bot`, minted from `OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against
(`bot.job.dispatch`, audience `openvibe.bot`, minted from `OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Project delegation (ADR-003 amendment 2026-10-12): the first-party service that started a job for a person in a
+-- project, as a subject-ref string ('service:watch'); the person is the requester. NULL when the project's own
+-- token started the job. A delegated caller reads only the jobs whose requester and via are its own.
+ALTER TABLE run_jobs ADD COLUMN IF NOT EXISTS via text;
@@ -13,11 +13,20 @@
@@ -13,11 +13,20 @@
*
*
* The project a job belongs to comes from the token's own project_id claim (a developer app or agent
* The project a job belongs to comes from the token's own project_id claim (a developer app or agent
* token names one), never from the request body or a query: the requester and the billed project are
* token names one), never from the request body or a query: the requester and the billed project are
- * the token's, always.
+ * the token's, always — with one exception, project delegation (ADR-003 amendment 2026-10-12): a
+ * first-party service (svc:) acting for a person (X-OV-Subject: usr_…) may name a project with
+ * X-OV-Project once Network confirms the person is an admin or owner of it. Such a principal is
+ *
+ * { kind: 'service', sub: 'svc:watch', ..., project_id: <X-OV-Project>, delegated: { subject: 'usr_…', via: 'service:watch' } }
+ *
+ * The person is the requester and the service is recorded as via; it may submit a job and read the jobs it
+ * submitted for that person, and nothing else (403 project.not_delegated on list, cancel and stream). An
+ * app, mod or agent token, or a service without X-OV-Subject, naming a project is 403 project.not_delegated.
*/
*/
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ANON = Object.freeze({ kind: 'anonymous' });
const ANON = Object.freeze({ kind: 'anonymous' });
@@ -51,8 +60,9 @@ function verifyService(token, { publicKey, issuer, audience }) {
@@ -51,8 +60,9 @@ function verifyService(token, { publicKey, issuer, audience }) {
return { ok: true, claims: r.claims };
return { ok: true, claims: r.claims };
}
}
-/** The project a principal acts for: its token's project_id claim, or null (no project named). */
+/** The project a principal acts for: its token's project_id claim (or its delegated project), or null (no project named). */
function projectOf(principal) {
function projectOf(principal) {
+ if (principal && principal.delegated) return PROJECT_RE.test(String(principal.project_id)) ? principal.project_id : null;
const id = principal && principal.claims && principal.claims.project_id;
const id = principal && principal.claims && principal.claims.project_id;
return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
}
}
@@ -65,13 +75,44 @@ function projectOf(principal) {
@@ -65,13 +75,44 @@ function projectOf(principal) {
function requesterOf(principal) {
function requesterOf(principal) {
if (!principal) return null;
if (!principal) return null;
if (principal.kind === 'user') return { type: 'user', id: principal.subject };
if (principal.kind === 'user') return { type: 'user', id: principal.subject };
+ if (principal.delegated) return { type: 'user', id: principal.delegated.subject }; // the person; the service is via
if (principal.kind !== 'service') return null;
if (principal.kind !== 'service') return null;
const sub = String((principal.claims && principal.claims.sub) || principal.sub || '');
const sub = String((principal.claims && principal.claims.sub) || principal.sub || '');
if (sub.startsWith('svc:')) return { type: 'service', id: sub.slice(4) };
if (sub.startsWith('svc:')) return { type: 'service', id: sub.slice(4) };
return ids.parseSubject(sub);
return ids.parseSubject(sub);
}
}
-function createApiAuth({ config, keys, userAuth }) {
+/** The service a delegated principal acts through, as a subject-ref string ('service:watch'), or null. */
+const viaOf = (principal) => (principal && principal.delegated ? principal.delegated.via : null);
+
+function createApiAuth({ config, keys, userAuth, membership = null, log = console }) {
+ const notDelegated = (detail) => ({ error: [403, 'project.not_delegated', detail] });
+
+ /**
+ * X-OV-Project on a verified token: only a first-party service naming the person may use it, and only for a
+ * project Network says the person is an admin or owner of (not archived). The answer never says which check
+ * failed, so it never tells a caller that a project exists.
+ */
+ async function delegate(req, claims) {
+ const project = String(req.get('X-OV-Project') || '').trim();
+ const subject = String(req.get('X-OV-Subject') || '').trim();
+ const sub = String(claims.sub || '');
+ const refuse = notDelegated('a project is named only by a first-party service acting for a person who is an admin or owner of it (ADR-003 amendment 2026-10-12)');
+ if (!sub.startsWith('svc:') || claims.project_id || !USER_SUBJECT.test(subject) || !PROJECT_RE.test(project)) return refuse;
+ if (!membership) return { error: [503, 'project.check_unavailable', 'Run cannot check project memberships here'] };
+ let r;
+ try { r = await membership.allows(project, subject); }
+ catch (e) {
+ log.warn(`[Run] project membership check failed: ${e.message}`);
+ return { error: [503, 'project.check_unavailable', 'Network could not confirm the project membership just now; try again'] };
+ }
+ if (!r.ok) return refuse;
+ return { principal: {
+ kind: 'service', sub, cap: claims.cap || [], claims, project_id: project, jti: claims.jti,
+ delegated: { subject, via: `service:${sub.slice(4)}` },
+ } };
+ }
+
async function resolve(req) {
async function resolve(req) {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ')) return { principal: ANON };
if (!header.startsWith('Bearer ')) return { principal: ANON };
@@ -82,10 +123,12 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -82,10 +123,12 @@ function createApiAuth({ config, keys, userAuth }) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
if (!r.ok) return { error: [401, r.code, r.reason] };
if (!r.ok) return { error: [401, r.code, r.reason] };
+ if (req.get('X-OV-Project') != null) return delegate(req, r.claims);
return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };
return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };
}
}
const claims = await userAuth.verify(token);
const claims = await userAuth.verify(token);
if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
+ if (req.get('X-OV-Project') != null) return notDelegated('a person\'s own token never names a project here');
const p = userPrincipal(claims);
const p = userPrincipal(claims);
if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
return { principal: p };
return { principal: p };
@@ -130,7 +173,13 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -130,7 +173,13 @@ function createApiAuth({ config, keys, userAuth }) {
* when the token names no project (a first-party service token without the project_id claim, or a user
* when the token names no project (a first-party service token without the project_id claim, or a user
* token): jobs belong to a project, and the billed project is never taken from a request body.
* token): jobs belong to a project, and the billed project is never taken from a request body.
*/
*/
- function requireProject(req) {
+ function requireProject(req, { delegated = false } = {}) {
+ if (req.principal && req.principal.delegated && !delegated) {
+ const err = new Error('a delegated call submits jobs and reads the ones it submitted; listing, cancelling and streaming need a token that names the project');
+ err.status = 403;
+ err.code = 'project.not_delegated';
+ throw err;
+ }
const project = projectOf(req.principal);
const project = projectOf(req.principal);
const requester = requesterOf(req.principal);
const requester = requesterOf(req.principal);
if (!project || !requester) {
if (!project || !requester) {
@@ -139,10 +188,10 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -139,10 +188,10 @@ function createApiAuth({ config, keys, userAuth }) {
err.code = 'run.project_required';
err.code = 'run.project_required';
throw err;
throw err;
}
}
- return { project, requester: ids.formatSubject(requester) };
+ return { project, requester: ids.formatSubject(requester), via: viaOf(req.principal) };
}
}
- return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf };
+ return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf, viaOf };
}
}
-module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };
+module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, viaOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };
@@ -16,7 +16,11 @@
@@ -16,7 +16,11 @@
* | GET /admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |
* | GET /admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |
*
*
* The project and the requester always come from the token (claims.project_id, claims.sub), NEVER from the
* The project and the requester always come from the token (claims.project_id, claims.sub), NEVER from the
- * body: a body field is never read for either. run.job.admin additionally reads and cancels any project's
+ * body: a body field is never read for either. The one exception is project delegation (ADR-003 amendment
+ * 2026-10-12, server/api/auth.js): a first-party service acting for a person names the project in
+ * X-OV-Project once Network confirms the person is an admin or owner of it; the person is the requester and
+ * the service is stored as via. Such a caller submits and reads the jobs it submitted for that person (any
+ * other job is the same 404); list, cancel and stream are 403 project.not_delegated. run.job.admin additionally reads and cancels any project's
* job (the manifest says so) and is the only way to pass project_id to a list. Another project's job is
* job (the manifest says so) and is the only way to pass project_id to a list. Another project's job is
* always 404 run.job_not_found, never 403: the answer must not say whether the id exists.
* always 404 run.job_not_found, never 403: the answer must not say whether the id exists.
*
*
@@ -125,23 +129,25 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
@@ -125,23 +129,25 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
* One job, scoped to the token's project — or to any project with run.job.admin. Null when the id is
* One job, scoped to the token's project — or to any project with run.job.admin. Null when the id is
* not a job id, does not exist, or belongs to another project: all three are the same 404 to the caller.
* not a job id, does not exist, or belongs to another project: all three are the same 404 to the caller.
*/
*/
- async function getJob(req, id) {
+ async function getJob(req, id, { delegated = false } = {}) {
if (!isJobId(id)) return null;
if (!isJobId(id)) return null;
- if (admin(req)) return store.get(db, id, null);
- return store.get(db, id, apiAuth.requireProject(req).project);
+ if (admin(req) && !req.principal.delegated) return store.get(db, id, null);
+ const scope = apiAuth.requireProject(req, { delegated });
+ if (scope.via) return store.getOwn(db, id, { projectId: scope.project, requester: scope.requester, via: scope.via });
+ return store.get(db, id, scope.project);
}
}
r.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
// ── POST /jobs — run.job.submit ────────────────────────────────────────────
// ── POST /jobs — run.job.submit ────────────────────────────────────────────
r.post('/jobs', guard('run.job.submit'), wrap(async (req, res) => {
r.post('/jobs', guard('run.job.submit'), wrap(async (req, res) => {
- const { project: projectId, requester } = apiAuth.requireProject(req);
+ const { project: projectId, requester, via } = apiAuth.requireProject(req, { delegated: true });
const body = validCreate(req.body);
const body = validCreate(req.body);
checkLimits(body, config.limits);
checkLimits(body, config.limits);
const key = idempotencyKey(req, body);
const key = idempotencyKey(req, body);
const at = now();
const at = now();
const out = await db.tx(async (t) => {
const out = await db.tx(async (t) => {
- const made = await store.mint(t, body, { projectId, requester, idempotencyKey: key, now: at });
+ const made = await store.mint(t, body, { projectId, requester, via, idempotencyKey: key, now: at });
// The queued event is written in the same transaction as the row (never for an idempotent repeat:
// The queued event is written in the same transaction as the row (never for an idempotent repeat:
// the row key run:<job id>:queued is already taken, and that replay is the row that exists).
// the row key run:<job id>:queued is already taken, and that replay is the row that exists).
if (made.created) await events.queued(t, made.row, { traceparent: req.ov && req.ov.traceparent });
if (made.created) await events.queued(t, made.row, { traceparent: req.ov && req.ov.traceparent });
@@ -165,7 +171,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
@@ -165,7 +171,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
// ── GET /jobs/:id — run.job.read ───────────────────────────────────────────
// ── GET /jobs/:id — run.job.read ───────────────────────────────────────────
r.get('/jobs/:id', guard('run.job.read'), wrap(async (req, res) => {
r.get('/jobs/:id', guard('run.job.read'), wrap(async (req, res) => {
- const row = await getJob(req, req.params.id);
+ const row = await getJob(req, req.params.id, { delegated: true });
if (!row) fail(404, 'run.job_not_found', 'no such job');
if (!row) fail(404, 'run.job_not_found', 'no such job');
res.json(store.readResult(row));
res.json(store.readResult(row));
}));
}));
@@ -220,6 +226,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
@@ -220,6 +226,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
// door is a query string a middleware cannot see.
// door is a query string a middleware cannot see.
const p = req.principal;
const p = req.principal;
if (p.kind !== 'service') fail(403, 'capability.denied', 'run.job.stream is granted to service principals only');
if (p.kind !== 'service') fail(403, 'capability.denied', 'run.job.stream is granted to service principals only');
+ if (p.delegated) fail(403, 'project.not_delegated', 'a delegated call does not stream; a token that names the project does');
const decision = contracts.capabilities.check(p.claims, 'run.job.stream');
const decision = contracts.capabilities.check(p.claims, 'run.job.stream');
if (!decision.allowed) fail(403, decision.code || 'capability.denied', decision.reason);
if (!decision.allowed) fail(403, decision.code || 'capability.denied', decision.reason);
const project = apiAuth.projectOf(p);
const project = apiAuth.projectOf(p);
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
-const { createKeyProvider, createUserAuth } = require('./network');
+const { createKeyProvider, createUserAuth, createProjectMembership } = require('./network');
const { createRunOutbox } = require('./events/outbox');
const { createRunOutbox } = require('./events/outbox');
const { createStreamTickets } = require('./jobs/ticket');
const { createStreamTickets } = require('./jobs/ticket');
const { createJobStream } = require('./jobs/stream');
const { createJobStream } = require('./jobs/stream');
@@ -55,7 +55,8 @@ function createApp(opts = {}) {
@@ -55,7 +55,8 @@ function createApp(opts = {}) {
// offers and the job loop that sends, mirrors and cancels. It is off without OV_OAUTH_CLIENT_SECRET —
// offers and the job loop that sends, mirrors and cancels. It is off without OV_OAUTH_CLIENT_SECRET —
// Run cannot mint the Bot token — and nothing runs until server/index.js calls its start().
// Run cannot mint the Bot token — and nothing runs until server/index.js calls its start().
const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
- const apiAuth = createApiAuth({ config, keys, userAuth });
+ const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
+ const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const app = express();
const app = express();
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*
*
* The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection
* The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection
* (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
* (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
- * transaction mode). Run is new: migrations/0001_jobs.sql is written as the final schema, no window.
+ * transaction mode). 0001_jobs.sql is the jobs schema; 0002 adds the nullable via column (project delegation).
*
*
* run_jobs one row per job (plan T14 R1; run.job-create-request@1)
* run_jobs one row per job (plan T14 R1; run.job-create-request@1)
* run_events_outbox the openvibe-sdk transactional outbox (run.job.* events)
* run_events_outbox the openvibe-sdk transactional outbox (run.job.* events)
@@ -74,7 +74,7 @@ function recordOf(row) {
@@ -74,7 +74,7 @@ function recordOf(row) {
* inputs [], egress none, requirements the contract's default, ttl_ms limits.wall_ms + 600000), the id
* inputs [], egress none, requirements the contract's default, ttl_ms limits.wall_ms + 600000), the id
* minted by Run (job_<ULID>; Run owns run.job.* and passes it to Bot unchanged).
* minted by Run (job_<ULID>; Run owns run.job.* and passes it to Bot unchanged).
*/
*/
-function normalize(body, { projectId, requester, idempotencyKey, now }) {
+function normalize(body, { projectId, requester, via = null, idempotencyKey, now }) {
const job = {
const job = {
id: newJobId(now),
id: newJobId(now),
class: body.class,
class: body.class,
@@ -93,6 +93,7 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {
@@ -93,6 +93,7 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {
id: job.id,
id: job.id,
project_id: projectId,
project_id: projectId,
requester,
requester,
+ via,
idempotency_key: idempotencyKey,
idempotency_key: idempotencyKey,
class: job.class,
class: job.class,
job,
job,
@@ -108,11 +109,11 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {
@@ -108,11 +109,11 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {
/** Insert a new job. The same (project_id, idempotency_key) already there returns that row, created:false. */
/** Insert a new job. The same (project_id, idempotency_key) already there returns that row, created:false. */
async function put(db, row) {
async function put(db, row) {
const inserted = await db.maybe(
const inserted = await db.maybe(
- `INSERT INTO run_jobs (id, project_id, requester, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ `INSERT INTO run_jobs (id, project_id, requester, via, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (project_id, idempotency_key) DO NOTHING
ON CONFLICT (project_id, idempotency_key) DO NOTHING
RETURNING *`,
RETURNING *`,
- [row.id, row.project_id, row.requester, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);
+ [row.id, row.project_id, row.requester, row.via, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);
if (inserted) return { row: inserted, created: true };
if (inserted) return { row: inserted, created: true };
const existing = await db.maybe('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [row.project_id, row.idempotency_key]);
const existing = await db.maybe('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [row.project_id, row.idempotency_key]);
if (!existing) throw new Error('run_jobs: the insert conflicted but no row is visible'); // only a crashed transaction does this
if (!existing) throw new Error('run_jobs: the insert conflicted but no row is visible'); // only a crashed transaction does this
@@ -125,15 +126,22 @@ async function put(db, row) {
@@ -125,15 +126,22 @@ async function put(db, row) {
* first job (created:false); with a different body it is 409 run.idempotency.conflict.
* first job (created:false); with a different body it is 409 run.idempotency.conflict.
*
*
* The unique index is permanent: a key is spent for its project for good, where run.job-create-request@1
* The unique index is permanent: a key is spent for its project for good, where run.job-create-request@1
- * promises the first-job answer "within 24 h". The 24 h window cannot be implemented on this table (no
- * migration may add a column or relax the index), and answering a repeat with the first job forever is the
- * safe direction — it can never run the work twice — so a key older than 24 h answers the same way rather
- * than being recycled. A caller that reuses a key for different work is refused, not silently served.
+ * promises the first-job answer "within 24 h". The 24 h window is not implemented on this table (the
+ * index is never relaxed), and answering a repeat with the first job forever is the safe direction — it
+ * can never run the work twice — so a key older than 24 h answers the same way rather than being
+ * recycled. A caller that reuses a key for different work is refused, not silently served.
+ *
+ * A delegated submit (via set: a first-party service acting for a person in the project) is answered the
+ * first job only when that job is the same person's through the same service: a key another requester spent
+ * is 409 without naming the job, so a delegated caller never learns of work it did not start.
*/
*/
-async function mint(db, body, { projectId, requester, idempotencyKey, now }) {
- const row = normalize(body, { projectId, requester, idempotencyKey, now });
+async function mint(db, body, { projectId, requester, via = null, idempotencyKey, now }) {
+ const row = normalize(body, { projectId, requester, via, idempotencyKey, now });
const out = await put(db, row);
const out = await put(db, row);
if (out.created) return out;
if (out.created) return out;
+ if ((via || out.row.via) && (out.row.requester !== requester || (out.row.via || null) !== via)) {
+ fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was already used in this project`);
+ }
if (canonical(recordOf(out.row)) !== canonical(recordOf(row))) {
if (canonical(recordOf(out.row)) !== canonical(recordOf(row))) {
fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was first used by ${out.row.id} with a different body`);
fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was first used by ${out.row.id} with a different body`);
}
}
@@ -143,6 +151,10 @@ async function mint(db, body, { projectId, requester, idempotencyKey, now }) {
@@ -143,6 +151,10 @@ async function mint(db, body, { projectId, requester, idempotencyKey, now }) {
/** Project-scoped get: null when the job does not exist or belongs to another project (→ 404, never 403). */
/** Project-scoped get: null when the job does not exist or belongs to another project (→ 404, never 403). */
const get = (db, id, projectId = null) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1${projectId ? ' AND project_id = $2' : ''}`, projectId ? [id, projectId] : [id]);
const get = (db, id, projectId = null) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1${projectId ? ' AND project_id = $2' : ''}`, projectId ? [id, projectId] : [id]);
+/** A delegated caller's get: the job only if it is this project's, this person's, through this service (else null → 404). */
+const getOwn = (db, id, { projectId, requester, via }) => db.maybe(
+ 'SELECT * FROM run_jobs WHERE id = $1 AND project_id = $2 AND requester = $3 AND via = $4', [id, projectId, requester, via]);
+
/** One page, newest first, keyset cursor (created_at, id) → { rows, nextCursor }. */
/** One page, newest first, keyset cursor (created_at, id) → { rows, nextCursor }. */
async function page(db, where, values, limit, cursor) {
async function page(db, where, values, limit, cursor) {
const conds = [...where];
const conds = [...where];
@@ -326,7 +338,7 @@ function readResult(row) {
@@ -326,7 +338,7 @@ function readResult(row) {
module.exports = {
module.exports = {
// The store surface the API and the dispatcher bridge (plan T14 step 6) use.
// The store surface the API and the dispatcher bridge (plan T14 step 6) use.
- put, mint, get, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,
+ put, mint, get, getOwn, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,
// The poller's reads (waiting → placed → active → ended).
// The poller's reads (waiting → placed → active → ended).
waiting, active, dueTtl,
waiting, active, dueTtl,
};
};
@@ -1,15 +1,21 @@
@@ -1,15 +1,21 @@
'use strict';
'use strict';
/**
/**
- * Run's side of OpenVibe.Network: the signing key every token is verified against, and offline
- * verification of a person's user token.
+ * Run's side of OpenVibe.Network: the signing key every token is verified against, offline verification
+ * of a person's user token, and the one Network read Run makes — a person's membership of a project, for
+ * project delegation.
*
*
- * Run calls no Network route: it verifies service tokens (audience openvibe.run) with the JWKS key and
- * accepts a Network user token as a Bearer exactly as Bot does. Only a token that names a project
- * (an app, agent or service principal with project_id) can act on jobs — every route says so.
+ * Run verifies service tokens (audience openvibe.run) with the JWKS key and accepts a Network user token
+ * as a Bearer exactly as Bot does. Only a token that names a project (an app, agent or service principal
+ * with project_id), or a first-party service delegated into a project (X-OV-Subject + X-OV-Project, see
+ * createProjectMembership), can act on jobs — every route says so.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { verifyUserToken } = require('openvibe-sdk/auth');
+const { verifyUserToken, createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const MEMBER_READ = 'network.project.member.read';
+const DELEGABLE_ROLES = new Set(['admin', 'owner']);
+const MEMBER_TTL_MS = 60_000; // the amendment's ceiling: a removed member or an archived project stops new jobs within a minute
/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */
/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
@@ -60,4 +66,39 @@ function createUserAuth(config, keys) {
@@ -60,4 +66,39 @@ function createUserAuth(config, keys) {
return { verify };
return { verify };
}
}
-module.exports = { createKeyProvider, createUserAuth };
+/**
+ * Project delegation (ADR-003 amendment 2026-10-12): may a first-party service start a job for this person in
+ * this project? Network states the membership (GET /internal/projects/:project/members/:subject, capability
+ * network.project.member.read, minted through Run's own client); the decision is Run's: only an admin or owner of
+ * a project that is not archived may be acted for. A yes is cached for MEMBER_TTL_MS, a no never (the person may
+ * have just been added). → { ok: true } | { ok: false } (a 404, another role, an archived project), and throws
+ * when Network cannot be asked (no client secret, no token, an error answer, a timeout): the caller answers 503.
+ */
+function createProjectMembership(config, { fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {
+ const { clientId, clientSecret } = config.oauth;
+ const tokens = clientSecret
+ ? createServiceTokenClient({ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', scope: MEMBER_READ, fetch: fetchImpl, now })
+ : null;
+ const yes = new Map(); // `${project} ${subject}` → when the yes expires
+ async function allows(project, subject) {
+ const key = `${project} ${subject}`;
+ const until = yes.get(key);
+ if (until && until > now()) return { ok: true };
+ yes.delete(key);
+ if (!tokens) throw new Error('OV_OAUTH_CLIENT_SECRET is unset: Run cannot ask Network about a project membership');
+ const url = `${config.network.internalUrl}/internal/projects/${encodeURIComponent(project)}/members/${encodeURIComponent(subject)}`;
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });
+ if (res.status === 404) return { ok: false };
+ if (!res.ok) throw new Error(`Network answered ${res.status} to the membership read`);
+ const body = await res.json();
+ const ok = !!body && body.project_id === project && body.subject === subject && DELEGABLE_ROLES.has(body.role) && body.archived === false;
+ if (ok) {
+ if (yes.size > 10_000) yes.clear();
+ yes.set(key, now() + MEMBER_TTL_MS);
+ }
+ return { ok };
+ }
+ return { allows, enabled: Boolean(tokens) };
+}
+
+module.exports = { createKeyProvider, createUserAuth, createProjectMembership, MEMBER_TTL_MS, DELEGABLE_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+/**
+ * Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (X-OV-Subject)
+ * names a project with X-OV-Project, and Run asks Network (the stub's members route, through Run's own client
+ * credentials) whether the person is an admin or owner of it. The person is the requester, the service is via;
+ * the service submits and reads only its own jobs for that person; everyone else naming a project is refused.
+ *
+ * node test/delegation.test.js
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+const { ids } = contracts;
+
+async function checks(t) {
+ const P = ids.newId('project');
+ const Q = ids.newId('project');
+ const ada = ids.newId('user');
+ const bo = ids.newId('user');
+ const dev = ids.newId('user');
+ const viewer = ids.newId('user');
+ const old = ids.newId('user');
+ t.network.members.set(`${P} ${ada}`, { role: 'admin' });
+ t.network.members.set(`${P} ${bo}`, { role: 'owner' });
+ t.network.members.set(`${P} ${dev}`, { role: 'developer' });
+ t.network.members.set(`${P} ${viewer}`, { role: 'viewer' });
+ t.network.members.set(`${Q} ${old}`, { role: 'owner', archived: true });
+
+ // A first-party service token (no project of its own) acting for `subject` in `project`.
+ const as = (subject, project, { sub = 'svc:watch', cap = ['run.job.submit', 'run.job.read', 'run.job.list', 'run.job.cancel', 'run.job.stream'] } = {}) => ({
+ token: t.network.signService({ sub, cap }),
+ headers: { 'X-OV-Subject': subject, 'X-OV-Project': project },
+ });
+ const refused = (r, what) => {
+ assert.strictEqual(r.status, 403, `${what}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'project.not_delegated', what);
+ };
+
+ let jobId;
+ await check('an admin member: the job is the project\'s, the person is the requester, the service is via', async () => {
+ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P), key: 'watch.chk_1.0' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.ok(contracts.validate('run.job-create-result@1', r.json).valid, JSON.stringify(r.json));
+ jobId = r.json.job.id;
+ assert.strictEqual(r.json.job.project_id, P);
+ assert.deepStrictEqual(r.json.job.requester, { type: 'user', id: ada });
+ const [row] = await t.db.many('SELECT requester, via FROM run_jobs WHERE id = $1', [jobId]);
+ assert.deepStrictEqual({ ...row }, { requester: `user:${ada}`, via: 'service:watch' });
+ const queued = await t.outboxRows('run.job.queued', jobId);
+ assert.strictEqual(queued.length, 1);
+ assert.ok(contracts.validate('run.job.queued@1', queued[0].payload).valid);
+ // Run asked Network as itself, for exactly the capability the read needs.
+ assert.ok(t.network.tokenRequests.scopes.includes('network.project.member.read'));
+ assert.ok(t.network.tokenRequests.audiences.includes('openvibe.network'));
+ assert.match(String(t.network.memberState.lastAuth), /^Bearer /);
+ // An owner is accepted too.
+ const owner = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) });
+ assert.strictEqual(owner.status, 201, owner.text);
+ });
+
+ await check('the delegated caller reads its own job; another person, another service: the same 404', async () => {
+ const mine = await t.call('GET', `/api/v1/jobs/${jobId}`, as(ada, P));
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.strictEqual(mine.json.id, jobId);
+ const otherPerson = await t.call('GET', `/api/v1/jobs/${jobId}`, as(bo, P));
+ const otherService = await t.call('GET', `/api/v1/jobs/${jobId}`, as(ada, P, { sub: 'svc:actor' }));
+ const missing = await t.call('GET', `/api/v1/jobs/${ids.newId('job')}`, as(ada, P));
+ for (const r of [otherPerson, otherService]) { assert.strictEqual(r.status, 404, r.text); assert.strictEqual(r.json.code, 'run.job_not_found'); }
+ assert.strictEqual(otherPerson.text.replace(/"request_id":"[^"]*"|"trace_id":"[^"]*"/g, ''), missing.text.replace(/"request_id":"[^"]*"|"trace_id":"[^"]*"/g, ''), 'never says the job exists');
+ // The project's own token still sees every job of the project.
+ const project = await t.call('GET', `/api/v1/jobs/${jobId}`, { token: t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.read'], project_id: P }) });
+ assert.strictEqual(project.status, 200, project.text);
+ });
+
+ await check('list, cancel and stream stay with project tokens', async () => {
+ refused(await t.call('GET', '/api/v1/jobs', as(ada, P)), 'list');
+ refused(await t.call('POST', `/api/v1/jobs/${jobId}/cancel`, as(ada, P)), 'cancel');
+ refused(await t.call('POST', `/api/v1/jobs/${jobId}/stream/ticket`, as(ada, P)), 'stream ticket');
+ const d = as(ada, P);
+ const stream = await t.sse(`/api/v1/jobs/${jobId}/stream`, { headers: { Authorization: `Bearer ${d.token}`, ...d.headers } });
+ assert.strictEqual(stream.status, 403, stream.text);
+ assert.strictEqual(stream.json.code, 'project.not_delegated');
+ const [row] = await t.db.many('SELECT state FROM run_jobs WHERE id = $1', [jobId]);
+ assert.strictEqual(row.state, 'queued', 'the refused cancel changed nothing');
+ });
+
+ await check('a developer, a viewer, a non-member and an archived project are refused alike', async () => {
+ const stranger = ids.newId('user');
+ for (const [subject, project, what] of [[dev, P, 'developer'], [viewer, P, 'viewer'], [stranger, P, 'non-member'], [old, Q, 'archived project'], [ada, ids.newId('project'), 'unknown project']]) {
+ refused(await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(subject, project) }), what);
+ }
+ const made = await t.db.many('SELECT id FROM run_jobs WHERE project_id = $1 AND requester = $2', [P, `user:${dev}`]);
+ assert.strictEqual(made.length, 0, 'no job was made for a refused delegation');
+ });
+
+ await check('only a first-party service naming a person may name a project', async () => {
+ const body = t.request();
+ // An app (a mod or an agent is refused by the same svc: test): its project is its token's own.
+ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.submit'], project_id: P }), headers: { 'X-OV-Subject': ada, 'X-OV-Project': P } }), 'an app token');
+ // A service with no person to act for.
+ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: 'svc:watch', cap: ['run.job.submit'] }), headers: { 'X-OV-Project': P } }), 'no X-OV-Subject');
+ // A token that already names a project cannot name another.
+ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: 'svc:watch', cap: ['run.job.submit'], project_id: Q }), headers: { 'X-OV-Subject': ada, 'X-OV-Project': P } }), 'a token with project_id');
+ // Malformed headers.
+ refused(await t.call('POST', '/api/v1/jobs', { body, ...as(ada, 'prj_nope') }), 'a malformed project');
+ refused(await t.call('POST', '/api/v1/jobs', { body, ...as('usr_nope', P) }), 'a malformed subject');
+ // A person's own token.
+ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signUser({ subject: ada }), headers: { 'X-OV-Project': P } }), 'a user token');
+ // The service still needs its own grant: delegation adds no capability.
+ const noGrant = await t.call('POST', '/api/v1/jobs', { body, ...as(ada, P, { cap: ['run.job.read'] }) });
+ assert.strictEqual(noGrant.status, 403, noGrant.text);
+ assert.strictEqual(noGrant.json.code, 'capability.denied');
+ });
+
+ await check('a yes is cached at most 60 s; a removed member is refused after it', async () => {
+ const before = t.network.memberState.reads;
+ assert.strictEqual((await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) })).status, 201);
+ assert.strictEqual(t.network.memberState.reads, before, 'served from the cache (bo was confirmed by an earlier check)');
+ t.network.members.delete(`${P} ${bo}`);
+ assert.strictEqual((await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) })).status, 201, 'within the minute');
+ t.clock.offset += 61_000;
+ try {
+ refused(await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) }), 'after the minute');
+ assert.ok(t.network.memberState.reads > before, 'asked Network again');
+ } finally { t.clock.offset -= 61_000; }
+ });
+
+ await check('Network unavailable: 503 project.check_unavailable, never a guess', async () => {
+ t.clock.offset += 61_000; // past every cached yes
+ t.network.memberState.fail = true;
+ try {
+ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P) });
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual(r.json.code, 'project.check_unavailable');
+ } finally { t.network.memberState.fail = false; t.clock.offset -= 61_000; }
+ });
+
+ await check('a delegated repeat answers its own first job; a key someone else spent is 409 without naming it', async () => {
+ const again = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P), key: 'watch.chk_1.0' });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.json.created, false);
+ assert.strictEqual(again.json.job.id, jobId);
+ const theirs = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P, { sub: 'svc:actor' }), key: 'watch.chk_1.0' });
+ assert.strictEqual(theirs.status, 409, theirs.text);
+ assert.strictEqual(theirs.json.code, 'run.idempotency.conflict');
+ assert.ok(!theirs.text.includes(jobId), 'the conflict does not name the job');
+ // The project's own token reusing a delegated key is refused the same way (the key is spent).
+ const project = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], project: P, sub: 'svc:builder', key: 'watch.chk_1.0' });
+ assert.strictEqual(project.status, 409, project.text);
+ });
+}
+
+async function main() {
+ // Run's own client secret is set: the membership read goes through the real client-credentials path.
+ const t = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'run-test-secret' } });
+ try {
+ await checks(t);
+ } finally {
+ await t.close();
+ }
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
@@ -40,7 +40,23 @@ async function startNetwork() {
@@ -40,7 +40,23 @@ async function startNetwork() {
const issuer = 'http://network.test';
const issuer = 'http://network.test';
const tokenRequests = { n: 0, audiences: [], scopes: [] };
const tokenRequests = { n: 0, audiences: [], scopes: [] };
const offers = []; // what the registry publishes; a test replaces its contents (t.setOffers)
const offers = []; // what the registry publishes; a test replaces its contents (t.setOffers)
+ // Project memberships Network states (GET /internal/projects/:project/members/:subject, project delegation):
+ // `${project} ${subject}` → { role, archived }; memberReads counts the reads, memberFail makes them answer 500.
+ const members = new Map();
+ const memberState = { reads: 0, fail: false, lastAuth: null };
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
+ const member = /^\/internal\/projects\/([^/]+)\/members\/([^/?]+)$/.exec(req.url);
+ if (req.method === 'GET' && member) {
+ memberState.reads++;
+ memberState.lastAuth = req.headers.authorization || null;
+ if (memberState.fail) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end('{}'); }
+ const project = decodeURIComponent(member[1]);
+ const subject = decodeURIComponent(member[2]);
+ const m = members.get(`${project} ${subject}`);
+ if (!m) { res.writeHead(404, { 'Content-Type': 'application/problem+json' }); return res.end(JSON.stringify({ code: 'project.member_not_found' })); }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ project_id: project, subject, role: m.role, archived: !!m.archived, added_at: null }));
+ }
if (req.url === '/api/.well-known/jwks') {
if (req.url === '/api/.well-known/jwks') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));
return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));
@@ -84,7 +100,7 @@ async function startNetwork() {
@@ -84,7 +100,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
return {
return {
- url, issuer, publicPem, privatePem, tokenRequests, offers,
+ url, issuer, publicPem, privatePem, tokenRequests, offers, members, memberState,
/** Publish exactly these platform.resource-offer@1 documents (the array keeps its identity). */
/** Publish exactly these platform.resource-offer@1 documents (the array keeps its identity). */
setOffers(list) { offers.splice(0, offers.length, ...list); },
setOffers(list) { offers.splice(0, offers.length, ...list); },
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),