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>
90 lines
3.3 KiB
JavaScript
90 lines
3.3 KiB
JavaScript
// Per-project authorization — the single source of truth for "can this user
|
|
// touch this project?". v1 auth answers "are you logged in?"; this answers
|
|
// "which projects, and at what level?".
|
|
//
|
|
// Model (locked with Zac):
|
|
// - role 'admin' → global bypass; every project at 'edit'.
|
|
// - role 'editor'/'viewer' → scoped to projects granted to them directly
|
|
// (project_access subject_type='user') or via a
|
|
// group they belong to (subject_type='group').
|
|
// - grant level 'view' → read-only; 'edit' → read-write.
|
|
//
|
|
// A user's effective level on a project is the MAX of every matching grant
|
|
// (direct + each group). 'edit' outranks 'view'.
|
|
//
|
|
// All functions take an optional `db` (defaults to the shared pool) so tests
|
|
// can inject an isolated test pool.
|
|
|
|
import defaultPool from '../db/pool.js';
|
|
|
|
const LEVEL_RANK = { view: 1, edit: 2 };
|
|
|
|
export function isAdmin(user) {
|
|
return user?.role === 'admin';
|
|
}
|
|
|
|
// Returns the higher of two levels (either may be null/undefined).
|
|
function maxLevel(a, b) {
|
|
const ra = LEVEL_RANK[a] || 0;
|
|
const rb = LEVEL_RANK[b] || 0;
|
|
if (ra === 0 && rb === 0) return null;
|
|
return ra >= rb ? a : b;
|
|
}
|
|
|
|
// Resolve every project the user can see, with their effective level.
|
|
// admin → { all: true, ids: null, levelByProject: null }
|
|
// else → { all: false, ids: Set<projectId>, levelByProject: Map<projectId, 'view'|'edit'> }
|
|
export async function accessibleProjectIds(user, db = defaultPool) {
|
|
if (isAdmin(user)) return { all: true, ids: null, levelByProject: null };
|
|
|
|
const levelByProject = new Map();
|
|
if (!user?.id) return { all: false, ids: new Set(), levelByProject };
|
|
|
|
const { rows } = await db.query(
|
|
`SELECT pa.project_id, pa.level
|
|
FROM project_access pa
|
|
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
|
|
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
|
SELECT group_id FROM user_groups WHERE user_id = $1
|
|
))`,
|
|
[user.id]
|
|
);
|
|
|
|
for (const r of rows) {
|
|
levelByProject.set(r.project_id, maxLevel(levelByProject.get(r.project_id), r.level));
|
|
}
|
|
return { all: false, ids: new Set(levelByProject.keys()), levelByProject };
|
|
}
|
|
|
|
// Effective level on a single project: 'edit' | 'view' | null.
|
|
export async function projectLevel(user, projectId, db = defaultPool) {
|
|
if (isAdmin(user)) return 'edit';
|
|
if (!user?.id || !projectId) return null;
|
|
|
|
const { rows } = await db.query(
|
|
`SELECT pa.level
|
|
FROM project_access pa
|
|
WHERE pa.project_id = $1
|
|
AND ( (pa.subject_type = 'user' AND pa.subject_id = $2)
|
|
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
|
SELECT group_id FROM user_groups WHERE user_id = $2
|
|
)) )`,
|
|
[projectId, user.id]
|
|
);
|
|
|
|
let level = null;
|
|
for (const r of rows) level = maxLevel(level, r.level);
|
|
return level;
|
|
}
|
|
|
|
// Throw a 403-shaped error (caught by errorHandler) unless the user has at
|
|
// least `need` access on the project. `need` ∈ 'view' | 'edit'.
|
|
export async function assertProjectAccess(user, projectId, need = 'view', db = defaultPool) {
|
|
if (isAdmin(user)) return;
|
|
const have = await projectLevel(user, projectId, db);
|
|
if (!have || (LEVEL_RANK[have] || 0) < (LEVEL_RANK[need] || 0)) {
|
|
const err = new Error('forbidden');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
}
|