dragonflight/services/mam-api/src/auth/authz.js

91 lines
3.3 KiB
JavaScript
Raw Normal View History

// 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;
}
}