openvibers/openvibe.network Commits
Add delegated agent grants with modes and the delegation ceiling (#34)
Changes
nnew file mode 100644@@@ -0,0 +1,158 @@++# Cutover runbook: PR #34 — delegated agent grants (migration 0017)++++PR [#34](https://github.com/OpenVibers/OpenVibe.Network/pull/34) adds plan T2 WS-Z2 slice 3: delegated agent++grants. The data change is one new table, `dev_agent_grants`, and two indexes on it++(`migrations/0017_agent_grants.sql`). No existing table is altered, no column is renamed or dropped, no row is++touched. The migration is `phase: expand` and every statement is `IF NOT EXISTS`, so it is safe to run on a++live production database and safe to run twice.++++This runbook covers the deploy of that migration. It is much smaller than the SQLite → PostgreSQL cutover++([cutover-t2-postgres.md](cutover-t2-postgres.md)): there is no data move, no freeze window, no snapshot++import. The migration runs as part of the normal boot of the new release.++++---++++## What changes++++| Object | Kind | Notes |++| --- | --- | --- |++| `dev_agent_grants` | table | one row per (agent, capability); FK → `dev_agents(id)`; CHECK on `mode`, `status`, `audience` format, and `revoked` ↔ `revoked_at` |++| `dev_agent_grants_active_idx` | partial index | `(agent_id) WHERE status = 'active'` |++| `dev_agent_grants_cap_idx` | index | `(capability, status)` |++++No existing table, index, view or function is modified. The application code that reads and writes the table++(`server/developer/agents.js`, `server/developer/routes.js`, `server/developer/store.js`,++`server/identity/grants-admin.js`) only activates when an owner creates a grant through the developer API;++before that, the table is empty and the indexes cost nothing.++++---++++## Prerequisites++++1. **Production is on PostgreSQL.** The migration is SQL that targets PostgreSQL (and PGlite in tests). The++ current production release (`bad7b106fda0` at the time of writing) already serves from PostgreSQL; confirm++ with `ov access run openvibe-ovh health network` (must say `ready`) and `validate network` (must say++ `checkout clean`).++2. **`DATABASE_DIRECT_URL` is set and connects.** The migration runner uses the direct (owner-role) connection++ for schema changes. Check by name only:++ ```bash++ sudo grep -cE '^DATABASE_DIRECT_URL=.+' /etc/openvibe/network.env++ ```++ Must print `1`. Never print the value.++3. **A green rehearsal on a copy of the production database.** See [Step 1](#step-1-rehearse-on-a-copy). Only++ after it passes does the owner write the rehearsal file the deploy gates on.++4. **The target commit is fixed.** It is the merge commit of PR #34 into `origin/main`. Steps 1 and 2 must use++ that one SHA.++++---++++## Step 1: rehearse on a copy++++Run the migration against a throwaway copy of the production schema. The goal is to prove that 0017 applies++cleanly on top of 0015 (the last migration production has applied) and that the resulting schema matches what++the tests assert.++++```bash++TARGET=<merge sha of PR #34>++STAGE=/var/tmp/network-pr34-stage++sudo -u ubuntu git clone --no-checkout /opt/openvibe.network "$STAGE"++sudo -u ubuntu git -C "$STAGE" checkout --detach "$TARGET"++cd "$STAGE" && sudo -u ubuntu npm ci++```++++Then run the agent-schema test, which builds a fresh database at 0015, applies 0016 and 0017, and asserts the++indexes, constraints and FK:++++```bash++cd "$STAGE" && sudo -u ubuntu NETWORK_TEST_STORE=pglite node test/agent-schema.test.js++```++++**Go:** exit 0 and `agent schema: all tests passed` on stdout.++**No-go:** read the failure. A migration syntax error or a constraint mismatch means the PR is not ready; do++not deploy.++++For an extra check against the real production schema (optional, recommended), point `NETWORK_TEST_STORE=env`++at a scratch PostgreSQL database that has been restored from a production dump, and run the same test. The++scratch database must never be the production URLs.++++The owner writes `~/openvibe/agents/ds/deploy/rehearsals/pr-34-agent-grants.json` with `{"ok": true}` only++after the rehearsal passes. ds-finish refuses to deploy without that file and without the merged PR's++`cutover` block.++++---++++## Step 2: deploy++++The migration runs as part of the normal boot of the new release. `server/db/database.js` runs every pending++migration in `migrations/` at startup, using `DATABASE_DIRECT_URL` for the schema changes. No manual SQL, no++script, no freeze.++++```bash++ov deploy OpenVibe.Network "PR #34: delegated agent grants (migration 0017)"++```++++The pipeline's own gates (verify, review, merge-by-policy) run before this. After merge, the deploy:++++1. Checks out the merge commit on `/opt/openvibe.network`.++2. Restarts `openvibe-network.service`.++3. The new process boots, runs `0017_agent_grants.sql` (the only pending migration), and starts serving.++++**Go:** `ov access run openvibe-ovh health network` says `ready` on the merge SHA; `validate network` says++`checkout clean` at that SHA; `journal openvibe-network.service` shows the boot banner with no migration error.++**No-go:** the service fails to start. Check the journal. A migration error at this point means the rehearsal++did not match production; roll back (below).++++---++++## Verification after deploy++++```bash++# The migration is recorded.++ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0017\|migration\|agent_grants'++++# The table exists and is empty (no grants have been created yet).++# This requires a DATABASE_DIRECT_URL connection; run on the host:++sudo node --env-file=/etc/openvibe/network.env -e '++ const { Client } = require("pg");++ (async () => {++ const c = new Client({ connectionString: process.env.DATABASE_DIRECT_URL });++ await c.connect();++ const t = await c.query("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1)", ["dev_agent_grants"]);++ const r = await c.query("SELECT count(*) FROM dev_agent_grants");++ const m = await c.query("SELECT id FROM ov_migrations WHERE id = $1", ["0017"]);++ console.log("table:", t.rows[0].exists, "rows:", r.rows[0].count, "migration:", m.rows.length === 1 ? "applied" : "MISSING");++ await c.end();++ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'++```++++Expected: `table: true rows: 0 migration: applied`.++++---++++## Rollback++++The migration is additive and the table is unused until an owner creates a grant. Two rollback paths:++++- **Code rollback, table stays.** Revert the deploy to the previous release. The new release's code no longer++ references `dev_agent_grants`, so the empty table is inert. No data is lost because no row was ever written.++ The migration remains recorded in `ov_migrations`; a future re-deploy of PR #34 will see it as already++ applied (`IF NOT EXISTS` throughout) and move on.++- **Full rollback, table dropped.** If the table must be removed (for example, to re-run the migration from++ scratch after a schema fix), connect as the owner role:++ ```sql++ DROP TABLE IF EXISTS dev_agent_grants;++ DELETE FROM ov_migrations WHERE id = '0017';++ ```++ Do this only when no grant row exists (`SELECT count(*) FROM dev_agent_grants` = 0). If any row exists, the++ grants are in use and the table cannot be dropped without losing them.++++In either case, restart the service after the rollback and confirm `health network` says `ready`.++++---++++## What is not covered here++++- **Data move.** There is none. The table starts empty.++- **Freeze window.** There is none. The migration is a fast `CREATE TABLE` + two `CREATE INDEX` on an empty++ table; it takes milliseconds.++- **SQLite path.** Production is PostgreSQL-only; the SQLite runner is not exercised here.@@@ -2,7 +2,8 @@Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`; PR #25,merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_id` under `network.project.read`, Host--only: `server/internal/routes.js:490`; PR #28, merge `e422342`); slices 3–9 are still unbuilt (design, §8).++only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:++`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`); slices 4–9 are still unbuilt (design, §8).Plan T2, "Projects and grants: projects as the ownership boundary for every resource;`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against@@@ -63,8 +64,8 @@ allowances changes.## 3. Data model--Agents are `0016` (built); delegated grants, confirmations and budgets take the next free numbers when their slices--land (`0017`–`0019` if nothing else lands first). The design first reserved `0010`–`0013`, but the `openvibe-sdk/db`++Agents are `0016` and delegated grants `0017` (built); confirmations and budgets take the next free numbers when their++slices land (`0018`–`0019` if nothing else lands first). The design first reserved `0010`–`0013`, but the `openvibe-sdk/db`runner tracks applied migrations by id and **refuses** a pending file numbered below one already applied (`migrate:0010_agents.sql is older than applied migration 0014`), and main already ships `0014` and `0015`; `test/agent-schema.test.js` pinsthis. `0010`–`0013` stay unused. Header convention of the existing migrations: `-- phase: expand`, `-- plan T2 WS-Z2.@@@ -121,7 +122,7 @@ CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) Wservice-hosted agent is `production`: the code that runs it is first-party, not the project's.- `revoked` is final, like an app. `paused` is the owner's kill switch and is reversible.--### Next — `00NN_agent_grants.sql` (slice 3)++### 0017 — `0017_agent_grants.sql` (built, slice 3)```sqlCREATE TABLE IF NOT EXISTS dev_agent_grants (@@@ -148,6 +149,13 @@ CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capacapability Contracts marks sensitive later is treated as sensitive on the next token with no data migration. The stored`mode` is what the owner chose; `effective_mode` (section 5) is computed.++As built: `PUT` takes the project row's lock (as the cascades do) and, for a service host, holds the host's++`principal_grants` row `FOR SHARE`, so a ceiling change and a `PUT` never miss each other. A ceiling cascade sets++`revoked_by` to the subject label of whoever shrank the ceiling (`system:network` for the expiry sweep) and++`revoke_reason = 'beyond_host'`; a person's revoke leaves `revoke_reason` empty. Each change is one `dev_audit` row,++action `grant.changed`, target `agent:agt_…`, detail `{ capability, audience, from, to, mode, reason? }`, without an++event (section 2, gap 3). A revoked agent's grants stay as they are and read back `within_host: false`.++### Next — `00NN_confirmations.sql` (slice 4)```sql@@@ -541,10 +549,11 @@ and agree).`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of`docs/developer-projects.md`, `test/agents.test.js`,`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.--3. **Delegated grants and modes.** `migrations/00NN_agent_grants.sql`, grant routes, the ceiling (reusing++3. **Delegated grants and modes** (built). `migrations/0017_agent_grants.sql`, grant routes, the ceiling (reusing`tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and`grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,-- cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.++ cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`. `GET /agents/:agent` still returns++ the `agent` alone; its grants are `GET /agents/:agent/grants`.4. **Confirmations, owner side.** `migrations/00NN_confirmations.sql`, `server/developer/confirmations.js` (store,lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade ofnnew file mode 100644@@@ -0,0 +1,25 @@++-- phase: expand++-- plan T2 WS-Z2. Delegated grants (docs/t2-projects-and-grants.md section 3, slice 3): what an agent may do for its++-- owner, one row per capability, never more than its host may do for the project (the delegation ceiling, checked by++-- server/developer/agents.js). `mode` is what the owner chose; whether a capability is sensitive is read from the++-- installed catalog on every read, so effective_mode is never stored. revoked is final for that row until the owner++-- sets the grant again.++++CREATE TABLE IF NOT EXISTS dev_agent_grants (++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),++ capability text COLLATE "C" NOT NULL,++ audience text COLLATE "C" NOT NULL CHECK (audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'),++ mode text COLLATE "C" NOT NULL CHECK (mode IN ('auto', 'confirm')),++ status text COLLATE "C" NOT NULL CHECK (status IN ('active', 'revoked')),++ granted_at text COLLATE "C" NOT NULL,++ granted_by text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C",++ revoked_at text COLLATE "C",++ revoked_by text COLLATE "C",++ revoke_reason text COLLATE "C",++ PRIMARY KEY (agent_id, capability),++ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))++);++CREATE INDEX IF NOT EXISTS dev_agent_grants_active_idx ON dev_agent_grants (agent_id) WHERE status = 'active';++CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capability, status);@@@ -11,9 +11,15 @@* - Losing the host or the project takes the agents with it, in the transaction that causes it: revoking the app,* archiving the project, the owner leaving it, and the owner's account erasure (revokeWhere).* - Every change is a dev_audit row without an event: Contracts has no payload for it yet.-- * Delegated grants, budgets, standing rules, confirmations and agent tokens come in later slices (section 8).++ * - Delegated grants (slice 3, migrations/0017_agent_grants.sql): the owner alone sets one, for a capability the++ * host may use for this project (the delegation ceiling, `ceiling` below); the owner, an admin+ or staff revoke it.++ * A capability the installed catalog marks sensitive is always `confirm`, whatever mode is stored. A change that++ * shrinks the ceiling (an allowance, an app grant, a service's principal_grants row) revokes the delegated grants++ * outside it in its own transaction (revokeBeyondHost).++ * Budgets, standing rules, confirmations and agent tokens come in later slices (section 8).*/--const { ids } = require('openvibe-contracts');++const { ids, capabilities } = require('openvibe-contracts');++const policy = require('./policy');const store = require('./store');const AGENT_ID_RE = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -161,4 +167,172 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));}--module.exports = { ensureSchema, AGENT_ID_RE, agentView, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere };++// ── Delegated grants ───────────────────────────────────────────++++const GRANT_MODES = ['auto', 'confirm'];++++/** Sensitive per the installed catalog, read on every call (never stored), so a capability marked later counts at once. */++const isSensitive = (capability) => { const c = capabilities.get(String(capability)); return !!(c && c.sensitive); };++/** What a grant means now: a sensitive capability is always confirmed, whatever mode the owner stored. */++const effectiveMode = (grant) => (isSensitive(grant.capability) ? 'confirm' : grant.mode);++++/**++ * The delegation ceiling: the capabilities the agent's host may use for this project at `audience`, now.++ * App host: the app's effectiveGrants (approved ∩ allowance, ∪ the sandbox allowance for a sandbox app, ∩ grantable).++ * Service host: its unexpired principal_grants, never an `internal` capability. Either way only `active` ones, and++ * nothing for a revoked agent, a revoked host app or an archived project.++ */++async function ceiling(db, agent, audience, settings) {++ if (agent.status === 'revoked') return new Set();++ let held = [];++ if (agent.host_kind === 'app') {++ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND revoked_at IS NULL').get(agent.host_app_id);++ const project = app && await db.prepare('SELECT * FROM dev_projects WHERE id = ? AND archived_at IS NULL').get(app.project_id);++ // Required here: tokens.js requires store.js, which requires this module lazily.++ if (project) held = await require('./tokens').effectiveGrants(db, app, project, audience, settings || policy.settings());++ } else {++ held = (await require('../identity/principals').grantsFor(db, agent.host_service, audience)).map((g) => g.capability)++ .filter((c) => { const m = capabilities.get(c); return m && m.visibility !== 'internal'; });++ }++ return new Set(held.filter((c) => { const m = capabilities.get(c); return m && m.status === 'active'; }));++}++++async function withinHost(db, agent, capability, settings) {++ const audience = policy.audienceOf(capability);++ return !!audience && (await ceiling(db, agent, audience, settings)).has(capability);++}++++function grantView(g, within) {++ return {++ capability: g.capability, audience: g.audience, mode: g.mode, effective_mode: effectiveMode(g), sensitive: isSensitive(g.capability),++ status: g.status, within_host: within, expires_at: g.expires_at || null, granted_at: g.granted_at, granted_by: g.granted_by,++ };++}++++/** Views of an agent's grant rows, each ceiling computed once per audience. */++async function grantViews(db, agent, rows, settings) {++ const ceilings = new Map();++ const out = [];++ for (const g of rows) {++ if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, agent, g.audience, settings));++ out.push(grantView(g, ceilings.get(g.audience).has(g.capability)));++ }++ return out;++}++++/** The audit row of a delegated-grant change: no event, Contracts has no payload for it yet (section 2). */++async function grantAudit(db, agent, g, { actor, from, to, mode, reason, ctx }) {++ await store.audit(db, { projectId: agent.project_id, actor, action: 'grant.changed', target: `agent:${agent.id}`,++ detail: { capability: g.capability, audience: g.audience, from, to, mode, reason: reason || undefined }, ctx });++}++++async function revokeGrantRow(db, agent, g, { actor, reason, ctx }) {++ const t = nowIso();++ const r = await db.prepare("UPDATE dev_agent_grants SET status = 'revoked', revoked_at = ?, revoked_by = ?, revoke_reason = ?, updated_at = ? WHERE agent_id = ? AND capability = ? AND status = 'active'")++ .run(t, actor, reason || null, t, agent.id, g.capability);++ if (r.changes) await grantAudit(db, agent, g, { actor, from: 'active', to: 'revoked', mode: g.mode, reason, ctx });++ return r.changes > 0;++}++++function expiryOf(v) {++ if (v == null || v === '') return null;++ const d = new Date(String(v));++ if (Number.isNaN(d.getTime())) fail(422, 'grant.invalid', 'expires_at is not a date');++ if (d.getTime() <= Date.now()) fail(422, 'grant.invalid', 'expires_at is in the past');++ return d.toISOString();++}++++async function listGrants(db, actor, projectId, agentId, { settings } = {}) {++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });++ const a = await loadAgent(db, project.id, agentId);++ return await grantViews(db, a, await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? ORDER BY capability').all(a.id), settings);++}++++async function readGrant(db, agent, capability, settings) {++ return (await grantViews(db, agent, [await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ?').get(agent.id, capability)], settings))[0];++}++++/**++ * Set a delegated grant: { mode: 'auto'|'confirm' (default confirm), expires_at? }. The agent's owner only: a person++ * delegates their own authority. Refused: an unknown capability (404 grant.unknown_capability), one that is not++ * active (404 grant.not_grantable), `auto` on a sensitive one (422 grant.sensitive_requires_confirm), one outside the++ * host's ceiling (403 grant.beyond_host). Setting a revoked grant again makes it active with a new granted_at.++ */++async function putGrant(db, actor, projectId, agentId, capability, body, { ctx, settings }) {++ const { project, role } = await store.access(db, actor, projectId, { staffOk: false });++ const a = await loadAgent(db, project.id, agentId);++ if (!(role && a.owner_subject === actor.subject)) fail(403, 'agent.forbidden', "only the agent's owner delegates to it");++ const cap = capabilities.get(String(capability));++ if (!cap) fail(404, 'grant.unknown_capability', `no capability ${String(capability).slice(0, 80)} in the catalog`);++ if (cap.status !== 'active') fail(404, 'grant.not_grantable', `${cap.id} is ${cap.status}`);++ const mode = body.mode === undefined || body.mode === null ? 'confirm' : body.mode;++ if (!GRANT_MODES.includes(mode)) fail(422, 'grant.invalid', "mode is 'auto' or 'confirm'");++ const expiresAt = expiryOf(body.expires_at);++ if (mode === 'auto' && isSensitive(cap.id)) fail(422, 'grant.sensitive_requires_confirm', `${cap.id} is sensitive: every use is confirmed`);++ const audience = policy.audienceOf(cap.id);++ await db.tx(async () => {++ // Under the project's lock, which an allowance change (its UPDATE), an app grant revoke, an app revocation, an++ // archive and a member's removal hold while they cascade: the ceiling read here is the one they leave behind.++ await store.lockProject(db, project.id);++ await store.access(db, actor, projectId, { staffOk: false });++ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(a.id);++ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');++ // A service's grant row is held until commit, so a concurrent /api/admin/grants/revoke cascades after this.++ if (cur.host_kind === 'service') await db.prepare('SELECT 1 AS held FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ? FOR SHARE').get(cur.host_service, cap.id, audience);++ if (!await withinHost(db, cur, cap.id, settings)) fail(403, 'grant.beyond_host', `${cap.id} is not something this agent's host may do for the project`);++ const prev = await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? FOR UPDATE').get(a.id, cap.id);++ const active = prev && prev.status === 'active';++ if (active && prev.mode === mode && (prev.expires_at || null) === expiresAt) return;++ const t = nowIso();++ if (active) {++ await db.prepare('UPDATE dev_agent_grants SET mode = ?, expires_at = ?, updated_at = ? WHERE agent_id = ? AND capability = ?').run(mode, expiresAt, t, a.id, cap.id);++ } else {++ await db.prepare(`INSERT INTO dev_agent_grants (agent_id, capability, audience, mode, status, granted_at, granted_by, updated_at, expires_at)++ VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)++ ON CONFLICT (agent_id, capability) DO UPDATE SET audience = excluded.audience, mode = excluded.mode, status = 'active',++ granted_at = excluded.granted_at, granted_by = excluded.granted_by, updated_at = excluded.updated_at, expires_at = excluded.expires_at,++ revoked_at = NULL, revoked_by = NULL, revoke_reason = NULL`)++ .run(a.id, cap.id, audience, mode, t, actor.label, t, expiresAt);++ }++ await grantAudit(db, cur, { capability: cap.id, audience }, { actor: actor.label, from: prev ? prev.status : 'none', to: 'active', mode, ctx });++ });++ return await readGrant(db, await loadAgent(db, project.id, a.id), cap.id, settings);++}++++/** Revoke a delegated grant: the agent's owner, an admin+ or staff. */++async function deleteGrant(db, actor, projectId, agentId, capability, { ctx, settings }) {++ const { project, role } = await store.access(db, actor, projectId, { allowArchived: true });++ const a = await loadAgent(db, project.id, agentId);++ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin') && !actor.staff) {++ fail(403, 'agent.forbidden', "only the agent's owner, an admin or staff revoke its grants");++ }++ await db.tx(async () => {++ const g = await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? FOR UPDATE').get(a.id, String(capability));++ if (!g) fail(404, 'grant.not_found', 'no such grant');++ if (g.status !== 'active') fail(409, 'grant.not_active', `grant is ${g.status}`);++ await revokeGrantRow(db, a, g, { actor: actor.label, reason: null, ctx });++ });++ return await readGrant(db, a, String(capability), settings);++}++++/**++ * The ceiling cascade, in the caller's transaction: revoke every active delegated grant of the (not revoked) agents++ * matching `where` that their host's ceiling no longer covers, one audit row each (reason beyond_host). `actor` is the++ * subject label of whoever shrank it. Returns how many were revoked.++ */++async function revokeBeyondHost(db, where, params, { actor, ctx, settings }) {++ let n = 0;++ for (const a of await db.prepare(`SELECT * FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params)) {++ const ceilings = new Map();++ for (const g of await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND status = 'active' ORDER BY capability").all(a.id)) {++ if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, a, g.audience, settings));++ if (!ceilings.get(g.audience).has(g.capability) && await revokeGrantRow(db, a, g, { actor, reason: 'beyond_host', ctx })) n++;++ }++ }++ return n;++}++++module.exports = {++ ensureSchema, AGENT_ID_RE, agentView, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,++ effectiveMode, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,++};@@@ -42,6 +42,9 @@ const cache = require('openvibe-shared/cache-policy');* POST /:project/agents/:agent/pause its owner, admin+ or staff* POST /:project/agents/:agent/resume its owner or admin+ (not staff)* DELETE /:project/agents/:agent revoke, final: its owner, admin+ or staff++ * GET /:project/agents/:agent/grants viewer+: delegated grants with effective_mode and within_host++ * PUT /:project/agents/:agent/grants/:capability { mode, expires_at? } its owner only, inside the host's ceiling++ * DELETE /:project/agents/:agent/grants/:capability revoke: its owner, admin+ or staff* GET /:project/quotas viewer+* PUT /:project/quotas/:capability { limit, window, unit } staff* DELETE /:project/quotas/:capability staff@@@ -136,6 +139,9 @@ function router() {r.post('/:project/agents/:agent/pause', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'pause', o)));r.post('/:project/agents/:agent/resume', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'resume', o)));r.delete('/:project/agents/:agent', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'revoke', o)));++ r.get('/:project/agents/:agent/grants', handle(async (db, a, req, o) => ({ grants: await agents.listGrants(db, a, req.params.project, req.params.agent, o) })));++ r.put('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.putGrant(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));++ r.delete('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.deleteGrant(db, a, req.params.project, req.params.agent, req.params.capability, o)));r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));@@@ -258,6 +258,8 @@ async function setAllowance(db, actor, projectId, { capabilities: list }, { ctx,await grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });}++ // Then the delegated grants of this project's app-hosted agents that the smaller ceiling no longer covers (WS-Z2).++ await require('./agents').revokeBeyondHost(db, "project_id = ? AND host_kind = 'app'", [project.id], { actor: actor.label, ctx, settings });});return { allowance: wanted, trimmed };}@@@ -651,8 +653,12 @@ async function decideGrant(db, actor, projectId, appId, capability, decision, {if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);}await db.tx(async () => {++ // A revoke takes the project's lock first, as a delegated grant's PUT does, so neither misses the other.++ if (decision === 'revoked') await lockProject(db, project.id);await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);await grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);++ // The app's agents lose the delegated grants it no longer covers (WS-Z2).++ if (decision === 'revoked') await require('./agents').revokeBeyondHost(db, 'host_app_id = ?', [app.id], { actor: actor.label, ctx, settings });});return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));}@@@ -14,6 +14,9 @@ const cache = require('openvibe-shared/cache-policy');* POST /api/admin/grants { client_id, capability, audience?, namespaces?, expires_at?, reason }* POST /api/admin/grants/revoke { client_id, capability, audience?, reason }*++ * A revoke and a recorded expiry also revoke, in the same transaction, the delegated grants of agents this service++ * hosts that its remaining grants no longer cover (plan T2 WS-Z2, server/developer/agents.js revokeBeyondHost).++ ** The audience defaults to, and must be, the capability owner's (openvibe.<owner>). Expired grants stop* counting at once (grantsFor filters them); expireDue(), every 10 minutes, records each expiry once.*/@@@ -140,10 +143,19 @@ async function revoke(db, body, actor) {.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);const namespaces = JSON.parse(prev.namespaces || '[]');await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });++ await revokeDelegated(db, t.client_id, actor);return { ...t, change: 'revoked' };});}++/**++ * The agents this service hosts lose the delegated grants its remaining grants no longer cover (plan T2 WS-Z2), in the++ * caller's transaction. Required lazily: agents.js reaches principals.js, which requires this module.++ */++async function revokeDelegated(db, clientId, actor) {++ await require('../developer/agents').revokeBeyondHost(db, "host_kind = 'service' AND host_service = ?", [clientId], { actor: actor ? `user:${actor}` : 'system:network' });++}++/** Record every grant whose expiry has passed, once. → how many */async function expireDue(db) {const due = await db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ov_now()').all();@@@ -152,6 +164,7 @@ async function expireDue(db) {const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(g.client_id, g.capability, g.audience)).changes;if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });++ if (n) await revokeDelegated(db, g.client_id, null);});}if (due.length) kick(db);nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++// Delegated grants (plan T2 WS-Z2 slice 3, docs/t2-projects-and-grants.md sections 4-7; server/developer/agents.js):++// the delegation ceiling for production-app, sandbox-app and service hosts; modes (auto on a sensitive capability is++// refused, and effective_mode follows the installed catalog on every read); only the agent's owner sets a grant; and++// every cascade with its audit row: an allowance shrink, an app grant revoke, a service grant revoke and its expiry,++// then an app revocation, a member's removal and an archive taking the agents.++// node test/agent-grants.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { capabilities } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const grantsAdmin = require('../server/identity/grants-admin');++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++const PEOPLE = [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin']];++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1)").run();++const S = {};++for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.locals.db = db;++app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: 'media.object.read', agentHostServices: 'actor' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/api/v1/projects', require('../server/developer/routes').router());++const server = http.createServer(app);++const T = {};++for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const api = async (who, method, p, body) => {++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, type: r.headers.get('content-type') || '', body: text ? JSON.parse(text) : null };++ };++ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };++ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };++ const grantRow = async (agent, cap) => await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ?').get(agent, cap);++ const audits = async (agent) => (await db.prepare("SELECT actor, detail, event FROM dev_audit WHERE target = ? AND action = 'grant.changed' ORDER BY id").all(`agent:${agent}`))++ .map((r) => { assert.strictEqual(r.event, null, 'no event until Contracts has a payload'); const d = JSON.parse(r.detail); return `${d.capability} ${d.from}>${d.to} ${d.mode} ${d.reason || '-'} ${r.actor}`; });++ try {++ // ── A project: a production app with two approved grants, a sandbox app with one, and Actor as a service host ──++ const P = ok(await api('owner', 'POST', '', { name: 'Delegation' }), 201).id;++ for (const [name, role] of [['dev', 'developer'], ['viewer', 'viewer'], ['admin', 'admin']]) ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);++ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.delete', 'events.app.publish'] }));++ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;++ const SB = ok(await api('dev', 'POST', `/${P}/apps`, { name: 'Bot sandbox', environment: 'sandbox' }), 201).id;++ for (const c of ['media.object.upload', 'media.object.delete']) assert.strictEqual(ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201).status, 'approved');++ assert.strictEqual(ok(await api('dev', 'POST', `/${P}/apps/${SB}/grants`, { capability: 'media.object.read' }), 201).status, 'requested');++ assert.strictEqual(ok(await api('owner', 'POST', `/${P}/apps/${SB}/grants/media.object.read/approve`)).status, 'approved');++ for (const c of ['chat.message.send', 'chat.moderation.read']) await grantsAdmin.grant(db, { client_id: 'actor', capability: c, reason: 'agent host test' }, null);++ const ownBot = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Prod bot', host: { type: 'app', id: PR } }), 201).id;++ const devBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Sandbox bot', host: { type: 'app', id: SB } }), 201).id;++ const svcBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Actor helper', host: { type: 'service', id: 'actor' } }), 201).id;++ const put = (who, agent, cap, body = {}) => api(who, 'PUT', `/${P}/agents/${agent}/grants/${cap}`, body);++++ // ── Production-app host: only what the app holds now, inside the allowance; never first-party ──++ const up = ok(await put('owner', ownBot, 'media.object.upload', { mode: 'auto' }));++ assert.deepStrictEqual({ ...up, granted_at: undefined }, {++ capability: 'media.object.upload', audience: 'openvibe.media', mode: 'auto', effective_mode: 'auto', sensitive: false,++ status: 'active', within_host: true, expires_at: null, granted_at: undefined, granted_by: `user:${S.owner}`,++ });++ assert.match(up.granted_at, /^\d{4}-\d{2}-\d{2}T/);++ refused(await put('owner', ownBot, 'events.app.publish'), 403, 'grant.beyond_host'); // in the allowance, but the app lacks it++ refused(await put('owner', ownBot, 'media.object.read'), 403, 'grant.beyond_host'); // the sandbox allowance is not a production app's++ refused(await put('owner', ownBot, 'chat.message.send'), 403, 'grant.beyond_host'); // first-party++ refused(await put('owner', ownBot, 'nope.thing.do'), 404, 'grant.unknown_capability');++ refused(await put('owner', ownBot, 'pics.image.upload'), 404, 'grant.not_grantable'); // planned++ refused(await put('owner', ownBot, 'media.object.upload', { mode: 'always' }), 422, 'grant.invalid');++ refused(await put('owner', ownBot, 'media.object.upload', { expires_at: '2001-01-01T00:00:00Z' }), 422, 'grant.invalid');++++ // ── Modes: auto on a sensitive capability is refused; confirm is the default ──++ refused(await put('owner', ownBot, 'media.object.delete', { mode: 'auto' }), 422, 'grant.sensitive_requires_confirm');++ const del = ok(await put('owner', ownBot, 'media.object.delete'));++ assert.deepStrictEqual([del.mode, del.effective_mode, del.sensitive, del.within_host], ['confirm', 'confirm', true, true]);++ const later = new Date(Date.now() + 86400e3).toISOString();++ assert.strictEqual(ok(await put('owner', ownBot, 'media.object.upload', { mode: 'auto', expires_at: later })).expires_at, later);++++ // ── effective_mode follows the installed catalog: a grant set to auto before its capability became sensitive reads as confirm ──++ const manifest = capabilities.get('media.object.upload');++ const wasSensitive = manifest.sensitive;++ manifest.sensitive = true;++ try {++ const g = ok(await api('owner', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.find((x) => x.capability === 'media.object.upload');++ assert.deepStrictEqual([g.mode, g.effective_mode, g.sensitive], ['auto', 'confirm', true]);++ assert.strictEqual((await grantRow(ownBot, 'media.object.upload')).mode, 'auto', 'the stored row is not rewritten');++ } finally {++ if (wasSensitive === undefined) delete manifest.sensitive; else manifest.sensitive = wasSensitive;++ }++ assert.strictEqual(ok(await api('owner', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.find((x) => x.capability === 'media.object.upload').effective_mode, 'auto');++++ // ── Only the agent's owner sets a grant ──++ refused(await put('admin', ownBot, 'media.object.read'), 403, 'agent.forbidden');++ refused(await put('viewer', ownBot, 'media.object.read'), 403, 'agent.forbidden');++ refused(await put('dev', ownBot, 'media.object.upload'), 403, 'agent.forbidden');++ refused(await put('staff', ownBot, 'media.object.upload'), 403, 'project.forbidden');++ refused(await put('stranger', ownBot, 'media.object.upload'), 404, 'project.not_found');++ refused(await put(null, ownBot, 'media.object.upload'), 401, 'auth.required');++++ // ── Sandbox-app host: the sandbox allowance counts, and still only what the app holds ──++ assert.strictEqual(ok(await put('dev', devBot, 'media.object.read', { mode: 'auto' })).within_host, true);++ refused(await put('dev', devBot, 'media.object.upload'), 403, 'grant.beyond_host');++++ // ── Service host: its principal_grants, never an internal capability ──++ refused(await put('dev', svcBot, 'chat.message.send', { mode: 'auto' }), 422, 'grant.sensitive_requires_confirm');++ const chat = ok(await put('dev', svcBot, 'chat.message.send', { mode: 'confirm' }));++ assert.deepStrictEqual([chat.audience, chat.effective_mode, chat.within_host], ['openvibe.chat', 'confirm', true]);++ refused(await put('dev', svcBot, 'chat.moderation.read'), 403, 'grant.beyond_host'); // internal, though Actor holds it++ refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // Actor does not hold it++++ // ── Read: members and staff ──++ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.map((g) => g.capability), ['media.object.delete', 'media.object.upload']);++ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents/${svcBot}/grants`)).grants.length, 1);++ refused(await api('stranger', 'GET', `/${P}/agents/${ownBot}/grants`), 404, 'project.not_found');++++ // ── Revoke: the owner, an admin+ or staff; not twice ──++ refused(await api('viewer', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 403, 'agent.forbidden');++ refused(await api('dev', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 403, 'agent.forbidden');++ assert.strictEqual(ok(await api('staff', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`)).status, 'revoked');++ refused(await api('owner', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 409, 'grant.not_active');++ refused(await api('owner', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.list`), 404, 'grant.not_found');++ assert.strictEqual((await grantRow(ownBot, 'media.object.delete')).revoked_by, `user:${S.staff}`);++ assert.strictEqual(ok(await put('owner', ownBot, 'media.object.delete')).status, 'active', 'the owner may set it again');++ const again = await grantRow(ownBot, 'media.object.delete');++ assert.deepStrictEqual([again.revoked_at, again.revoked_by, again.revoke_reason], [null, null, null]);++++ // ── Cascade: an allowance shrink revokes the delegated grants outside it; the sandbox allowance keeps devBot's ──++ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'events.app.publish'] }));++ const cut = await grantRow(ownBot, 'media.object.delete');++ assert.deepStrictEqual([cut.status, cut.revoked_by, cut.revoke_reason], ['revoked', `user:${S.staff}`, 'beyond_host']);++ assert.strictEqual((await grantRow(ownBot, 'media.object.upload')).status, 'active');++ assert.strictEqual((await grantRow(devBot, 'media.object.read')).status, 'active');++++ // ── Cascade: revoking the app's grant revokes its agents' delegated grant ──++ ok(await api('owner', 'DELETE', `/${P}/apps/${PR}/grants/media.object.upload`));++ assert.deepStrictEqual([(await grantRow(ownBot, 'media.object.upload')).status, (await grantRow(ownBot, 'media.object.upload')).revoke_reason], ['revoked', 'beyond_host']);++ assert.deepStrictEqual(await audits(ownBot), [++ `media.object.upload none>active auto - user:${S.owner}`,++ `media.object.delete none>active confirm - user:${S.owner}`,++ `media.object.upload active>active auto - user:${S.owner}`,++ `media.object.delete active>revoked confirm - user:${S.staff}`,++ `media.object.delete revoked>active confirm - user:${S.owner}`,++ `media.object.delete active>revoked confirm beyond_host user:${S.staff}`,++ `media.object.upload active>revoked auto beyond_host user:${S.owner}`,++ ]);++++ // ── Cascade: /api/admin/grants/revoke on the service host ──++ await grantsAdmin.revoke(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'no longer needed' }, S.owner);++ assert.deepStrictEqual([(await grantRow(svcBot, 'chat.message.send')).status, (await grantRow(svcBot, 'chat.message.send')).revoked_by], ['revoked', `user:${S.owner}`]);++++ // ── Cascade: the service grant's expiry. It stops counting at once (within_host false); the sweep revokes ──++ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'blog.post.publish', reason: 'agent host test', expires_at: later }, null);++ assert.strictEqual(ok(await put('dev', svcBot, 'blog.post.publish')).within_host, true);++ await db.prepare("UPDATE principal_grants SET expires_at = '2001-01-01 00:00:00' WHERE client_id = 'actor' AND capability = 'blog.post.publish'").run();++ const lapsed = ok(await api('dev', 'GET', `/${P}/agents/${svcBot}/grants`)).grants.find((g) => g.capability === 'blog.post.publish');++ assert.deepStrictEqual([lapsed.status, lapsed.within_host], ['active', false]);++ assert.ok(await grantsAdmin.expireDue(db) >= 1);++ assert.strictEqual((await grantRow(svcBot, 'blog.post.publish')).status, 'revoked');++ assert.deepStrictEqual(await audits(svcBot), [++ `chat.message.send none>active confirm - user:${S.dev}`,++ `chat.message.send active>revoked confirm beyond_host user:${S.owner}`,++ `blog.post.publish none>active confirm - user:${S.dev}`,++ 'blog.post.publish active>revoked confirm beyond_host system:network',++ ]);++++ // ── Losing the host or the project takes the agents (slice 2's cascades), each with its audit row; a revoked++ // agent's grants are outside every ceiling, and it takes no new ones ──++ const reasons = async (agent) => (await db.prepare("SELECT detail FROM dev_audit WHERE target = ? AND action = 'agent.revoked'").all(`agent:${agent}`)).map((r) => JSON.parse(r.detail).reason);++ ok(await api('dev', 'DELETE', `/${P}/apps/${SB}`));++ assert.deepStrictEqual(await reasons(devBot), ['app_revoked']);++ assert.strictEqual(ok(await api('dev', 'GET', `/${P}/agents/${devBot}/grants`)).grants[0].within_host, false);++ refused(await put('dev', devBot, 'media.object.read'), 409, 'agent.revoked');++ ok(await api('admin', 'DELETE', `/${P}/members/${S.dev}`), 204);++ assert.deepStrictEqual(await reasons(svcBot), ['member_removed']);++ ok(await api('owner', 'POST', `/${P}/archive`));++ assert.deepStrictEqual(await reasons(ownBot), ['project_archived']);++ refused(await put('owner', ownBot, 'media.object.delete'), 409, 'project.archived');++ console.log('agent grants: all tests passed');++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });@@@ -1,7 +1,7 @@'use strict';--// The agents migration (plan T2 WS-Z2 slice 2, migrations/0016_agents.sql) reaches a database that production--// already migrated: a database at 0015 without it gains it from the normal runner, once, and the file is safe to--// run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied++// The agents migrations (plan T2 WS-Z2 slices 2-3, migrations/0016_agents.sql and 0017_agent_grants.sql) reach a++// database that production already migrated: a database at 0015 without them gains them from the normal runner,++// once, and each file is safe to run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied// migrations by id but refuses a pending file numbered below one already applied.// node test/agent-schema.test.jsconst assert = require('assert');@@@ -43,11 +43,11 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.-- await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, '0016_agents.sql'), 'utf8')));++ for (const f of ['0016_agents.sql', '0017_agent_grants.sql']) await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')));// The table works on the migrated database: ISO defaults like every other text timestamp, and the app host// must be an app of the agent's own project and environment.@@@ -64,6 +64,25 @@ const quiet = { log() {}, warn() {}, error() {} };assert.deepStrictEqual([r.updated_at.length, r.status], [24, 'active']);await assert.rejects(agent(`agt_${'6'.repeat(26)}`, other, 'sandbox'), /foreign key/i, 'an app of another project');await assert.rejects(agent(`agt_${'7'.repeat(26)}`, prj, 'production'), /foreign key/i, 'an app of the other environment');++++ // Delegated grants (0017): one row per (agent, capability), of an existing agent, with a known mode and status,++ // and a revoked row always says when.++ const now = new Date().toISOString();++ const grant = (g) => t.db.prepare(`INSERT INTO dev_agent_grants (agent_id, capability, audience, mode, status, granted_at, granted_by, updated_at, revoked_at)++ VALUES (?, ?, ?, ?, ?, ?, 'test', ?, ?)`).run(g.agent || `agt_${'5'.repeat(26)}`, g.capability || 'media.object.read', g.audience || 'openvibe.media',++ g.mode || 'confirm', g.status || 'active', now, now, g.revoked_at === undefined ? null : g.revoked_at);++ await grant({});++ await assert.rejects(grant({}), /duplicate key|unique/i, 'one row per agent and capability');++ await grant({ capability: 'media.object.upload', mode: 'auto', status: 'revoked', revoked_at: now });++ await assert.rejects(grant({ agent: `agt_${'8'.repeat(26)}`, capability: 'media.object.list' }), /foreign key/i, 'an unknown agent');++ await assert.rejects(grant({ capability: 'media.object.list', mode: 'always' }), /check constraint/i, 'an unknown mode');++ await assert.rejects(grant({ capability: 'media.object.list', status: 'paused' }), /check constraint/i, 'an unknown status');++ await assert.rejects(grant({ capability: 'media.object.list', status: 'revoked' }), /check constraint/i, 'revoked without revoked_at');++ await assert.rejects(grant({ capability: 'media.object.list', revoked_at: now }), /check constraint/i, 'active with revoked_at');++ await assert.rejects(grant({ capability: 'media.object.list', audience: 'media' }), /check constraint/i, 'an audience that is not openvibe.<service>');++ const idx = (await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_agent_grants' ORDER BY indexname").all());++ assert.deepStrictEqual(idx.map((i) => i.indexname), ['dev_agent_grants_active_idx', 'dev_agent_grants_cap_idx', 'dev_agent_grants_pkey']);++ assert.match(idx[0].indexdef, /WHERE \(?status = 'active'/);console.log('agent schema: all tests passed');} finally {await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+# Cutover runbook: PR #34 — delegated agent grants (migration 0017)
+
+PR [#34](https://github.com/OpenVibers/OpenVibe.Network/pull/34) adds plan T2 WS-Z2 slice 3: delegated agent
+grants. The data change is one new table, `dev_agent_grants`, and two indexes on it
+(`migrations/0017_agent_grants.sql`). No existing table is altered, no column is renamed or dropped, no row is
+touched. The migration is `phase: expand` and every statement is `IF NOT EXISTS`, so it is safe to run on a
+live production database and safe to run twice.
+
+This runbook covers the deploy of that migration. It is much smaller than the SQLite → PostgreSQL cutover
+([cutover-t2-postgres.md](cutover-t2-postgres.md)): there is no data move, no freeze window, no snapshot
+import. The migration runs as part of the normal boot of the new release.
+
+---
+
+## What changes
+
+| Object | Kind | Notes |
+| --- | --- | --- |
+| `dev_agent_grants` | table | one row per (agent, capability); FK → `dev_agents(id)`; CHECK on `mode`, `status`, `audience` format, and `revoked` ↔ `revoked_at` |
+| `dev_agent_grants_active_idx` | partial index | `(agent_id) WHERE status = 'active'` |
+| `dev_agent_grants_cap_idx` | index | `(capability, status)` |
+
+No existing table, index, view or function is modified. The application code that reads and writes the table
+(`server/developer/agents.js`, `server/developer/routes.js`, `server/developer/store.js`,
+`server/identity/grants-admin.js`) only activates when an owner creates a grant through the developer API;
+before that, the table is empty and the indexes cost nothing.
+
+---
+
+## Prerequisites
+
+1. **Production is on PostgreSQL.** The migration is SQL that targets PostgreSQL (and PGlite in tests). The
+ current production release (`bad7b106fda0` at the time of writing) already serves from PostgreSQL; confirm
+ with `ov access run openvibe-ovh health network` (must say `ready`) and `validate network` (must say
+ `checkout clean`).
+2. **`DATABASE_DIRECT_URL` is set and connects.** The migration runner uses the direct (owner-role) connection
+ for schema changes. Check by name only:
+ ```bash
+ sudo grep -cE '^DATABASE_DIRECT_URL=.+' /etc/openvibe/network.env
+ ```
+ Must print `1`. Never print the value.
+3. **A green rehearsal on a copy of the production database.** See [Step 1](#step-1-rehearse-on-a-copy). Only
+ after it passes does the owner write the rehearsal file the deploy gates on.
+4. **The target commit is fixed.** It is the merge commit of PR #34 into `origin/main`. Steps 1 and 2 must use
+ that one SHA.
+
+---
+
+## Step 1: rehearse on a copy
+
+Run the migration against a throwaway copy of the production schema. The goal is to prove that 0017 applies
+cleanly on top of 0015 (the last migration production has applied) and that the resulting schema matches what
+the tests assert.
+
+```bash
+TARGET=<merge sha of PR #34>
+STAGE=/var/tmp/network-pr34-stage
+sudo -u ubuntu git clone --no-checkout /opt/openvibe.network "$STAGE"
+sudo -u ubuntu git -C "$STAGE" checkout --detach "$TARGET"
+cd "$STAGE" && sudo -u ubuntu npm ci
+```
+
+Then run the agent-schema test, which builds a fresh database at 0015, applies 0016 and 0017, and asserts the
+indexes, constraints and FK:
+
+```bash
+cd "$STAGE" && sudo -u ubuntu NETWORK_TEST_STORE=pglite node test/agent-schema.test.js
+```
+
+**Go:** exit 0 and `agent schema: all tests passed` on stdout.
+**No-go:** read the failure. A migration syntax error or a constraint mismatch means the PR is not ready; do
+not deploy.
+
+For an extra check against the real production schema (optional, recommended), point `NETWORK_TEST_STORE=env`
+at a scratch PostgreSQL database that has been restored from a production dump, and run the same test. The
+scratch database must never be the production URLs.
+
+The owner writes `~/openvibe/agents/ds/deploy/rehearsals/pr-34-agent-grants.json` with `{"ok": true}` only
+after the rehearsal passes. ds-finish refuses to deploy without that file and without the merged PR's
+`cutover` block.
+
+---
+
+## Step 2: deploy
+
+The migration runs as part of the normal boot of the new release. `server/db/database.js` runs every pending
+migration in `migrations/` at startup, using `DATABASE_DIRECT_URL` for the schema changes. No manual SQL, no
+script, no freeze.
+
+```bash
+ov deploy OpenVibe.Network "PR #34: delegated agent grants (migration 0017)"
+```
+
+The pipeline's own gates (verify, review, merge-by-policy) run before this. After merge, the deploy:
+
+1. Checks out the merge commit on `/opt/openvibe.network`.
+2. Restarts `openvibe-network.service`.
+3. The new process boots, runs `0017_agent_grants.sql` (the only pending migration), and starts serving.
+
+**Go:** `ov access run openvibe-ovh health network` says `ready` on the merge SHA; `validate network` says
+`checkout clean` at that SHA; `journal openvibe-network.service` shows the boot banner with no migration error.
+**No-go:** the service fails to start. Check the journal. A migration error at this point means the rehearsal
+did not match production; roll back (below).
+
+---
+
+## Verification after deploy
+
+```bash
+# The migration is recorded.
+ov access run openvibe-ovh journal openvibe-network.service 200 | grep -i '0017\|migration\|agent_grants'
+
+# The table exists and is empty (no grants have been created yet).
+# This requires a DATABASE_DIRECT_URL connection; run on the host:
+sudo node --env-file=/etc/openvibe/network.env -e '
+ const { Client } = require("pg");
+ (async () => {
+ const c = new Client({ connectionString: process.env.DATABASE_DIRECT_URL });
+ await c.connect();
+ const t = await c.query("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1)", ["dev_agent_grants"]);
+ const r = await c.query("SELECT count(*) FROM dev_agent_grants");
+ const m = await c.query("SELECT id FROM ov_migrations WHERE id = $1", ["0017"]);
+ console.log("table:", t.rows[0].exists, "rows:", r.rows[0].count, "migration:", m.rows.length === 1 ? "applied" : "MISSING");
+ await c.end();
+ })().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'
+```
+
+Expected: `table: true rows: 0 migration: applied`.
+
+---
+
+## Rollback
+
+The migration is additive and the table is unused until an owner creates a grant. Two rollback paths:
+
+- **Code rollback, table stays.** Revert the deploy to the previous release. The new release's code no longer
+ references `dev_agent_grants`, so the empty table is inert. No data is lost because no row was ever written.
+ The migration remains recorded in `ov_migrations`; a future re-deploy of PR #34 will see it as already
+ applied (`IF NOT EXISTS` throughout) and move on.
+- **Full rollback, table dropped.** If the table must be removed (for example, to re-run the migration from
+ scratch after a schema fix), connect as the owner role:
+ ```sql
+ DROP TABLE IF EXISTS dev_agent_grants;
+ DELETE FROM ov_migrations WHERE id = '0017';
+ ```
+ Do this only when no grant row exists (`SELECT count(*) FROM dev_agent_grants` = 0). If any row exists, the
+ grants are in use and the table cannot be dropped without losing them.
+
+In either case, restart the service after the rollback and confirm `health network` says `ready`.
+
+---
+
+## What is not covered here
+
+- **Data move.** There is none. The table starts empty.
+- **Freeze window.** There is none. The migration is a fast `CREATE TABLE` + two `CREATE INDEX` on an empty
+ table; it takes milliseconds.
+- **SQLite path.** Production is PostgreSQL-only; the SQLite runner is not exercised here.
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`; PR #25,
Status: **slices 1–2 built** (proposals, and agents: `migrations/0016_agents.sql`, `server/developer/agents.js`; PR #25,
merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_id` under `network.project.read`, Host
merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_id` under `network.project.read`, Host
-only: `server/internal/routes.js:490`; PR #28, merge `e422342`); slices 3–9 are still unbuilt (design, §8).
+only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:
+`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`); slices 4–9 are still unbuilt (design, §8).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against
Pinned version: `openvibe-contracts` **v0.85.0** (`package.json:33`); every contract claim below was re-checked against
@@ -63,8 +64,8 @@ allowances changes.
@@ -63,8 +64,8 @@ allowances changes.
## 3. Data model
## 3. Data model
-Agents are `0016` (built); delegated grants, confirmations and budgets take the next free numbers when their slices
-land (`0017`–`0019` if nothing else lands first). The design first reserved `0010`–`0013`, but the `openvibe-sdk/db`
+Agents are `0016` and delegated grants `0017` (built); confirmations and budgets take the next free numbers when their
+slices land (`0018`–`0019` if nothing else lands first). The design first reserved `0010`–`0013`, but the `openvibe-sdk/db`
runner tracks applied migrations by id and **refuses** a pending file numbered below one already applied (`migrate:
runner tracks applied migrations by id and **refuses** a pending file numbered below one already applied (`migrate:
0010_agents.sql is older than applied migration 0014`), and main already ships `0014` and `0015`; `test/agent-schema.test.js` pins
0010_agents.sql is older than applied migration 0014`), and main already ships `0014` and `0015`; `test/agent-schema.test.js` pins
this. `0010`–`0013` stay unused. Header convention of the existing migrations: `-- phase: expand`, `-- plan T2 WS-Z2.
this. `0010`–`0013` stay unused. Header convention of the existing migrations: `-- phase: expand`, `-- plan T2 WS-Z2.
@@ -121,7 +122,7 @@ CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) W
@@ -121,7 +122,7 @@ CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) W
service-hosted agent is `production`: the code that runs it is first-party, not the project's.
service-hosted agent is `production`: the code that runs it is first-party, not the project's.
- `revoked` is final, like an app. `paused` is the owner's kill switch and is reversible.
- `revoked` is final, like an app. `paused` is the owner's kill switch and is reversible.
-### Next — `00NN_agent_grants.sql` (slice 3)
+### 0017 — `0017_agent_grants.sql` (built, slice 3)
```sql
```sql
CREATE TABLE IF NOT EXISTS dev_agent_grants (
CREATE TABLE IF NOT EXISTS dev_agent_grants (
@@ -148,6 +149,13 @@ CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capa
@@ -148,6 +149,13 @@ CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capa
capability Contracts marks sensitive later is treated as sensitive on the next token with no data migration. The stored
capability Contracts marks sensitive later is treated as sensitive on the next token with no data migration. The stored
`mode` is what the owner chose; `effective_mode` (section 5) is computed.
`mode` is what the owner chose; `effective_mode` (section 5) is computed.
+As built: `PUT` takes the project row's lock (as the cascades do) and, for a service host, holds the host's
+`principal_grants` row `FOR SHARE`, so a ceiling change and a `PUT` never miss each other. A ceiling cascade sets
+`revoked_by` to the subject label of whoever shrank the ceiling (`system:network` for the expiry sweep) and
+`revoke_reason = 'beyond_host'`; a person's revoke leaves `revoke_reason` empty. Each change is one `dev_audit` row,
+action `grant.changed`, target `agent:agt_…`, detail `{ capability, audience, from, to, mode, reason? }`, without an
+event (section 2, gap 3). A revoked agent's grants stay as they are and read back `within_host: false`.
+
### Next — `00NN_confirmations.sql` (slice 4)
### Next — `00NN_confirmations.sql` (slice 4)
```sql
```sql
@@ -541,10 +549,11 @@ and agree).
@@ -541,10 +549,11 @@ and agree).
`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of
`account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of
`docs/developer-projects.md`, `test/agents.test.js`,
`docs/developer-projects.md`, `test/agents.test.js`,
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
`test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
-3. **Delegated grants and modes.** `migrations/00NN_agent_grants.sql`, grant routes, the ceiling (reusing
+3. **Delegated grants and modes** (built). `migrations/0017_agent_grants.sql`, grant routes, the ceiling (reusing
`tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and
`tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and
`grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,
`grants-admin.js` revoke + `expireDue`, `test/agent-grants.test.js`,
- cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
+ cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`. `GET /agents/:agent` still returns
+ the `agent` alone; its grants are `GET /agents/:agent/grants`.
4. **Confirmations, owner side.** `migrations/00NN_confirmations.sql`, `server/developer/confirmations.js` (store,
4. **Confirmations, owner side.** `migrations/00NN_confirmations.sql`, `server/developer/confirmations.js` (store,
lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,
lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,
rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+-- phase: expand
+-- plan T2 WS-Z2. Delegated grants (docs/t2-projects-and-grants.md section 3, slice 3): what an agent may do for its
+-- owner, one row per capability, never more than its host may do for the project (the delegation ceiling, checked by
+-- server/developer/agents.js). `mode` is what the owner chose; whether a capability is sensitive is read from the
+-- installed catalog on every read, so effective_mode is never stored. revoked is final for that row until the owner
+-- sets the grant again.
+
+CREATE TABLE IF NOT EXISTS dev_agent_grants (
+ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
+ capability text COLLATE "C" NOT NULL,
+ audience text COLLATE "C" NOT NULL CHECK (audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'),
+ mode text COLLATE "C" NOT NULL CHECK (mode IN ('auto', 'confirm')),
+ status text COLLATE "C" NOT NULL CHECK (status IN ('active', 'revoked')),
+ granted_at text COLLATE "C" NOT NULL,
+ granted_by text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C",
+ revoked_at text COLLATE "C",
+ revoked_by text COLLATE "C",
+ revoke_reason text COLLATE "C",
+ PRIMARY KEY (agent_id, capability),
+ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
+);
+CREATE INDEX IF NOT EXISTS dev_agent_grants_active_idx ON dev_agent_grants (agent_id) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capability, status);
@@ -11,9 +11,15 @@
@@ -11,9 +11,15 @@
* - Losing the host or the project takes the agents with it, in the transaction that causes it: revoking the app,
* - Losing the host or the project takes the agents with it, in the transaction that causes it: revoking the app,
* archiving the project, the owner leaving it, and the owner's account erasure (revokeWhere).
* archiving the project, the owner leaving it, and the owner's account erasure (revokeWhere).
* - Every change is a dev_audit row without an event: Contracts has no payload for it yet.
* - Every change is a dev_audit row without an event: Contracts has no payload for it yet.
- * Delegated grants, budgets, standing rules, confirmations and agent tokens come in later slices (section 8).
+ * - Delegated grants (slice 3, migrations/0017_agent_grants.sql): the owner alone sets one, for a capability the
+ * host may use for this project (the delegation ceiling, `ceiling` below); the owner, an admin+ or staff revoke it.
+ * A capability the installed catalog marks sensitive is always `confirm`, whatever mode is stored. A change that
+ * shrinks the ceiling (an allowance, an app grant, a service's principal_grants row) revokes the delegated grants
+ * outside it in its own transaction (revokeBeyondHost).
+ * Budgets, standing rules, confirmations and agent tokens come in later slices (section 8).
*/
*/
-const { ids } = require('openvibe-contracts');
+const { ids, capabilities } = require('openvibe-contracts');
+const policy = require('./policy');
const store = require('./store');
const store = require('./store');
const AGENT_ID_RE = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
const AGENT_ID_RE = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -161,4 +167,172 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {
@@ -161,4 +167,172 @@ async function changeStatus(db, actor, projectId, agentId, action, { ctx }) {
return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));
return agentView(await db.prepare('SELECT * FROM dev_agents WHERE id = ?').get(a.id));
}
}
-module.exports = { ensureSchema, AGENT_ID_RE, agentView, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere };
+// ── Delegated grants ───────────────────────────────────────────
+
+const GRANT_MODES = ['auto', 'confirm'];
+
+/** Sensitive per the installed catalog, read on every call (never stored), so a capability marked later counts at once. */
+const isSensitive = (capability) => { const c = capabilities.get(String(capability)); return !!(c && c.sensitive); };
+/** What a grant means now: a sensitive capability is always confirmed, whatever mode the owner stored. */
+const effectiveMode = (grant) => (isSensitive(grant.capability) ? 'confirm' : grant.mode);
+
+/**
+ * The delegation ceiling: the capabilities the agent's host may use for this project at `audience`, now.
+ * App host: the app's effectiveGrants (approved ∩ allowance, ∪ the sandbox allowance for a sandbox app, ∩ grantable).
+ * Service host: its unexpired principal_grants, never an `internal` capability. Either way only `active` ones, and
+ * nothing for a revoked agent, a revoked host app or an archived project.
+ */
+async function ceiling(db, agent, audience, settings) {
+ if (agent.status === 'revoked') return new Set();
+ let held = [];
+ if (agent.host_kind === 'app') {
+ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND revoked_at IS NULL').get(agent.host_app_id);
+ const project = app && await db.prepare('SELECT * FROM dev_projects WHERE id = ? AND archived_at IS NULL').get(app.project_id);
+ // Required here: tokens.js requires store.js, which requires this module lazily.
+ if (project) held = await require('./tokens').effectiveGrants(db, app, project, audience, settings || policy.settings());
+ } else {
+ held = (await require('../identity/principals').grantsFor(db, agent.host_service, audience)).map((g) => g.capability)
+ .filter((c) => { const m = capabilities.get(c); return m && m.visibility !== 'internal'; });
+ }
+ return new Set(held.filter((c) => { const m = capabilities.get(c); return m && m.status === 'active'; }));
+}
+
+async function withinHost(db, agent, capability, settings) {
+ const audience = policy.audienceOf(capability);
+ return !!audience && (await ceiling(db, agent, audience, settings)).has(capability);
+}
+
+function grantView(g, within) {
+ return {
+ capability: g.capability, audience: g.audience, mode: g.mode, effective_mode: effectiveMode(g), sensitive: isSensitive(g.capability),
+ status: g.status, within_host: within, expires_at: g.expires_at || null, granted_at: g.granted_at, granted_by: g.granted_by,
+ };
+}
+
+/** Views of an agent's grant rows, each ceiling computed once per audience. */
+async function grantViews(db, agent, rows, settings) {
+ const ceilings = new Map();
+ const out = [];
+ for (const g of rows) {
+ if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, agent, g.audience, settings));
+ out.push(grantView(g, ceilings.get(g.audience).has(g.capability)));
+ }
+ return out;
+}
+
+/** The audit row of a delegated-grant change: no event, Contracts has no payload for it yet (section 2). */
+async function grantAudit(db, agent, g, { actor, from, to, mode, reason, ctx }) {
+ await store.audit(db, { projectId: agent.project_id, actor, action: 'grant.changed', target: `agent:${agent.id}`,
+ detail: { capability: g.capability, audience: g.audience, from, to, mode, reason: reason || undefined }, ctx });
+}
+
+async function revokeGrantRow(db, agent, g, { actor, reason, ctx }) {
+ const t = nowIso();
+ const r = await db.prepare("UPDATE dev_agent_grants SET status = 'revoked', revoked_at = ?, revoked_by = ?, revoke_reason = ?, updated_at = ? WHERE agent_id = ? AND capability = ? AND status = 'active'")
+ .run(t, actor, reason || null, t, agent.id, g.capability);
+ if (r.changes) await grantAudit(db, agent, g, { actor, from: 'active', to: 'revoked', mode: g.mode, reason, ctx });
+ return r.changes > 0;
+}
+
+function expiryOf(v) {
+ if (v == null || v === '') return null;
+ const d = new Date(String(v));
+ if (Number.isNaN(d.getTime())) fail(422, 'grant.invalid', 'expires_at is not a date');
+ if (d.getTime() <= Date.now()) fail(422, 'grant.invalid', 'expires_at is in the past');
+ return d.toISOString();
+}
+
+async function listGrants(db, actor, projectId, agentId, { settings } = {}) {
+ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
+ const a = await loadAgent(db, project.id, agentId);
+ return await grantViews(db, a, await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? ORDER BY capability').all(a.id), settings);
+}
+
+async function readGrant(db, agent, capability, settings) {
+ return (await grantViews(db, agent, [await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ?').get(agent.id, capability)], settings))[0];
+}
+
+/**
+ * Set a delegated grant: { mode: 'auto'|'confirm' (default confirm), expires_at? }. The agent's owner only: a person
+ * delegates their own authority. Refused: an unknown capability (404 grant.unknown_capability), one that is not
+ * active (404 grant.not_grantable), `auto` on a sensitive one (422 grant.sensitive_requires_confirm), one outside the
+ * host's ceiling (403 grant.beyond_host). Setting a revoked grant again makes it active with a new granted_at.
+ */
+async function putGrant(db, actor, projectId, agentId, capability, body, { ctx, settings }) {
+ const { project, role } = await store.access(db, actor, projectId, { staffOk: false });
+ const a = await loadAgent(db, project.id, agentId);
+ if (!(role && a.owner_subject === actor.subject)) fail(403, 'agent.forbidden', "only the agent's owner delegates to it");
+ const cap = capabilities.get(String(capability));
+ if (!cap) fail(404, 'grant.unknown_capability', `no capability ${String(capability).slice(0, 80)} in the catalog`);
+ if (cap.status !== 'active') fail(404, 'grant.not_grantable', `${cap.id} is ${cap.status}`);
+ const mode = body.mode === undefined || body.mode === null ? 'confirm' : body.mode;
+ if (!GRANT_MODES.includes(mode)) fail(422, 'grant.invalid', "mode is 'auto' or 'confirm'");
+ const expiresAt = expiryOf(body.expires_at);
+ if (mode === 'auto' && isSensitive(cap.id)) fail(422, 'grant.sensitive_requires_confirm', `${cap.id} is sensitive: every use is confirmed`);
+ const audience = policy.audienceOf(cap.id);
+ await db.tx(async () => {
+ // Under the project's lock, which an allowance change (its UPDATE), an app grant revoke, an app revocation, an
+ // archive and a member's removal hold while they cascade: the ceiling read here is the one they leave behind.
+ await store.lockProject(db, project.id);
+ await store.access(db, actor, projectId, { staffOk: false });
+ const cur = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(a.id);
+ if (cur.status === 'revoked') fail(409, 'agent.revoked', 'agent is revoked');
+ // A service's grant row is held until commit, so a concurrent /api/admin/grants/revoke cascades after this.
+ if (cur.host_kind === 'service') await db.prepare('SELECT 1 AS held FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ? FOR SHARE').get(cur.host_service, cap.id, audience);
+ if (!await withinHost(db, cur, cap.id, settings)) fail(403, 'grant.beyond_host', `${cap.id} is not something this agent's host may do for the project`);
+ const prev = await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? FOR UPDATE').get(a.id, cap.id);
+ const active = prev && prev.status === 'active';
+ if (active && prev.mode === mode && (prev.expires_at || null) === expiresAt) return;
+ const t = nowIso();
+ if (active) {
+ await db.prepare('UPDATE dev_agent_grants SET mode = ?, expires_at = ?, updated_at = ? WHERE agent_id = ? AND capability = ?').run(mode, expiresAt, t, a.id, cap.id);
+ } else {
+ await db.prepare(`INSERT INTO dev_agent_grants (agent_id, capability, audience, mode, status, granted_at, granted_by, updated_at, expires_at)
+ VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
+ ON CONFLICT (agent_id, capability) DO UPDATE SET audience = excluded.audience, mode = excluded.mode, status = 'active',
+ granted_at = excluded.granted_at, granted_by = excluded.granted_by, updated_at = excluded.updated_at, expires_at = excluded.expires_at,
+ revoked_at = NULL, revoked_by = NULL, revoke_reason = NULL`)
+ .run(a.id, cap.id, audience, mode, t, actor.label, t, expiresAt);
+ }
+ await grantAudit(db, cur, { capability: cap.id, audience }, { actor: actor.label, from: prev ? prev.status : 'none', to: 'active', mode, ctx });
+ });
+ return await readGrant(db, await loadAgent(db, project.id, a.id), cap.id, settings);
+}
+
+/** Revoke a delegated grant: the agent's owner, an admin+ or staff. */
+async function deleteGrant(db, actor, projectId, agentId, capability, { ctx, settings }) {
+ const { project, role } = await store.access(db, actor, projectId, { allowArchived: true });
+ const a = await loadAgent(db, project.id, agentId);
+ if (!(role && a.owner_subject === actor.subject) && !atLeast(role, 'admin') && !actor.staff) {
+ fail(403, 'agent.forbidden', "only the agent's owner, an admin or staff revoke its grants");
+ }
+ await db.tx(async () => {
+ const g = await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ? FOR UPDATE').get(a.id, String(capability));
+ if (!g) fail(404, 'grant.not_found', 'no such grant');
+ if (g.status !== 'active') fail(409, 'grant.not_active', `grant is ${g.status}`);
+ await revokeGrantRow(db, a, g, { actor: actor.label, reason: null, ctx });
+ });
+ return await readGrant(db, a, String(capability), settings);
+}
+
+/**
+ * The ceiling cascade, in the caller's transaction: revoke every active delegated grant of the (not revoked) agents
+ * matching `where` that their host's ceiling no longer covers, one audit row each (reason beyond_host). `actor` is the
+ * subject label of whoever shrank it. Returns how many were revoked.
+ */
+async function revokeBeyondHost(db, where, params, { actor, ctx, settings }) {
+ let n = 0;
+ for (const a of await db.prepare(`SELECT * FROM dev_agents WHERE (${where}) AND status <> 'revoked' ORDER BY id`).all(...params)) {
+ const ceilings = new Map();
+ for (const g of await db.prepare("SELECT * FROM dev_agent_grants WHERE agent_id = ? AND status = 'active' ORDER BY capability").all(a.id)) {
+ if (!ceilings.has(g.audience)) ceilings.set(g.audience, await ceiling(db, a, g.audience, settings));
+ if (!ceilings.get(g.audience).has(g.capability) && await revokeGrantRow(db, a, g, { actor, reason: 'beyond_host', ctx })) n++;
+ }
+ }
+ return n;
+}
+
+module.exports = {
+ ensureSchema, AGENT_ID_RE, agentView, listAgents, getAgent, createAgent, renameAgent, changeStatus, revokeWhere,
+ effectiveMode, withinHost, listGrants, putGrant, deleteGrant, revokeBeyondHost,
+};
@@ -42,6 +42,9 @@ const cache = require('openvibe-shared/cache-policy');
@@ -42,6 +42,9 @@ const cache = require('openvibe-shared/cache-policy');
* POST /:project/agents/:agent/pause its owner, admin+ or staff
* POST /:project/agents/:agent/pause its owner, admin+ or staff
* POST /:project/agents/:agent/resume its owner or admin+ (not staff)
* POST /:project/agents/:agent/resume its owner or admin+ (not staff)
* DELETE /:project/agents/:agent revoke, final: its owner, admin+ or staff
* DELETE /:project/agents/:agent revoke, final: its owner, admin+ or staff
+ * GET /:project/agents/:agent/grants viewer+: delegated grants with effective_mode and within_host
+ * PUT /:project/agents/:agent/grants/:capability { mode, expires_at? } its owner only, inside the host's ceiling
+ * DELETE /:project/agents/:agent/grants/:capability revoke: its owner, admin+ or staff
* GET /:project/quotas viewer+
* GET /:project/quotas viewer+
* PUT /:project/quotas/:capability { limit, window, unit } staff
* PUT /:project/quotas/:capability { limit, window, unit } staff
* DELETE /:project/quotas/:capability staff
* DELETE /:project/quotas/:capability staff
@@ -136,6 +139,9 @@ function router() {
@@ -136,6 +139,9 @@ function router() {
r.post('/:project/agents/:agent/pause', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'pause', o)));
r.post('/:project/agents/:agent/pause', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'pause', o)));
r.post('/:project/agents/:agent/resume', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'resume', o)));
r.post('/:project/agents/:agent/resume', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'resume', o)));
r.delete('/:project/agents/:agent', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'revoke', o)));
r.delete('/:project/agents/:agent', handle(async (db, a, req, o) => await agents.changeStatus(db, a, req.params.project, req.params.agent, 'revoke', o)));
+ r.get('/:project/agents/:agent/grants', handle(async (db, a, req, o) => ({ grants: await agents.listGrants(db, a, req.params.project, req.params.agent, o) })));
+ r.put('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.putGrant(db, a, req.params.project, req.params.agent, req.params.capability, req.body || {}, o)));
+ r.delete('/:project/agents/:agent/grants/:capability', handle(async (db, a, req, o) => await agents.deleteGrant(db, a, req.params.project, req.params.agent, req.params.capability, o)));
r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
@@ -258,6 +258,8 @@ async function setAllowance(db, actor, projectId, { capabilities: list }, { ctx,
@@ -258,6 +258,8 @@ async function setAllowance(db, actor, projectId, { capabilities: list }, { ctx,
await grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
await grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });
trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });
}
}
+ // Then the delegated grants of this project's app-hosted agents that the smaller ceiling no longer covers (WS-Z2).
+ await require('./agents').revokeBeyondHost(db, "project_id = ? AND host_kind = 'app'", [project.id], { actor: actor.label, ctx, settings });
});
});
return { allowance: wanted, trimmed };
return { allowance: wanted, trimmed };
}
}
@@ -651,8 +653,12 @@ async function decideGrant(db, actor, projectId, appId, capability, decision, {
@@ -651,8 +653,12 @@ async function decideGrant(db, actor, projectId, appId, capability, decision, {
if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
}
}
await db.tx(async () => {
await db.tx(async () => {
+ // A revoke takes the project's lock first, as a delegated grant's PUT does, so neither misses the other.
+ if (decision === 'revoked') await lockProject(db, project.id);
await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);
await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);
await grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);
await grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);
+ // The app's agents lose the delegated grants it no longer covers (WS-Z2).
+ if (decision === 'revoked') await require('./agents').revokeBeyondHost(db, 'host_app_id = ?', [app.id], { actor: actor.label, ctx, settings });
});
});
return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));
return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));
}
}
@@ -14,6 +14,9 @@ const cache = require('openvibe-shared/cache-policy');
@@ -14,6 +14,9 @@ const cache = require('openvibe-shared/cache-policy');
* POST /api/admin/grants { client_id, capability, audience?, namespaces?, expires_at?, reason }
* POST /api/admin/grants { client_id, capability, audience?, namespaces?, expires_at?, reason }
* POST /api/admin/grants/revoke { client_id, capability, audience?, reason }
* POST /api/admin/grants/revoke { client_id, capability, audience?, reason }
*
*
+ * A revoke and a recorded expiry also revoke, in the same transaction, the delegated grants of agents this service
+ * hosts that its remaining grants no longer cover (plan T2 WS-Z2, server/developer/agents.js revokeBeyondHost).
+ *
* The audience defaults to, and must be, the capability owner's (openvibe.<owner>). Expired grants stop
* The audience defaults to, and must be, the capability owner's (openvibe.<owner>). Expired grants stop
* counting at once (grantsFor filters them); expireDue(), every 10 minutes, records each expiry once.
* counting at once (grantsFor filters them); expireDue(), every 10 minutes, records each expiry once.
*/
*/
@@ -140,10 +143,19 @@ async function revoke(db, body, actor) {
@@ -140,10 +143,19 @@ async function revoke(db, body, actor) {
.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
const namespaces = JSON.parse(prev.namespaces || '[]');
const namespaces = JSON.parse(prev.namespaces || '[]');
await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
+ await revokeDelegated(db, t.client_id, actor);
return { ...t, change: 'revoked' };
return { ...t, change: 'revoked' };
});
});
}
}
+/**
+ * The agents this service hosts lose the delegated grants its remaining grants no longer cover (plan T2 WS-Z2), in the
+ * caller's transaction. Required lazily: agents.js reaches principals.js, which requires this module.
+ */
+async function revokeDelegated(db, clientId, actor) {
+ await require('../developer/agents').revokeBeyondHost(db, "host_kind = 'service' AND host_service = ?", [clientId], { actor: actor ? `user:${actor}` : 'system:network' });
+}
+
/** Record every grant whose expiry has passed, once. → how many */
/** Record every grant whose expiry has passed, once. → how many */
async function expireDue(db) {
async function expireDue(db) {
const due = await db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ov_now()').all();
const due = await db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ov_now()').all();
@@ -152,6 +164,7 @@ async function expireDue(db) {
@@ -152,6 +164,7 @@ async function expireDue(db) {
const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
.run(g.client_id, g.capability, g.audience)).changes;
.run(g.client_id, g.capability, g.audience)).changes;
if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
+ if (n) await revokeDelegated(db, g.client_id, null);
});
});
}
}
if (due.length) kick(db);
if (due.length) kick(db);
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+// Delegated grants (plan T2 WS-Z2 slice 3, docs/t2-projects-and-grants.md sections 4-7; server/developer/agents.js):
+// the delegation ceiling for production-app, sandbox-app and service hosts; modes (auto on a sensitive capability is
+// refused, and effective_mode follows the installed catalog on every read); only the agent's owner sets a grant; and
+// every cascade with its audit row: an allowance shrink, an app grant revoke, a service grant revoke and its expiry,
+// then an app revocation, a member's removal and an archive taking the agents.
+// node test/agent-grants.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { capabilities } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const grantsAdmin = require('../server/identity/grants-admin');
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+const PEOPLE = [[10, 'owner'], [11, 'dev'], [12, 'viewer'], [13, 'stranger'], [14, 'staff'], [15, 'admin']];
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin'), (15, 'admin', 'x', 'user')`).run();
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('actor', 'x', 'OpenVibe.Actor', '[]', 1)").run();
+const S = {};
+for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.locals.db = db;
+app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: 'media.object.read', agentHostServices: 'actor' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/api/v1/projects', require('../server/developer/routes').router());
+const server = http.createServer(app);
+const T = {};
+for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const api = async (who, method, p, body) => {
+ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, type: r.headers.get('content-type') || '', body: text ? JSON.parse(text) : null };
+ };
+ const ok = (r, status = 200) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); return r.body; };
+ const refused = (r, status, code) => { assert.strictEqual(r.status, status, JSON.stringify(r.body)); assert.strictEqual(r.body.code, code); assert.match(r.type, /problem\+json/); };
+ const grantRow = async (agent, cap) => await db.prepare('SELECT * FROM dev_agent_grants WHERE agent_id = ? AND capability = ?').get(agent, cap);
+ const audits = async (agent) => (await db.prepare("SELECT actor, detail, event FROM dev_audit WHERE target = ? AND action = 'grant.changed' ORDER BY id").all(`agent:${agent}`))
+ .map((r) => { assert.strictEqual(r.event, null, 'no event until Contracts has a payload'); const d = JSON.parse(r.detail); return `${d.capability} ${d.from}>${d.to} ${d.mode} ${d.reason || '-'} ${r.actor}`; });
+ try {
+ // ── A project: a production app with two approved grants, a sandbox app with one, and Actor as a service host ──
+ const P = ok(await api('owner', 'POST', '', { name: 'Delegation' }), 201).id;
+ for (const [name, role] of [['dev', 'developer'], ['viewer', 'viewer'], ['admin', 'admin']]) ok(await api('owner', 'POST', `/${P}/members`, { username: name, role }), 201);
+ ok(await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' }));
+ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.delete', 'events.app.publish'] }));
+ const PR = ok(await api('owner', 'POST', `/${P}/apps`, { name: 'Bot', environment: 'production' }), 201).id;
+ const SB = ok(await api('dev', 'POST', `/${P}/apps`, { name: 'Bot sandbox', environment: 'sandbox' }), 201).id;
+ for (const c of ['media.object.upload', 'media.object.delete']) assert.strictEqual(ok(await api('owner', 'POST', `/${P}/apps/${PR}/grants`, { capability: c }), 201).status, 'approved');
+ assert.strictEqual(ok(await api('dev', 'POST', `/${P}/apps/${SB}/grants`, { capability: 'media.object.read' }), 201).status, 'requested');
+ assert.strictEqual(ok(await api('owner', 'POST', `/${P}/apps/${SB}/grants/media.object.read/approve`)).status, 'approved');
+ for (const c of ['chat.message.send', 'chat.moderation.read']) await grantsAdmin.grant(db, { client_id: 'actor', capability: c, reason: 'agent host test' }, null);
+ const ownBot = ok(await api('owner', 'POST', `/${P}/agents`, { name: 'Prod bot', host: { type: 'app', id: PR } }), 201).id;
+ const devBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Sandbox bot', host: { type: 'app', id: SB } }), 201).id;
+ const svcBot = ok(await api('dev', 'POST', `/${P}/agents`, { name: 'Actor helper', host: { type: 'service', id: 'actor' } }), 201).id;
+ const put = (who, agent, cap, body = {}) => api(who, 'PUT', `/${P}/agents/${agent}/grants/${cap}`, body);
+
+ // ── Production-app host: only what the app holds now, inside the allowance; never first-party ──
+ const up = ok(await put('owner', ownBot, 'media.object.upload', { mode: 'auto' }));
+ assert.deepStrictEqual({ ...up, granted_at: undefined }, {
+ capability: 'media.object.upload', audience: 'openvibe.media', mode: 'auto', effective_mode: 'auto', sensitive: false,
+ status: 'active', within_host: true, expires_at: null, granted_at: undefined, granted_by: `user:${S.owner}`,
+ });
+ assert.match(up.granted_at, /^\d{4}-\d{2}-\d{2}T/);
+ refused(await put('owner', ownBot, 'events.app.publish'), 403, 'grant.beyond_host'); // in the allowance, but the app lacks it
+ refused(await put('owner', ownBot, 'media.object.read'), 403, 'grant.beyond_host'); // the sandbox allowance is not a production app's
+ refused(await put('owner', ownBot, 'chat.message.send'), 403, 'grant.beyond_host'); // first-party
+ refused(await put('owner', ownBot, 'nope.thing.do'), 404, 'grant.unknown_capability');
+ refused(await put('owner', ownBot, 'pics.image.upload'), 404, 'grant.not_grantable'); // planned
+ refused(await put('owner', ownBot, 'media.object.upload', { mode: 'always' }), 422, 'grant.invalid');
+ refused(await put('owner', ownBot, 'media.object.upload', { expires_at: '2001-01-01T00:00:00Z' }), 422, 'grant.invalid');
+
+ // ── Modes: auto on a sensitive capability is refused; confirm is the default ──
+ refused(await put('owner', ownBot, 'media.object.delete', { mode: 'auto' }), 422, 'grant.sensitive_requires_confirm');
+ const del = ok(await put('owner', ownBot, 'media.object.delete'));
+ assert.deepStrictEqual([del.mode, del.effective_mode, del.sensitive, del.within_host], ['confirm', 'confirm', true, true]);
+ const later = new Date(Date.now() + 86400e3).toISOString();
+ assert.strictEqual(ok(await put('owner', ownBot, 'media.object.upload', { mode: 'auto', expires_at: later })).expires_at, later);
+
+ // ── effective_mode follows the installed catalog: a grant set to auto before its capability became sensitive reads as confirm ──
+ const manifest = capabilities.get('media.object.upload');
+ const wasSensitive = manifest.sensitive;
+ manifest.sensitive = true;
+ try {
+ const g = ok(await api('owner', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.find((x) => x.capability === 'media.object.upload');
+ assert.deepStrictEqual([g.mode, g.effective_mode, g.sensitive], ['auto', 'confirm', true]);
+ assert.strictEqual((await grantRow(ownBot, 'media.object.upload')).mode, 'auto', 'the stored row is not rewritten');
+ } finally {
+ if (wasSensitive === undefined) delete manifest.sensitive; else manifest.sensitive = wasSensitive;
+ }
+ assert.strictEqual(ok(await api('owner', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.find((x) => x.capability === 'media.object.upload').effective_mode, 'auto');
+
+ // ── Only the agent's owner sets a grant ──
+ refused(await put('admin', ownBot, 'media.object.read'), 403, 'agent.forbidden');
+ refused(await put('viewer', ownBot, 'media.object.read'), 403, 'agent.forbidden');
+ refused(await put('dev', ownBot, 'media.object.upload'), 403, 'agent.forbidden');
+ refused(await put('staff', ownBot, 'media.object.upload'), 403, 'project.forbidden');
+ refused(await put('stranger', ownBot, 'media.object.upload'), 404, 'project.not_found');
+ refused(await put(null, ownBot, 'media.object.upload'), 401, 'auth.required');
+
+ // ── Sandbox-app host: the sandbox allowance counts, and still only what the app holds ──
+ assert.strictEqual(ok(await put('dev', devBot, 'media.object.read', { mode: 'auto' })).within_host, true);
+ refused(await put('dev', devBot, 'media.object.upload'), 403, 'grant.beyond_host');
+
+ // ── Service host: its principal_grants, never an internal capability ──
+ refused(await put('dev', svcBot, 'chat.message.send', { mode: 'auto' }), 422, 'grant.sensitive_requires_confirm');
+ const chat = ok(await put('dev', svcBot, 'chat.message.send', { mode: 'confirm' }));
+ assert.deepStrictEqual([chat.audience, chat.effective_mode, chat.within_host], ['openvibe.chat', 'confirm', true]);
+ refused(await put('dev', svcBot, 'chat.moderation.read'), 403, 'grant.beyond_host'); // internal, though Actor holds it
+ refused(await put('dev', svcBot, 'media.object.read'), 403, 'grant.beyond_host'); // Actor does not hold it
+
+ // ── Read: members and staff ──
+ assert.deepStrictEqual(ok(await api('viewer', 'GET', `/${P}/agents/${ownBot}/grants`)).grants.map((g) => g.capability), ['media.object.delete', 'media.object.upload']);
+ assert.strictEqual(ok(await api('staff', 'GET', `/${P}/agents/${svcBot}/grants`)).grants.length, 1);
+ refused(await api('stranger', 'GET', `/${P}/agents/${ownBot}/grants`), 404, 'project.not_found');
+
+ // ── Revoke: the owner, an admin+ or staff; not twice ──
+ refused(await api('viewer', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 403, 'agent.forbidden');
+ refused(await api('dev', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 403, 'agent.forbidden');
+ assert.strictEqual(ok(await api('staff', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`)).status, 'revoked');
+ refused(await api('owner', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.delete`), 409, 'grant.not_active');
+ refused(await api('owner', 'DELETE', `/${P}/agents/${ownBot}/grants/media.object.list`), 404, 'grant.not_found');
+ assert.strictEqual((await grantRow(ownBot, 'media.object.delete')).revoked_by, `user:${S.staff}`);
+ assert.strictEqual(ok(await put('owner', ownBot, 'media.object.delete')).status, 'active', 'the owner may set it again');
+ const again = await grantRow(ownBot, 'media.object.delete');
+ assert.deepStrictEqual([again.revoked_at, again.revoked_by, again.revoke_reason], [null, null, null]);
+
+ // ── Cascade: an allowance shrink revokes the delegated grants outside it; the sandbox allowance keeps devBot's ──
+ ok(await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'events.app.publish'] }));
+ const cut = await grantRow(ownBot, 'media.object.delete');
+ assert.deepStrictEqual([cut.status, cut.revoked_by, cut.revoke_reason], ['revoked', `user:${S.staff}`, 'beyond_host']);
+ assert.strictEqual((await grantRow(ownBot, 'media.object.upload')).status, 'active');
+ assert.strictEqual((await grantRow(devBot, 'media.object.read')).status, 'active');
+
+ // ── Cascade: revoking the app's grant revokes its agents' delegated grant ──
+ ok(await api('owner', 'DELETE', `/${P}/apps/${PR}/grants/media.object.upload`));
+ assert.deepStrictEqual([(await grantRow(ownBot, 'media.object.upload')).status, (await grantRow(ownBot, 'media.object.upload')).revoke_reason], ['revoked', 'beyond_host']);
+ assert.deepStrictEqual(await audits(ownBot), [
+ `media.object.upload none>active auto - user:${S.owner}`,
+ `media.object.delete none>active confirm - user:${S.owner}`,
+ `media.object.upload active>active auto - user:${S.owner}`,
+ `media.object.delete active>revoked confirm - user:${S.staff}`,
+ `media.object.delete revoked>active confirm - user:${S.owner}`,
+ `media.object.delete active>revoked confirm beyond_host user:${S.staff}`,
+ `media.object.upload active>revoked auto beyond_host user:${S.owner}`,
+ ]);
+
+ // ── Cascade: /api/admin/grants/revoke on the service host ──
+ await grantsAdmin.revoke(db, { client_id: 'actor', capability: 'chat.message.send', reason: 'no longer needed' }, S.owner);
+ assert.deepStrictEqual([(await grantRow(svcBot, 'chat.message.send')).status, (await grantRow(svcBot, 'chat.message.send')).revoked_by], ['revoked', `user:${S.owner}`]);
+
+ // ── Cascade: the service grant's expiry. It stops counting at once (within_host false); the sweep revokes ──
+ await grantsAdmin.grant(db, { client_id: 'actor', capability: 'blog.post.publish', reason: 'agent host test', expires_at: later }, null);
+ assert.strictEqual(ok(await put('dev', svcBot, 'blog.post.publish')).within_host, true);
+ await db.prepare("UPDATE principal_grants SET expires_at = '2001-01-01 00:00:00' WHERE client_id = 'actor' AND capability = 'blog.post.publish'").run();
+ const lapsed = ok(await api('dev', 'GET', `/${P}/agents/${svcBot}/grants`)).grants.find((g) => g.capability === 'blog.post.publish');
+ assert.deepStrictEqual([lapsed.status, lapsed.within_host], ['active', false]);
+ assert.ok(await grantsAdmin.expireDue(db) >= 1);
+ assert.strictEqual((await grantRow(svcBot, 'blog.post.publish')).status, 'revoked');
+ assert.deepStrictEqual(await audits(svcBot), [
+ `chat.message.send none>active confirm - user:${S.dev}`,
+ `chat.message.send active>revoked confirm beyond_host user:${S.owner}`,
+ `blog.post.publish none>active confirm - user:${S.dev}`,
+ 'blog.post.publish active>revoked confirm beyond_host system:network',
+ ]);
+
+ // ── Losing the host or the project takes the agents (slice 2's cascades), each with its audit row; a revoked
+ // agent's grants are outside every ceiling, and it takes no new ones ──
+ const reasons = async (agent) => (await db.prepare("SELECT detail FROM dev_audit WHERE target = ? AND action = 'agent.revoked'").all(`agent:${agent}`)).map((r) => JSON.parse(r.detail).reason);
+ ok(await api('dev', 'DELETE', `/${P}/apps/${SB}`));
+ assert.deepStrictEqual(await reasons(devBot), ['app_revoked']);
+ assert.strictEqual(ok(await api('dev', 'GET', `/${P}/agents/${devBot}/grants`)).grants[0].within_host, false);
+ refused(await put('dev', devBot, 'media.object.read'), 409, 'agent.revoked');
+ ok(await api('admin', 'DELETE', `/${P}/members/${S.dev}`), 204);
+ assert.deepStrictEqual(await reasons(svcBot), ['member_removed']);
+ ok(await api('owner', 'POST', `/${P}/archive`));
+ assert.deepStrictEqual(await reasons(ownBot), ['project_archived']);
+ refused(await put('owner', ownBot, 'media.object.delete'), 409, 'project.archived');
+ console.log('agent grants: all tests passed');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
-// The agents migration (plan T2 WS-Z2 slice 2, migrations/0016_agents.sql) reaches a database that production
-// already migrated: a database at 0015 without it gains it from the normal runner, once, and the file is safe to
-// run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied
+// The agents migrations (plan T2 WS-Z2 slices 2-3, migrations/0016_agents.sql and 0017_agent_grants.sql) reach a
+// database that production already migrated: a database at 0015 without them gains them from the normal runner,
+// once, and each file is safe to run twice. It also pins why it is 0016 and not the design's 0010: the openvibe-sdk/db runner tracks applied
// migrations by id but refuses a pending file numbered below one already applied.
// migrations by id but refuses a pending file numbered below one already applied.
// node test/agent-schema.test.js
// node test/agent-schema.test.js
const assert = require('assert');
const assert = require('assert');
@@ -43,11 +43,11 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -43,11 +43,11 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
- await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, '0016_agents.sql'), 'utf8')));
+ for (const f of ['0016_agents.sql', '0017_agent_grants.sql']) await asOwner((db) => db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8')));
// The table works on the migrated database: ISO defaults like every other text timestamp, and the app host
// The table works on the migrated database: ISO defaults like every other text timestamp, and the app host
// must be an app of the agent's own project and environment.
// must be an app of the agent's own project and environment.
@@ -64,6 +64,25 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -64,6 +64,25 @@ const quiet = { log() {}, warn() {}, error() {} };
assert.deepStrictEqual([r.updated_at.length, r.status], [24, 'active']);
assert.deepStrictEqual([r.updated_at.length, r.status], [24, 'active']);
await assert.rejects(agent(`agt_${'6'.repeat(26)}`, other, 'sandbox'), /foreign key/i, 'an app of another project');
await assert.rejects(agent(`agt_${'6'.repeat(26)}`, other, 'sandbox'), /foreign key/i, 'an app of another project');
await assert.rejects(agent(`agt_${'7'.repeat(26)}`, prj, 'production'), /foreign key/i, 'an app of the other environment');
await assert.rejects(agent(`agt_${'7'.repeat(26)}`, prj, 'production'), /foreign key/i, 'an app of the other environment');
+
+ // Delegated grants (0017): one row per (agent, capability), of an existing agent, with a known mode and status,
+ // and a revoked row always says when.
+ const now = new Date().toISOString();
+ const grant = (g) => t.db.prepare(`INSERT INTO dev_agent_grants (agent_id, capability, audience, mode, status, granted_at, granted_by, updated_at, revoked_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'test', ?, ?)`).run(g.agent || `agt_${'5'.repeat(26)}`, g.capability || 'media.object.read', g.audience || 'openvibe.media',
+ g.mode || 'confirm', g.status || 'active', now, now, g.revoked_at === undefined ? null : g.revoked_at);
+ await grant({});
+ await assert.rejects(grant({}), /duplicate key|unique/i, 'one row per agent and capability');
+ await grant({ capability: 'media.object.upload', mode: 'auto', status: 'revoked', revoked_at: now });
+ await assert.rejects(grant({ agent: `agt_${'8'.repeat(26)}`, capability: 'media.object.list' }), /foreign key/i, 'an unknown agent');
+ await assert.rejects(grant({ capability: 'media.object.list', mode: 'always' }), /check constraint/i, 'an unknown mode');
+ await assert.rejects(grant({ capability: 'media.object.list', status: 'paused' }), /check constraint/i, 'an unknown status');
+ await assert.rejects(grant({ capability: 'media.object.list', status: 'revoked' }), /check constraint/i, 'revoked without revoked_at');
+ await assert.rejects(grant({ capability: 'media.object.list', revoked_at: now }), /check constraint/i, 'active with revoked_at');
+ await assert.rejects(grant({ capability: 'media.object.list', audience: 'media' }), /check constraint/i, 'an audience that is not openvibe.<service>');
+ const idx = (await t.db.prepare("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'dev_agent_grants' ORDER BY indexname").all());
+ assert.deepStrictEqual(idx.map((i) => i.indexname), ['dev_agent_grants_active_idx', 'dev_agent_grants_cap_idx', 'dev_agent_grants_pkey']);
+ assert.match(idx[0].indexdef, /WHERE \(?status = 'active'/);
console.log('agent schema: all tests passed');
console.log('agent schema: all tests passed');
} finally {
} finally {
await t.close();
await t.close();