// 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, levelByProject: Map } 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; } }