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
6 KiB
JavaScript
125 lines
6 KiB
JavaScript
// Integration test for per-project RBAC: the grant-management API on the
|
|
// projects router + scoped enforcement on GET /projects and GET /projects/:id.
|
|
//
|
|
// NOTE: the routers use the singleton pool (src/db/pool.js), which reads
|
|
// DATABASE_URL. We point DATABASE_URL at the throwaway TEST_DATABASE_URL and
|
|
// seed through that same singleton pool so the router and the test share one
|
|
// database. Skips cleanly when TEST_DATABASE_URL is unset.
|
|
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';
|
|
|
|
// Build an app that injects a chosen user as req.user (simulating requireAuth),
|
|
// then mounts the real projects router with the same admin gate index.js uses.
|
|
async function appWithProjects(user) {
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
process.env.AUTH_ENABLED = 'true';
|
|
const { default: projectsRouter } = await import('../../src/routes/projects.js?v=' + Date.now());
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.user = user; next(); });
|
|
app.use('/api/v1/projects', projectsRouter);
|
|
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 seedBaseline(pool) {
|
|
const alpha = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Alpha','alpha') RETURNING id`)).rows[0].id;
|
|
const beta = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Beta','beta') 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 ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
|
return { alpha, beta, admin, scoped };
|
|
}
|
|
|
|
test('admin grants a project, scoped user then sees only it; revoke removes access', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
try {
|
|
const { alpha, beta, admin, scoped } = await seedBaseline(pool);
|
|
|
|
// Scoped viewer initially sees nothing.
|
|
let s = await appWithProjects(scoped);
|
|
let list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
|
assert.equal(list.length, 0, 'scoped user should see no projects before any grant');
|
|
// And cannot read Alpha directly.
|
|
let direct = await fetch(s.baseUrl + '/api/v1/projects/' + alpha);
|
|
assert.equal(direct.status, 403);
|
|
await s.close();
|
|
|
|
// Admin grants the scoped user 'view' on Alpha.
|
|
const a = await appWithProjects(admin);
|
|
const grant = await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'view' }),
|
|
});
|
|
assert.equal(grant.status, 201);
|
|
const grantList = await (await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access')).json();
|
|
assert.equal(grantList.length, 1);
|
|
assert.equal(grantList[0].subject_id, scoped.id);
|
|
await a.close();
|
|
|
|
// Scoped viewer now sees exactly Alpha (not Beta), can GET it, cannot PATCH (view-only).
|
|
s = await appWithProjects(scoped);
|
|
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
|
assert.deepEqual(list.map(p => p.id), [alpha]);
|
|
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + alpha)).status, 200);
|
|
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + beta)).status, 403);
|
|
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ description: 'hacked' }),
|
|
});
|
|
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
|
|
await s.close();
|
|
|
|
// Admin revokes; scoped viewer goes back to seeing nothing.
|
|
const a2 = await appWithProjects(admin);
|
|
const del = await fetch(a2.baseUrl + '/api/v1/projects/' + alpha + '/access/user/' + scoped.id, { method: 'DELETE' });
|
|
assert.equal(del.status, 204);
|
|
await a2.close();
|
|
|
|
s = await appWithProjects(scoped);
|
|
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
|
assert.equal(list.length, 0);
|
|
await s.close();
|
|
} finally { await pool.end(); }
|
|
});
|
|
|
|
test('non-admin cannot reach the grant-management API', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
try {
|
|
const { alpha, scoped } = await seedBaseline(pool);
|
|
const s = await appWithProjects(scoped);
|
|
// requireAdmin sits on the access sub-routes; a viewer is 403.
|
|
const r = await fetch(s.baseUrl + '/api/v1/projects/' + alpha + '/access');
|
|
assert.equal(r.status, 403);
|
|
await s.close();
|
|
} finally { await pool.end(); }
|
|
});
|
|
|
|
test('edit-level grant allows PATCH', { skip: SKIP }, async () => {
|
|
const pool = await setupTestDb();
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
try {
|
|
const { alpha, admin, scoped } = await seedBaseline(pool);
|
|
const a = await appWithProjects(admin);
|
|
await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'edit' }),
|
|
});
|
|
await a.close();
|
|
|
|
const s = await appWithProjects(scoped);
|
|
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ description: 'updated by editor' }),
|
|
});
|
|
assert.equal(patch.status, 200);
|
|
await s.close();
|
|
} finally { await pool.end(); }
|
|
});
|