dragonflight/services/mam-api/test/auth/authz.test.js
Zac ec026195eb feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
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>
2026-05-30 02:37:36 +00:00

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