91 lines
3.3 KiB
JavaScript
91 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;
|
||
|
|
}
|
||
|
|
}
|