feat(mam-api): finish per-project authz on the deferred routers

Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.

- recorders: param('id') resolves project + view baseline, requireRecorderEdit
  on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
  asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
  and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
  to write); also fixes the author bug (req.session.userId -> req.user.id, which
  was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
  registration) — TODO replaced with a rationale note

Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
  asset into a project the caller lacks edit on — now asserts edit on the
  override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
  could pull in (and via GET /:id leak signed proxy URLs for) assets from a
  project they can't access — now every clip asset must belong to the
  sequence's project; pre-transaction queries moved inside try/catch so a DB
  error returns 500 instead of hanging the request

- tests: recorders-access, sequences-access (incl. cross-project clip guard),
  comments-access (incl. author-id regression)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Zac 2026-05-30 03:48:02 +00:00
parent 0c3a4b625f
commit 2615143c6d
8 changed files with 429 additions and 43 deletions

View file

@ -1,5 +1,7 @@
// TODO(authz): per-project scoping not yet enforced. Capture sessions carry a // authz: intentionally any-logged-in (no per-project scoping). This is a thin
// project_id; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern. // 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'; import express from 'express';
const router = express.Router(); const router = express.Router();

View file

@ -1,16 +1,27 @@
// Asset-scoped comments for the Asset Detail page. // 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). // 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. // Express's :assetId param flows through from the parent mount.
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router({ mergeParams: true }); 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) { function rowToJson(r) {
return { return {
id: r.id, id: r.id,
@ -52,8 +63,9 @@ router.post('/', async (req, res, next) => {
if (!body || !String(body).trim()) { if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' }); return res.status(400).json({ error: 'body is required' });
} }
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on. // Author is the authenticated user (requireAuth sets req.user for both
const userId = req.session?.userId || null; // session and bearer auth, and the dev user when AUTH_ENABLED=false).
const userId = req.user?.id || null;
const ins = await pool.query( const ins = await pool.query(
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms) `INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)

View file

@ -1,8 +1,5 @@
// External media imports — currently YouTube only. // 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 // 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), // 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 // 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 { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router(); const router = express.Router();
@ -63,6 +61,8 @@ router.post('/youtube', async (req, res, next) => {
if (projCheck.rows.length === 0) { if (projCheck.rows.length === 0) {
return res.status(404).json({ error: 'Project not found' }); 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(); const assetId = uuidv4();

View file

@ -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 express from 'express';
import http from 'http'; import http from 'http';
import fs from 'fs'; import fs from 'fs';
@ -8,10 +6,34 @@ import dgram from 'dgram';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js'; import { getS3Bucket } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js'; import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); 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. // Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc. // Device index 0 → 7438, index 1 → 7439, etc.
@ -151,6 +173,17 @@ function pickRecorderFields(body) {
// parallel with a per-call timeout from `dockerApi`. // parallel with a per-call timeout from `dockerApi`.
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { 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(` const result = await pool.query(`
SELECT r.*, la.live_asset_id SELECT r.*, la.live_asset_id
FROM recorders r FROM recorders r
@ -164,8 +197,9 @@ router.get('/', async (req, res, next) => {
ORDER BY a.created_at DESC ORDER BY a.created_at DESC
LIMIT 1 LIMIT 1
) la ON TRUE ) la ON TRUE
${scopeClause}
ORDER BY r.created_at DESC ORDER BY r.created_at DESC
`); `, params);
const rows = result.rows; const rows = result.rows;
// Only inspect containers for recorders that actually claim to be recording. // 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' }); .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. // Defaults — written on insert so the DB row is always self-contained.
const defaults = { const defaults = {
source_config: {}, source_config: {},
@ -258,7 +297,7 @@ router.get('/:id', async (req, res, next) => {
// PATCH /:id - Edit recorder settings // PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift. // 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 { try {
const { id } = req.params; const { id } = req.params;
@ -297,7 +336,7 @@ router.patch('/:id', async (req, res, next) => {
}); });
// POST /:id/start - Start recording // POST /:id/start - Start recording
router.post('/:id/start', async (req, res, next) => { router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -347,6 +386,14 @@ router.post('/:id/start', async (req, res, next) => {
? req.body.projectId ? req.body.projectId
: recorder.project_id; : 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 // live-asset: create the asset row right now (status='live') so the
// library shows the recording while it is happening. // library shows the recording while it is happening.
const assetIdLive = uuidv4(); const assetIdLive = uuidv4();
@ -553,7 +600,7 @@ router.post('/:id/start', async (req, res, next) => {
}); });
// POST /:id/stop - Stop recording // POST /:id/stop - Stop recording
router.post('/:id/stop', async (req, res, next) => { router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -724,7 +771,7 @@ router.get('/:id/status', async (req, res, next) => {
}); });
// DELETE /:id - Delete recorder // DELETE /:id - Delete recorder
router.delete('/:id', async (req, res, next) => { router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;

View file

@ -1,10 +1,9 @@
// services/mam-api/src/routes/sequences.js // 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 express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js'; import { getSignedUrlForObject } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js'; import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess } from '../auth/authz.js';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
@ -21,7 +20,27 @@ const conformQueue = new Queue('conform', {
}); });
const router = express.Router(); 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 ──────────────────────────────────────────────────────────────── // ── Row mapper ────────────────────────────────────────────────────────────────
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a // node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
@ -126,6 +145,7 @@ router.get('/', async (req, res, next) => {
try { try {
const { project_id } = req.query; const { project_id } = req.query;
if (!project_id) return res.status(400).json({ error: 'project_id is required' }); 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( const r = await pool.query(
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`, `SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
[project_id] [project_id]
@ -145,6 +165,7 @@ router.post('/', async (req, res, next) => {
height = 1080, height = 1080,
} = req.body; } = req.body;
if (!project_id) return res.status(400).json({ error: 'project_id is required' }); 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( const r = await pool.query(
`INSERT INTO sequences (project_id, name, frame_rate, width, height) `INSERT INTO sequences (project_id, name, frame_rate, width, height)
VALUES ($1, $2, $3, $4, $5) RETURNING *`, VALUES ($1, $2, $3, $4, $5) RETURNING *`,
@ -190,7 +211,7 @@ router.get('/:id', async (req, res, next) => {
}); });
// ── PUT /:id update sequence metadata ────────────────────────────────────── // ── PUT /:id update sequence metadata ──────────────────────────────────────
router.put('/:id', async (req, res, next) => { router.put('/:id', requireSequenceEdit, async (req, res, next) => {
try { try {
const { name, frame_rate, width, height } = req.body; const { name, frame_rate, width, height } = req.body;
const updates = []; const updates = [];
@ -213,7 +234,7 @@ router.put('/:id', async (req, res, next) => {
}); });
// ── DELETE /:id ─────────────────────────────────────────────────────────────── // ── DELETE /:id ───────────────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => { router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
try { try {
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]); 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' }); if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
@ -222,12 +243,14 @@ router.delete('/:id', async (req, res, next) => {
}); });
// ── PUT /:id/clips full replace of clip array (single transaction) ────────── // ── PUT /:id/clips full replace of clip array (single transaction) ──────────
router.put('/:id/clips', async (req, res, next) => { router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
// Verify sequence exists first (before acquiring transaction client) const clips = Array.isArray(req.body) ? req.body : [];
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]); 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' }); if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const clips = Array.isArray(req.body) ? req.body : [];
for (const c of clips) { for (const c of clips) {
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' }); 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)) || if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
@ -239,8 +262,22 @@ router.put('/:id/clips', async (req, res, next) => {
} }
} }
const client = await pool.connect(); // Every referenced asset must belong to THIS sequence's project. Without this,
try { // 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('BEGIN');
await client.query( await client.query(
`DELETE FROM sequence_clips WHERE sequence_id = $1`, `DELETE FROM sequence_clips WHERE sequence_id = $1`,
@ -267,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => {
await client.query('COMMIT'); await client.query('COMMIT');
res.json({ ok: true, count: clips.length }); res.json({ ok: true, count: clips.length });
} catch (e) { } 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); next(e);
} finally { } 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 ───────────────────────── // ── POST /:id/conform conform sequence via FCP XML ─────────────────────────
// Accepts FCP XML content and encode settings from the Premiere plugin, // Accepts FCP XML content and encode settings from the Premiere plugin,
// queues a conform job in BullMQ, and returns the job ID for polling. // 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 { try {
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]); 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' }); if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });

View file

@ -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(); }
});

View file

@ -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(); }
});

View file

@ -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(); }
});