Adds per-project access control on top of the flat v1 auth. admin keeps global access; editor/viewer are scoped to projects granted to them (direct or via group) at view (read-only) or edit (read-write) level. - migration 026: project_access table + access_level enum - src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/ assertProjectAccess - requireAdmin middleware; admin-gate /users, /auth/users, /groups - enforce scoping on projects, assets, bins (list filter + per-resource view/edit + create checks); gate bulk asset maintenance + batch-trim - grant API: GET/POST/DELETE /projects/:id/access - web-ui: hide admin nav for non-admins, admin-route bounce, project "Manage access" modal, rewrite Policies tab - tests: authz, project-access, assets-access (node:test, skip w/o DB) - deferred routers carry TODO(authz) markers; .env.example documents the service-token-needs-admin/grants requirement Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
125 lines
5 KiB
JavaScript
125 lines
5 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
import {
|
|
isAdmin,
|
|
accessibleProjectIds,
|
|
projectLevel,
|
|
assertProjectAccess,
|
|
} from '../../src/auth/authz.js';
|
|
|
|
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
|
|
// ── isAdmin (pure, no DB) ───────────────────────────────────────────────────
|
|
test('isAdmin true only for role admin', () => {
|
|
assert.equal(isAdmin({ role: 'admin' }), true);
|
|
assert.equal(isAdmin({ role: 'editor' }), false);
|
|
assert.equal(isAdmin({ role: 'viewer' }), false);
|
|
assert.equal(isAdmin(null), false);
|
|
assert.equal(isAdmin(undefined), false);
|
|
});
|
|
|
|
// Seed helpers shared across the DB-backed cases.
|
|
async function seed(pool) {
|
|
const proj = async (name) =>
|
|
(await pool.query(
|
|
`INSERT INTO projects (name, s3_prefix) VALUES ($1, $1) RETURNING id`, [name]
|
|
)).rows[0].id;
|
|
const user = async (username, role) =>
|
|
(await pool.query(
|
|
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', $2) RETURNING id`,
|
|
[username, role]
|
|
)).rows[0].id;
|
|
const group = async (name) =>
|
|
(await pool.query(`INSERT INTO groups (name) VALUES ($1) RETURNING id`, [name])).rows[0].id;
|
|
const grantUser = (pid, uid, level) =>
|
|
pool.query(
|
|
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
|
|
VALUES ($1, 'user', $2, $3)`, [pid, uid, level]);
|
|
const grantGroup = (pid, gid, level) =>
|
|
pool.query(
|
|
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
|
|
VALUES ($1, 'group', $2, $3)`, [pid, gid, level]);
|
|
const addToGroup = (uid, gid) =>
|
|
pool.query(`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [uid, gid]);
|
|
return { proj, user, group, grantUser, grantGroup, addToGroup };
|
|
}
|
|
|
|
test('admin sees all projects, every project at edit', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
try {
|
|
const s = await seed(pool);
|
|
await s.proj('Alpha'); await s.proj('Beta');
|
|
const admin = { id: await s.user('adm', 'admin'), role: 'admin' };
|
|
|
|
const acc = await accessibleProjectIds(admin, pool);
|
|
assert.equal(acc.all, true);
|
|
assert.equal(await projectLevel(admin, '00000000-0000-4000-8000-000000000001', pool), 'edit');
|
|
await assertProjectAccess(admin, '00000000-0000-4000-8000-000000000001', 'edit', pool); // no throw
|
|
} finally { await pool.end(); }
|
|
});
|
|
|
|
test('direct user grant scopes access and respects level', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
try {
|
|
const s = await seed(pool);
|
|
const alpha = await s.proj('Alpha');
|
|
const beta = await s.proj('Beta');
|
|
const u = { id: await s.user('bob', 'editor'), role: 'editor' };
|
|
await s.grantUser(alpha, u.id, 'view');
|
|
|
|
const acc = await accessibleProjectIds(u, pool);
|
|
assert.equal(acc.all, false);
|
|
assert.deepEqual([...acc.ids], [alpha]);
|
|
assert.equal(await projectLevel(u, alpha, pool), 'view');
|
|
assert.equal(await projectLevel(u, beta, pool), null);
|
|
|
|
await assertProjectAccess(u, alpha, 'view', pool); // ok
|
|
await assert.rejects(() => assertProjectAccess(u, alpha, 'edit', pool), e => e.status === 403);
|
|
await assert.rejects(() => assertProjectAccess(u, beta, 'view', pool), e => e.status === 403);
|
|
} finally { await pool.end(); }
|
|
});
|
|
|
|
test('group grant flows through membership', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
try {
|
|
const s = await seed(pool);
|
|
const alpha = await s.proj('Alpha');
|
|
const u = { id: await s.user('carol', 'viewer'), role: 'viewer' };
|
|
const g = await s.group('broadcasters');
|
|
await s.addToGroup(u.id, g);
|
|
await s.grantGroup(alpha, g, 'edit');
|
|
|
|
assert.equal(await projectLevel(u, alpha, pool), 'edit');
|
|
const acc = await accessibleProjectIds(u, pool);
|
|
assert.deepEqual([...acc.ids], [alpha]);
|
|
await assertProjectAccess(u, alpha, 'edit', pool); // ok via group
|
|
} finally { await pool.end(); }
|
|
});
|
|
|
|
test('effective level is the max of direct + group grants', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
try {
|
|
const s = await seed(pool);
|
|
const alpha = await s.proj('Alpha');
|
|
const u = { id: await s.user('dan', 'editor'), role: 'editor' };
|
|
const g = await s.group('team');
|
|
await s.addToGroup(u.id, g);
|
|
await s.grantUser(alpha, u.id, 'view'); // direct: view
|
|
await s.grantGroup(alpha, g, 'edit'); // group: edit → wins
|
|
|
|
assert.equal(await projectLevel(u, alpha, pool), 'edit');
|
|
} finally { await pool.end(); }
|
|
});
|
|
|
|
test('user with no grants sees nothing', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
try {
|
|
const s = await seed(pool);
|
|
await s.proj('Alpha');
|
|
const u = { id: await s.user('eve', 'viewer'), role: 'viewer' };
|
|
|
|
const acc = await accessibleProjectIds(u, pool);
|
|
assert.equal(acc.ids.size, 0);
|
|
} finally { await pool.end(); }
|
|
});
|