diff --git a/server/config/api-surface.js b/server/config/api-surface.js index 9af5c93..d67485d 100644 --- a/server/config/api-surface.js +++ b/server/config/api-surface.js @@ -48,4 +48,13 @@ const JWT_ONLY_ROUTERS = [ { path: '/api/tokens', mod: './routes/tokens', tenancy: true }, ]; -module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS }; +// #73: AGENCY_ROUTERS - capability-restricted ('agency' scope) surface. Mounted with +// bearerAuth + resolveTenancy + agencyGate (NOT tokenScopeGate). An 'agency' token is +// OFF the read/write/full ladder, so tokenScopeGate rejects it on every PUBLIC_ROUTER - +// it can reach ONLY this router, and only its allowlisted playlists in its bound +// workspace (agencyGate enforces both). read/write/full tokens and JWTs are rejected here. +const AGENCY_ROUTERS = [ + { path: '/api/agency', mod: './routes/agency' }, +]; + +module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS }; diff --git a/server/db/database.js b/server/db/database.js index 192dc5f..6e121c5 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -193,6 +193,8 @@ const migrations = [ "ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0", "CREATE TABLE IF NOT EXISTS totp_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, code_hash TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), used_at INTEGER)", "CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)", + // #73: agency-token target allowlist (capability-restricted tokens). + "CREATE TABLE IF NOT EXISTS api_token_targets (token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index de5d2f5..838ad64 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -530,12 +530,24 @@ CREATE TABLE IF NOT EXISTS api_tokens ( name TEXT NOT NULL, -- user-given label user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' + scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency' created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_used_at INTEGER, revoked_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + +-- #73: target allowlist for capability-restricted ('agency') tokens. An agency token +-- (scope='agency', OFF the read/write/full ladder so tokenScopeGate rejects it on every +-- other router) may act ONLY on the playlists listed here, enforced at the single +-- agencyGate seam. FK cascade both ways: revoke the token or delete the playlist and the +-- grant disappears. +CREATE TABLE IF NOT EXISTS api_token_targets ( + token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (token_id, playlist_id) +); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); -- ===================== SCHEMA MIGRATIONS ===================== diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index e747e27..a9ca1dc 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -112,7 +112,31 @@ function requireScope(need) { }; } +// #73: THE single seam for capability-restricted ('agency') tokens. Mounted on the +// AGENCY_ROUTER (config/api-surface.js) in place of tokenScopeGate. Two checks, no more: +// (1) only an agency token passes (a JWT or read/write/full token is rejected); +// (2) if the request targets a playlist, that playlist must be in THIS token's +// allowlist AND in the token's bound workspace - one query enforces both the +// target restriction and cross-workspace isolation. +// Every agency capability route passes through here, so the whole primitive is proven +// at one place. Removing the api_token_targets condition makes the bite-test go red. +function agencyGate(req, res, next) { + if (!req.viaToken || req.tokenScope !== 'agency') { + return res.status(403).json({ error: 'agency token required' }); + } + const playlistId = req.params.playlistId || (req.body && req.body.playlist_id); + if (playlistId) { + const ok = db.prepare(` + SELECT 1 FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id + WHERE t.token_id = ? AND t.playlist_id = ? AND p.workspace_id = ? + `).get(req.apiToken.id, playlistId, req.jwtWorkspaceId); + if (!ok) return res.status(403).json({ error: 'playlist not in this agency token\'s allowlist' }); + } + next(); +} + module.exports = { - bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, + bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate, hashToken, generateToken, displayPrefix, TOKEN_PREFIX, }; diff --git a/server/test/agency-gate.test.js b/server/test/agency-gate.test.js new file mode 100644 index 0000000..9a2e7dc --- /dev/null +++ b/server/test/agency-gate.test.js @@ -0,0 +1,39 @@ +'use strict'; + +// #73 THE SEAM: agencyGate is the single place capability+target restriction is enforced. +// Prove it confines before any endpoint is built behind it. Removing the api_token_targets +// condition in agencyGate makes "non-designated -> 403" go red (the bite). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +const mem = new Database(':memory:'); +mem.exec(` + CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT, PRIMARY KEY(token_id, playlist_id)); + CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT); + INSERT INTO playlists (id, workspace_id) VALUES ('plA','wsA'), ('plB','wsA'), ('plC','wsB'); + INSERT INTO api_token_targets (token_id, playlist_id) VALUES ('tok1','plA'), ('tok1','plC'); +`); // tok1 is allowlisted for plA (wsA, its bound ws) and plC (wsB, a DIFFERENT ws) +require.cache[require.resolve('../db/database')] = { + id: require.resolve('../db/database'), loaded: true, exports: { db: mem }, +}; +const { agencyGate } = require('../middleware/apiToken'); + +function gate(over = {}) { + const req = { viaToken: true, tokenScope: 'agency', apiToken: { id: 'tok1' }, jwtWorkspaceId: 'wsA', params: {}, body: {}, ...over }; + let status = 200, nexted = false; + const res = { status(s) { status = s; return this; }, json() { return this; } }; + agencyGate(req, res, () => { nexted = true; }); + return { status, nexted }; +} + +test('#73 agencyGate: only agency tokens, only allowlisted playlists, only the bound workspace', () => { + assert.equal(gate({ params: { playlistId: 'plA' } }).nexted, true, 'designated playlist in bound ws -> passes'); + assert.equal(gate({ params: { playlistId: 'plB' } }).status, 403, 'NON-designated playlist -> 403 (target restriction)'); + assert.equal(gate({ params: { playlistId: 'plC' } }).status, 403, 'designated but CROSS-workspace -> 403'); + assert.equal(gate({ tokenScope: 'write', params: { playlistId: 'plA' } }).status, 403, 'non-agency token -> 403'); + assert.equal(gate({ viaToken: false, params: { playlistId: 'plA' } }).status, 403, 'JWT -> 403'); + // body.playlist_id is honored too (create-item path), so the seam covers both routes + assert.equal(gate({ body: { playlist_id: 'plB' } }).status, 403, 'non-designated via body -> 403'); +}); diff --git a/server/test/agency-scope.test.js b/server/test/agency-scope.test.js new file mode 100644 index 0000000..1c6dcb5 --- /dev/null +++ b/server/test/agency-scope.test.js @@ -0,0 +1,32 @@ +'use strict'; + +// #73 SPINE: an 'agency' scope is OFF the read/write/full ladder, so the EXISTING +// tokenScopeGate rejects it on every router by construction (auto-confinement). This is +// the foundation the whole model rests on - prove it before building anything on top. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +// tokenScopeGate is pure (no db), but requiring the module loads db/database - inject one. +require.cache[require.resolve('../db/database')] = { + id: require.resolve('../db/database'), loaded: true, exports: { db: new Database(':memory:') }, +}; +const { tokenScopeGate } = require('../middleware/apiToken'); + +function run(scope, method) { + const req = { viaToken: true, tokenScope: scope, method }; + let status = 200, nexted = false; + const res = { status(s) { status = s; return this; }, json() { return this; } }; + tokenScopeGate(req, res, () => { nexted = true; }); + return { status, nexted }; +} + +test('#73 spine: agency scope auto-fails tokenScopeGate everywhere (off-ladder)', () => { + assert.equal(run('agency', 'GET').status, 403, 'agency cannot read on a normal router'); + assert.equal(run('agency', 'POST').status, 403, 'agency cannot write on a normal router'); + assert.equal(run('agency', 'GET').nexted, false, 'agency never reaches the handler'); + // Contrast: normal scopes still pass - the gate isn't just rejecting everything. + assert.equal(run('write', 'POST').nexted, true, 'write still passes write'); + assert.equal(run('read', 'GET').nexted, true, 'read still passes read'); +});