diff --git a/services/mam-api/src/routes/capture.js b/services/mam-api/src/routes/capture.js index de97a96..f699d83 100644 --- a/services/mam-api/src/routes/capture.js +++ b/services/mam-api/src/routes/capture.js @@ -1,5 +1,7 @@ -// TODO(authz): per-project scoping not yet enforced. Capture sessions carry a -// project_id; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern. +// authz: intentionally any-logged-in (no per-project scoping). This is a thin +// proxy to shared capture hardware with no project_id of its own; the resulting +// asset is scoped when it's registered via the /assets route. Gated by the +// global requireAuth in index.js, like the rest of /api/v1. import express from 'express'; const router = express.Router(); diff --git a/services/mam-api/src/routes/comments.js b/services/mam-api/src/routes/comments.js index 1d657de..5a0cae1 100644 --- a/services/mam-api/src/routes/comments.js +++ b/services/mam-api/src/routes/comments.js @@ -1,16 +1,27 @@ // Asset-scoped comments for the Asset Detail page. // -// TODO(authz): per-project scoping not yet enforced. Comments hang off an asset -// (resolve project via the asset); adopt assertProjectAccess (auth/authz.js). -// // Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router). // Express's :assetId param flows through from the parent mount. import express from 'express'; import pool from '../db/pool.js'; +import { assertProjectAccess } from '../auth/authz.js'; const router = express.Router({ mergeParams: true }); +// Scope every comment route to the parent asset's project: resolve project_id +// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or +// unknown asset is a clean 404 before any access decision leaks its existence. +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +router.use(async (req, res, next) => { + try { + const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]); + if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); + await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view'); + next(); + } catch (err) { next(err); } +}); + function rowToJson(r) { return { id: r.id, @@ -52,8 +63,9 @@ router.post('/', async (req, res, next) => { if (!body || !String(body).trim()) { return res.status(400).json({ error: 'body is required' }); } - // Best-effort author lookup — pull from the session if AUTH_ENABLED is on. - const userId = req.session?.userId || null; + // Author is the authenticated user (requireAuth sets req.user for both + // session and bearer auth, and the dev user when AUTH_ENABLED=false). + const userId = req.user?.id || null; const ins = await pool.query( `INSERT INTO asset_comments (asset_id, user_id, body, frame_ms) diff --git a/services/mam-api/src/routes/imports.js b/services/mam-api/src/routes/imports.js index d802a46..eeab8c3 100644 --- a/services/mam-api/src/routes/imports.js +++ b/services/mam-api/src/routes/imports.js @@ -1,8 +1,5 @@ // External media imports — currently YouTube only. // -// TODO(authz): per-project scoping not yet enforced. Imports target a project_id; -// adopt assertProjectAccess (auth/authz.js) — see bins.js pattern. -// // The flow mirrors upload.js: create the asset row up front with a placeholder // filename (the worker fills in the real title once yt-dlp prints metadata), // then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the @@ -13,6 +10,7 @@ import express from 'express'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; import pool from '../db/pool.js'; +import { assertProjectAccess } from '../auth/authz.js'; const router = express.Router(); @@ -63,6 +61,8 @@ router.post('/youtube', async (req, res, next) => { if (projCheck.rows.length === 0) { return res.status(404).json({ error: 'Project not found' }); } + // Importing writes an asset into the project — require edit access. + await assertProjectAccess(req.user, projectId, 'edit'); const assetId = uuidv4(); diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index bbf0967..8367500 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -1,5 +1,3 @@ -// TODO(authz): per-project scoping not yet enforced. Recorders carry project_id; -// adopt assertProjectAccess/accessibleProjectIds (auth/authz.js) — see bins.js. import express from 'express'; import http from 'http'; import fs from 'fs'; @@ -8,10 +6,34 @@ import dgram from 'dgram'; import pool from '../db/pool.js'; import { getS3Bucket } from '../s3/client.js'; import { validateUuid } from '../middleware/errors.js'; +import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); -router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); + +// Every /:id recorder route is scoped to the recorder's project. The param +// handler validates the UUID, resolves the owning project_id, and asserts the +// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit. +// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess +// throws 403 for non-admins on a null project). +router.param('id', async (req, res, next) => { + validateUuid('id')(req, res, () => {}); + if (res.headersSent) return; + try { + const { rows } = await pool.query('SELECT project_id FROM recorders WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' }); + req.recorderProjectId = rows[0].project_id; + await assertProjectAccess(req.user, req.recorderProjectId, 'view'); + next(); + } catch (err) { next(err); } +}); + +async function requireRecorderEdit(req, res, next) { + try { + await assertProjectAccess(req.user, req.recorderProjectId, 'edit'); + next(); + } catch (err) { next(err); } +} // Base port for on-demand SDI sidecar containers on remote worker nodes. // Device index 0 → 7438, index 1 → 7439, etc. @@ -151,6 +173,17 @@ function pickRecorderFields(body) { // parallel with a per-call timeout from `dockerApi`. router.get('/', async (req, res, next) => { try { + // Scope to recorders in projects the caller can access (admins unfiltered). + // Recorders with a NULL project are admin-only and never appear for scoped + // users (accessibleProjectIds never yields a null id). + const access = await accessibleProjectIds(req.user); + let scopeClause = ''; + const params = []; + if (!access.all) { + if (access.ids.size === 0) return res.json([]); + scopeClause = 'WHERE r.project_id = ANY($1::uuid[])'; + params.push([...access.ids]); + } const result = await pool.query(` SELECT r.*, la.live_asset_id FROM recorders r @@ -164,8 +197,9 @@ router.get('/', async (req, res, next) => { ORDER BY a.created_at DESC LIMIT 1 ) la ON TRUE + ${scopeClause} ORDER BY r.created_at DESC - `); + `, params); const rows = result.rows; // Only inspect containers for recorders that actually claim to be recording. @@ -196,6 +230,11 @@ router.post('/', async (req, res, next) => { .json({ error: 'Name and source_type are required' }); } + // Creating a recorder writes into a project — require edit there. A recorder + // with no project_id is admin-only (assertProjectAccess denies non-admins on + // a null project). + await assertProjectAccess(req.user, fields.project_id ?? null, 'edit'); + // Defaults — written on insert so the DB row is always self-contained. const defaults = { source_config: {}, @@ -258,7 +297,7 @@ router.get('/:id', async (req, res, next) => { // PATCH /:id - Edit recorder settings // Blocked while recorder is actively recording to prevent config drift. -router.patch('/:id', async (req, res, next) => { +router.patch('/:id', requireRecorderEdit, async (req, res, next) => { try { const { id } = req.params; @@ -297,7 +336,7 @@ router.patch('/:id', async (req, res, next) => { }); // POST /:id/start - Start recording -router.post('/:id/start', async (req, res, next) => { +router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { try { const { id } = req.params; @@ -347,6 +386,14 @@ router.post('/:id/start', async (req, res, next) => { ? req.body.projectId : recorder.project_id; + // requireRecorderEdit only covered the recorder's own project. If this take + // is being routed into a DIFFERENT project, the caller must have edit there + // too — otherwise edit on recorder A's project would let them write live + // assets into any project B. + if (takeProjectId !== recorder.project_id) { + await assertProjectAccess(req.user, takeProjectId, 'edit'); + } + // live-asset: create the asset row right now (status='live') so the // library shows the recording while it is happening. const assetIdLive = uuidv4(); @@ -553,7 +600,7 @@ router.post('/:id/start', async (req, res, next) => { }); // POST /:id/stop - Stop recording -router.post('/:id/stop', async (req, res, next) => { +router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => { try { const { id } = req.params; @@ -724,7 +771,7 @@ router.get('/:id/status', async (req, res, next) => { }); // DELETE /:id - Delete recorder -router.delete('/:id', async (req, res, next) => { +router.delete('/:id', requireRecorderEdit, async (req, res, next) => { try { const { id } = req.params; diff --git a/services/mam-api/src/routes/sequences.js b/services/mam-api/src/routes/sequences.js index 6233a76..76041a5 100644 --- a/services/mam-api/src/routes/sequences.js +++ b/services/mam-api/src/routes/sequences.js @@ -1,10 +1,9 @@ // services/mam-api/src/routes/sequences.js -// TODO(authz): per-project scoping not yet enforced. Sequences belong to a -// project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern. import express from 'express'; import pool from '../db/pool.js'; import { getSignedUrlForObject } from '../s3/client.js'; import { validateUuid } from '../middleware/errors.js'; +import { assertProjectAccess } from '../auth/authz.js'; import { Queue } from 'bullmq'; const parseRedisUrl = (url) => { @@ -21,7 +20,27 @@ const conformQueue = new Queue('conform', { }); const router = express.Router(); -router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); + +// Scope every /:id sequence route to its project: validate the UUID, resolve +// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit. +router.param('id', async (req, res, next) => { + validateUuid('id')(req, res, () => {}); + if (res.headersSent) return; + try { + const { rows } = await pool.query('SELECT project_id FROM sequences WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' }); + req.sequenceProjectId = rows[0].project_id; + await assertProjectAccess(req.user, req.sequenceProjectId, 'view'); + next(); + } catch (err) { next(err); } +}); + +async function requireSequenceEdit(req, res, next) { + try { + await assertProjectAccess(req.user, req.sequenceProjectId, 'edit'); + next(); + } catch (err) { next(err); } +} // ── Row mapper ──────────────────────────────────────────────────────────────── // node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a @@ -126,6 +145,7 @@ router.get('/', async (req, res, next) => { try { const { project_id } = req.query; if (!project_id) return res.status(400).json({ error: 'project_id is required' }); + await assertProjectAccess(req.user, project_id, 'view'); const r = await pool.query( `SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`, [project_id] @@ -145,6 +165,7 @@ router.post('/', async (req, res, next) => { height = 1080, } = req.body; if (!project_id) return res.status(400).json({ error: 'project_id is required' }); + await assertProjectAccess(req.user, project_id, 'edit'); const r = await pool.query( `INSERT INTO sequences (project_id, name, frame_rate, width, height) VALUES ($1, $2, $3, $4, $5) RETURNING *`, @@ -190,7 +211,7 @@ router.get('/:id', async (req, res, next) => { }); // ── PUT /:id – update sequence metadata ────────────────────────────────────── -router.put('/:id', async (req, res, next) => { +router.put('/:id', requireSequenceEdit, async (req, res, next) => { try { const { name, frame_rate, width, height } = req.body; const updates = []; @@ -213,7 +234,7 @@ router.put('/:id', async (req, res, next) => { }); // ── DELETE /:id ─────────────────────────────────────────────────────────────── -router.delete('/:id', async (req, res, next) => { +router.delete('/:id', requireSequenceEdit, async (req, res, next) => { try { const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]); if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' }); @@ -222,25 +243,41 @@ router.delete('/:id', async (req, res, next) => { }); // ── PUT /:id/clips – full replace of clip array (single transaction) ────────── -router.put('/:id/clips', async (req, res, next) => { - // Verify sequence exists first (before acquiring transaction client) - const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]); - if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' }); - +router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => { const clips = Array.isArray(req.body) ? req.body : []; - for (const c of clips) { - if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' }); - if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) || - !Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) { - return res.status(400).json({ error: 'Clip frame fields must be finite numbers' }); - } - if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) { - return res.status(400).json({ error: 'Clip track must be a non-negative integer' }); - } - } - - const client = await pool.connect(); + let client; try { + // Verify sequence exists first (before acquiring transaction client). + const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]); + if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' }); + + for (const c of clips) { + if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' }); + if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) || + !Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) { + return res.status(400).json({ error: 'Clip frame fields must be finite numbers' }); + } + if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) { + return res.status(400).json({ error: 'Clip track must be a non-negative integer' }); + } + } + + // Every referenced asset must belong to THIS sequence's project. Without this, + // a user with edit on the sequence could splice in assets from a project they + // can't access — and GET /:id would then hand back those assets' names and + // signed proxy URLs (cross-project leak). + const assetIds = [...new Set(clips.map(c => c.asset_id))]; + if (assetIds.length) { + const owning = await pool.query( + `SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`, + [assetIds, req.sequenceProjectId] + ); + if (owning.rows.length !== assetIds.length) { + return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' }); + } + } + + client = await pool.connect(); await client.query('BEGIN'); await client.query( `DELETE FROM sequence_clips WHERE sequence_id = $1`, @@ -267,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => { await client.query('COMMIT'); res.json({ ok: true, count: clips.length }); } catch (e) { - await client.query('ROLLBACK'); + // client is only set once we've connected; a failure in the pre-transaction + // queries (existence/validation/ownership) has no transaction to roll back. + if (client) await client.query('ROLLBACK').catch(() => {}); next(e); } finally { - client.release(); + if (client) client.release(); } }); @@ -302,7 +341,7 @@ router.post('/:id/export/edl', async (req, res, next) => { // ── POST /:id/conform – conform sequence via FCP XML ───────────────────────── // Accepts FCP XML content and encode settings from the Premiere plugin, // queues a conform job in BullMQ, and returns the job ID for polling. -router.post('/:id/conform', async (req, res, next) => { +router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => { try { const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]); if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' }); diff --git a/services/mam-api/test/routes/comments-access.test.js b/services/mam-api/test/routes/comments-access.test.js new file mode 100644 index 0000000..d056760 --- /dev/null +++ b/services/mam-api/test/routes/comments-access.test.js @@ -0,0 +1,76 @@ +// RBAC coverage for asset comments: the guard resolves the project via the +// asset, requiring view to read and edit to write. Also verifies the author id +// is recorded from req.user (regression for the old req.session.userId bug). +// Skips without TEST_DATABASE_URL. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; + +const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; + +async function appWithComments(user) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const { default: commentsRouter } = await import('../../src/routes/comments.js?v=' + Date.now()); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.user = user; next(); }); + // Mirror index.js mount so :assetId flows through (mergeParams in the router). + app.use('/api/v1/assets/:assetId/comments', commentsRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) })); + }); +} + +async function seed(pool) { + const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id; + const alpha = await proj('Alpha'); + const beta = await proj('Beta'); + const asset = async (pid, name) => (await pool.query( + `INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name])).rows[0].id; + const assetA = await asset(alpha, 'a1'); + const assetB = await asset(beta, 'b1'); + const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' }; + const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' }; + await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]); + await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]); + return { assetA, assetB, viewer, editor }; +} + +test('comments require view to read and block ungranted assets', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { assetA, assetB, viewer } = await seed(pool); + const s = await appWithComments(viewer); + assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments')).status, 200); + assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetB + '/comments')).status, 403); + await s.close(); + } finally { await pool.end(); } +}); + +test('posting a comment requires edit and records the author id from req.user', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { assetA, viewer, editor } = await seed(pool); + + // viewer (view-only) cannot post. + let s = await appWithComments(viewer); + let r = await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'hi' }) }); + assert.equal(r.status, 403); + await s.close(); + + // editor can post, and the author id is the editor (not null). + let e = await appWithComments(editor); + r = await fetch(e.baseUrl + '/api/v1/assets/' + assetA + '/comments', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'looks good' }) }); + assert.equal(r.status, 201); + const created = await r.json(); + assert.equal(created.user_id, editor.id, 'comment author must be req.user.id'); + await e.close(); + } finally { await pool.end(); } +}); diff --git a/services/mam-api/test/routes/recorders-access.test.js b/services/mam-api/test/routes/recorders-access.test.js new file mode 100644 index 0000000..ee143ba --- /dev/null +++ b/services/mam-api/test/routes/recorders-access.test.js @@ -0,0 +1,107 @@ +// RBAC coverage for recorders: list is scoped to accessible projects, /:id +// asserts view, mutators assert edit, null-project recorders are admin-only. +// Same harness as assets-access.test.js — singleton pool on TEST_DATABASE_URL, +// req.user injected, router dynamic-imported. Skips without TEST_DATABASE_URL. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; + +const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; + +async function appWithRecorders(user) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const { default: recordersRouter } = await import('../../src/routes/recorders.js?v=' + Date.now()); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.user = user; next(); }); + app.use('/api/v1/recorders', recordersRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) })); + }); +} + +async function seed(pool) { + const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id; + const alpha = await proj('Alpha'); + const beta = await proj('Beta'); + const rec = async (pid, name) => (await pool.query( + `INSERT INTO recorders (name, source_type, project_id) VALUES ($1, 'srt', $2) RETURNING id`, [name, pid])).rows[0].id; + const recA = await rec(alpha, 'Cam A'); + const recB = await rec(beta, 'Cam B'); + const recNull = (await pool.query( + `INSERT INTO recorders (name, source_type, project_id) VALUES ('Unassigned','srt',NULL) RETURNING id`)).rows[0].id; + const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' }; + const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' }; + await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, scoped.id]); + return { alpha, beta, recA, recB, recNull, admin, scoped }; +} + +test('recorders list is scoped; admin sees all incl. null-project, scoped sees only granted', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { recA, admin, scoped } = await seed(pool); + + let a = await appWithRecorders(admin); + let list = await (await fetch(a.baseUrl + '/api/v1/recorders')).json(); + assert.equal(list.length, 3, 'admin sees all three recorders'); + await a.close(); + + let s = await appWithRecorders(scoped); + list = await (await fetch(s.baseUrl + '/api/v1/recorders')).json(); + assert.deepEqual(list.map(r => r.id), [recA], 'scoped editor sees only the granted-project recorder'); + await s.close(); + } finally { await pool.end(); } +}); + +test('recorder /:id enforces view; mutators enforce edit', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { recA, recB, recNull, scoped } = await seed(pool); + const s = await appWithRecorders(scoped); + + // view-granted on Alpha: can GET recA, cannot GET recB (other project) or recNull (admin-only). + assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recA)).status, 200); + assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recB)).status, 403); + assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recNull)).status, 403); + + // view-only grant cannot PATCH (edit) or start. + const patch = await fetch(s.baseUrl + '/api/v1/recorders/' + recA, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) }); + assert.equal(patch.status, 403, 'view-level grant must not allow edit'); + await s.close(); + } finally { await pool.end(); } +}); + +test('creating a recorder requires edit; null-project create is admin-only', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { alpha, admin, scoped } = await seed(pool); + + // scoped editor has only 'view' on Alpha → create denied. + let s = await appWithRecorders(scoped); + let r = await fetch(s.baseUrl + '/api/v1/recorders', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New', source_type: 'srt', project_id: alpha }) }); + assert.equal(r.status, 403); + // null project → admin-only, still denied for the editor. + r = await fetch(s.baseUrl + '/api/v1/recorders', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New2', source_type: 'srt' }) }); + assert.equal(r.status, 403); + await s.close(); + + // admin can create in any project and with no project. + let a = await appWithRecorders(admin); + r = await fetch(a.baseUrl + '/api/v1/recorders', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'AdminRec', source_type: 'srt' }) }); + assert.equal(r.status, 201); + await a.close(); + } finally { await pool.end(); } +}); diff --git a/services/mam-api/test/routes/sequences-access.test.js b/services/mam-api/test/routes/sequences-access.test.js new file mode 100644 index 0000000..6849977 --- /dev/null +++ b/services/mam-api/test/routes/sequences-access.test.js @@ -0,0 +1,103 @@ +// RBAC coverage for sequences: list/create assert on the query/body project, +// /:id asserts view, mutators assert edit. Skips without TEST_DATABASE_URL. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; + +const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; + +async function appWithSequences(user) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const { default: sequencesRouter } = await import('../../src/routes/sequences.js?v=' + Date.now()); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.user = user; next(); }); + app.use('/api/v1/sequences', sequencesRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) })); + }); +} + +async function seed(pool) { + const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id; + const alpha = await proj('Alpha'); + const beta = await proj('Beta'); + const seqB = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'B seq') RETURNING id`, [beta])).rows[0].id; + const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' }; + const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' }; + await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]); + await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]); + return { alpha, beta, seqB, viewer, editor }; +} + +const asset = (pool, pid, name) => pool.query( + `INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, + [pid, name]).then(r => r.rows[0].id); + +test('GET /sequences?project_id 403s on an ungranted project', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { alpha, beta, viewer } = await seed(pool); + const s = await appWithSequences(viewer); + assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + alpha)).status, 200); + assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + beta)).status, 403); + await s.close(); + } finally { await pool.end(); } +}); + +test('POST /sequences requires edit; viewer denied, editor allowed; /:id view enforced', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { alpha, seqB, viewer, editor } = await seed(pool); + + // viewer (view-only on Alpha) cannot create. + let s = await appWithSequences(viewer); + let r = await fetch(s.baseUrl + '/api/v1/sequences', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'X' }) }); + assert.equal(r.status, 403); + // viewer cannot read a sequence in an ungranted project (Beta). + assert.equal((await fetch(s.baseUrl + '/api/v1/sequences/' + seqB)).status, 403); + await s.close(); + + // editor can create in Alpha and then PUT it. + let e = await appWithSequences(editor); + r = await fetch(e.baseUrl + '/api/v1/sequences', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'Mine' }) }); + assert.equal(r.status, 201); + const seqId = (await r.json()).id; + const put = await fetch(e.baseUrl + '/api/v1/sequences/' + seqId, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Renamed' }) }); + assert.equal(put.status, 200); + await e.close(); + } finally { await pool.end(); } +}); + +test('PUT /:id/clips rejects assets from outside the sequence project (cross-project leak guard)', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const { alpha, beta, editor } = await seed(pool); + const seqA = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'A seq') RETURNING id`, [alpha])).rows[0].id; + const assetA = await asset(pool, alpha, 'a-clip'); + const assetB = await asset(pool, beta, 'b-clip'); // editor has NO access to project Beta + + const e = await appWithSequences(editor); + const clip = (aid) => ({ asset_id: aid, track: 0, timeline_in_frames: 0, timeline_out_frames: 10, source_in_frames: 0, source_out_frames: 10 }); + + // Same-project asset is accepted. + let r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA)]) }); + assert.equal(r.status, 200); + + // Splicing in a Beta asset must be rejected — it would leak B's media via GET /:id. + r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA), clip(assetB)]) }); + assert.equal(r.status, 400, 'foreign-project asset must be rejected'); + await e.close(); + } finally { await pool.end(); } +});