mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
feat(api): agency-token security primitive - off-ladder scope + agencyGate (#73)
The capability/target-restricted token model for the agency portal (#73 option B), proven before any endpoint sits on it: - 'agency' scope value is OFF the read/write/full ladder, so the existing tokenScopeGate rejects it on every public router by construction (auto-confinement, no new code). - api_token_targets join table: which playlists an agency token may act on. - agencyGate: THE single seam - agency-scope-only + (playlist in this token's allowlist AND in the bound workspace), one query enforcing target + cross-workspace isolation. - AGENCY_ROUTERS category in config/api-surface.js (mounted with agencyGate, not tokenScopeGate) - declared; router/mount land with the endpoints. Both bite-tested: spine (agency 403s on tokenScopeGate; read/write still pass) and the gate (non-designated/cross-workspace/non-agency/JWT -> 403; neutralizing the target check goes red). NARROW - not the general capability-scope system. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4c5865013
commit
c8a24d2243
|
|
@ -48,4 +48,13 @@ const JWT_ONLY_ROUTERS = [
|
||||||
{ path: '/api/tokens', mod: './routes/tokens', tenancy: true },
|
{ 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 };
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,8 @@ const migrations = [
|
||||||
"ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0",
|
"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 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)",
|
"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"
|
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||||
// error means the column is already present (expected on a migrated DB) - benign.
|
// error means the column is already present (expected on a migrated DB) - benign.
|
||||||
|
|
|
||||||
|
|
@ -530,12 +530,24 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
name TEXT NOT NULL, -- user-given label
|
name TEXT NOT NULL, -- user-given label
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
workspace_id TEXT NOT NULL REFERENCES workspaces(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')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
last_used_at INTEGER,
|
last_used_at INTEGER,
|
||||||
revoked_at INTEGER
|
revoked_at INTEGER
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
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);
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||||
|
|
||||||
-- ===================== SCHEMA MIGRATIONS =====================
|
-- ===================== SCHEMA MIGRATIONS =====================
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
module.exports = {
|
||||||
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope,
|
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate,
|
||||||
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
|
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
39
server/test/agency-gate.test.js
Normal file
39
server/test/agency-gate.test.js
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
32
server/test/agency-scope.test.js
Normal file
32
server/test/agency-scope.test.js
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue