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>
103 lines
5.5 KiB
JavaScript
103 lines
5.5 KiB
JavaScript
// 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(); }
|
|
});
|