feat(mam-api,web-ui): per-project RBAC (v2 auth layer)

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>
This commit is contained in:
Zac 2026-05-30 02:37:36 +00:00
parent 9d6bbf8112
commit ec026195eb
20 changed files with 879 additions and 58 deletions

View file

@ -25,6 +25,13 @@ MAM_API_URL=http://mam-api:3000
# Auth — default to ON in production. Setting to 'false' is a dev-only escape # Auth — default to ON in production. Setting to 'false' is a dev-only escape
# hatch that disables all auth checks and attaches a synthetic 'dev' user to # hatch that disables all auth checks and attaches a synthetic 'dev' user to
# every request. Never run with AUTH_ENABLED=false on a network you don't control. # every request. Never run with AUTH_ENABLED=false on a network you don't control.
#
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
# user with the access they need — an 'admin' user (full access), or a user with
# the right project grants. A non-admin service token with no grants will get
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
# user is admin, so this only matters once auth is on.
AUTH_ENABLED=true AUTH_ENABLED=true
# CORS allowlist — comma-separated origins that may carry credentials to the API. # CORS allowlist — comma-separated origins that may carry credentials to the API.

View file

@ -0,0 +1,90 @@
// 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;
}
}

View file

@ -0,0 +1,30 @@
-- Migration 026 — per-project access grants (RBAC v2).
--
-- v1 auth is flat: any logged-in user can do everything. This adds per-project
-- scoping. A grant targets either a user or a group (polymorphic subject) and
-- carries a level: 'view' (read-only) or 'edit' (read-write). Admins bypass all
-- of this in code (authz.js) and need no rows here.
--
-- subject_id is intentionally NOT a foreign key — it points at either users.id
-- or groups.id depending on subject_type. Rows are cleaned up when the project
-- is deleted (FK cascade). A deleted user/group leaves an orphan row that
-- resolves to nobody (harmless); a later sweep can prune them if desired.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
CREATE TYPE access_level AS ENUM ('view', 'edit');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS project_access (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
subject_id UUID NOT NULL,
level access_level NOT NULL DEFAULT 'view',
granted_by UUID REFERENCES users ON DELETE SET NULL,
granted_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, subject_type, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_project_access_subject
ON project_access (subject_type, subject_id);

View file

@ -8,7 +8,7 @@ import os from 'node:os';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import pool from './db/pool.js'; import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js'; import { errorHandler } from './middleware/errors.js';
import { requireAuth, requireUiHeader } from './middleware/auth.js'; import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js'; import { loadS3ConfigFromDb } from './s3/client.js';
import authRouter from './routes/auth.js'; import authRouter from './routes/auth.js';
@ -117,8 +117,10 @@ app.use('/api/v1', (req, res, next) => {
// ── API Routes ──────────────────────────────────────────────────────────────── // ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter); app.use('/api/v1/auth', authRouter);
app.use('/api/v1/auth/users', usersRouter); // User and group administration is admin-only (RBAC v2). The auth gate above
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate // already established req.user; requireAdmin rejects non-admins with 403.
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter); // alias for the SPA Users page
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter); app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/projects', projectsRouter);
@ -129,7 +131,7 @@ app.use('/api/v1/upload', uploadRouter);
app.use('/api/v1/recorders', recordersRouter); app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings', settingsRouter); app.use('/api/v1/settings', settingsRouter);
app.use('/api/v1/ampp', amppRouter); app.use('/api/v1/ampp', amppRouter);
app.use('/api/v1/groups', groupsRouter); app.use('/api/v1/groups', requireAdmin, groupsRouter);
app.use('/api/v1/sequences', sequencesRouter); app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system', systemRouter); app.use('/api/v1/system', systemRouter);
app.use('/api/v1/cluster', clusterRouter); app.use('/api/v1/cluster', clusterRouter);

View file

@ -4,7 +4,9 @@ import { parseBearer, hashToken } from '../auth/tokens.js';
// Stable UUID matching migration 023's seeded dev user. // Stable UUID matching migration 023's seeded dev user.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */ /** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000'; export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' }; // role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
// RBAC v2 gates — matches migration 023's seeded dev row.
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
const ABSOLUTE_MS = 8 * 3600 * 1000; const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS = 1 * 3600 * 1000; const IDLE_MS = 1 * 3600 * 1000;
@ -73,6 +75,14 @@ export async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'unauthorized' }); return res.status(401).json({ error: 'unauthorized' });
} }
// Gate a route to admins only. requireAuth must run first (it sets req.user).
// 401 when unauthenticated, 403 when authenticated but not an admin.
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
return next();
}
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site // Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens // cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only. // against the edge cases. Applied to mutating verbs only.

View file

@ -7,9 +7,36 @@ import pool from '../db/pool.js';
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js'; import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { validateUuid } from '../middleware/errors.js'; import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { requireAdmin } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Every /:id asset route is scoped to the asset's project. The param handler
// validates the UUID, resolves the owning project_id, and asserts at least
// 'view' access (the baseline for touching an asset at all). Mutating routes
// additionally assert 'edit' via req.assetProjectId. A missing asset is a clean
// 404 here rather than leaking existence to users without access.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
req.assetProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.assetProjectId, 'view');
next();
} catch (err) { next(err); }
});
// Route-level guard for mutating /:id endpoints — escalates the param handler's
// 'view' baseline to 'edit'. Reuses req.assetProjectId (already resolved).
async function requireAssetEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.assetProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// BullMQ queue connection (mirrors worker/src/index.js) // BullMQ queue connection (mirrors worker/src/index.js)
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
@ -66,6 +93,15 @@ router.get('/', async (req, res, next) => {
const params = []; const params = [];
let paramCount = 1; let paramCount = 1;
// Scope to projects the caller can access (admins are unfiltered). Without
// this, a granted user would see every asset across every project.
const access = await accessibleProjectIds(req.user);
if (!access.all) {
if (access.ids.size === 0) return res.json({ assets: [], total: 0 });
query += ` AND a.project_id = ANY($${paramCount++}::uuid[])`;
params.push([...access.ids]);
}
// Exclude archived unless explicitly requested — independent of status filter // Exclude archived unless explicitly requested — independent of status filter
if (include_archived !== 'true') { if (include_archived !== 'true') {
query += ` AND a.status <> 'archived'`; query += ` AND a.status <> 'archived'`;
@ -132,6 +168,9 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'projectId and clipName are required' }); return res.status(400).json({ error: 'projectId and clipName are required' });
} }
// Registering an asset writes into a project — require edit access there.
await assertProjectAccess(req.user, projectId, 'edit');
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null; const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
if (durationNum !== null && !Number.isFinite(durationNum)) { if (durationNum !== null && !Number.isFinite(durationNum)) {
return res.status(400).json({ error: 'duration must be a finite number (seconds)' }); return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
@ -220,8 +259,8 @@ router.post('/', async (req, res, next) => {
} }
}); });
// POST /cleanup-live // POST /cleanup-live — cross-project maintenance, admin only.
router.post('/cleanup-live', async (req, res, next) => { router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
try { try {
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10)); const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
const result = await pool.query( const result = await pool.query(
@ -234,8 +273,8 @@ router.post('/cleanup-live', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// POST /cleanup-live-orphans // POST /cleanup-live-orphans — cross-project maintenance, admin only.
router.post('/cleanup-live-orphans', async (_req, res, next) => { router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
try { try {
const liveRoot = process.env.LIVE_DIR || '/live'; const liveRoot = process.env.LIVE_DIR || '/live';
let entries; let entries;
@ -277,7 +316,7 @@ router.get('/:id', async (req, res, next) => {
}); });
// PATCH /:id // PATCH /:id
router.patch('/:id', async (req, res, next) => { router.patch('/:id', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { display_name, tags, notes, bin_id } = req.body; const { display_name, tags, notes, bin_id } = req.body;
@ -299,7 +338,7 @@ router.patch('/:id', async (req, res, next) => {
}); });
// POST /:id/copy // POST /:id/copy
router.post('/:id/copy', async (req, res, next) => { router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { binId, projectId } = req.body; const { binId, projectId } = req.body;
@ -346,7 +385,7 @@ router.post('/:id/copy', async (req, res, next) => {
}); });
// POST /:id/mark-empty // POST /:id/mark-empty
router.post('/:id/mark-empty', async (req, res, next) => { router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Bug #66: first check the asset exists and what status it is in // Bug #66: first check the asset exists and what status it is in
@ -384,7 +423,7 @@ router.post('/:id/mark-empty', async (req, res, next) => {
// the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id // the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id
// flips it out of 'live', records duration + S3 keys, and kicks off the // flips it out of 'live', records duration + S3 keys, and kicks off the
// proxy -> thumbnail -> filmstrip job chain. // proxy -> thumbnail -> filmstrip job chain.
router.post('/:id/finalize', async (req, res, next) => { router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { hiresKey, proxyKey, duration } = req.body; const { hiresKey, proxyKey, duration } = req.body;
@ -436,7 +475,7 @@ router.post('/:id/finalize', async (req, res, next) => {
}); });
// POST /:id/generate-proxy // POST /:id/generate-proxy
router.post('/:id/generate-proxy', async (req, res, next) => { router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]); const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
@ -452,8 +491,8 @@ router.post('/:id/generate-proxy', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// POST /backfill-proxies // POST /backfill-proxies — cross-project maintenance, admin only.
router.post('/backfill-proxies', async (_req, res, next) => { router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
try { try {
const targets = await pool.query( const targets = await pool.query(
`SELECT id, original_s3_key FROM assets `SELECT id, original_s3_key FROM assets
@ -477,7 +516,7 @@ router.post('/backfill-proxies', async (_req, res, next) => {
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip // POST /:id/reprocess?type=proxy|thumbnail|filmstrip
// Force-requeue a processing job regardless of current asset status. // Force-requeue a processing job regardless of current asset status.
router.post('/:id/reprocess', async (req, res, next) => { router.post('/:id/reprocess', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const type = req.query.type || 'proxy'; const type = req.query.type || 'proxy';
@ -528,7 +567,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
}); });
// POST /:id/retry // POST /:id/retry
router.post('/:id/retry', async (req, res, next) => { router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]); const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
@ -547,7 +586,7 @@ router.post('/:id/retry', async (req, res, next) => {
}); });
// DELETE /:id // DELETE /:id
router.delete('/:id', async (req, res, next) => { router.delete('/:id', requireAssetEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { hard } = req.query; const { hard } = req.query;
@ -908,6 +947,15 @@ router.post('/batch-trim', async (req, res, next) => {
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' }); return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
} }
} }
// Authorize every source asset's project (edit) before queuing any work.
const trimAssetIds = [...new Set(clips.map(c => c.assetId))];
const owning = await pool.query('SELECT id, project_id FROM assets WHERE id = ANY($1::uuid[])', [trimAssetIds]);
const projById = new Map(owning.rows.map(r => [r.id, r.project_id]));
for (const aid of trimAssetIds) {
const pid = projById.get(aid);
if (!pid) return res.status(404).json({ error: 'Asset not found: ' + aid });
await assertProjectAccess(req.user, pid, 'edit');
}
const jobId = uuidv4(); const jobId = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query( await pool.query(

View file

@ -1,25 +1,60 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js'; import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// GET / - List bins. Filter by project_id when supplied; otherwise return // Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
// every bin across every project so the Library / asset-context-menu can // project_id for mutating routes to escalate to 'edit'.
// present a global "move to bin" picker. router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
req.binProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.binProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireBinEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.binProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// GET / - List bins. When project_id is supplied, scope to it (after an access
// check); otherwise return bins across every project the caller can access.
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const { project_id } = req.query; const { project_id } = req.query;
const params = [];
let where = '';
if (project_id) { if (project_id) {
where = 'WHERE b.project_id = $1'; await assertProjectAccess(req.user, project_id, 'view');
params.push(project_id); const result = await pool.query(
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
WHERE b.project_id = $1
ORDER BY b.created_at DESC`,
[project_id]
);
return res.json(result.rows);
} }
const access = await accessibleProjectIds(req.user);
let where = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
where = 'WHERE b.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query( const result = await pool.query(
`SELECT b.*, p.name AS project_name, `SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count (SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
@ -29,14 +64,13 @@ router.get('/', async (req, res, next) => {
ORDER BY b.created_at DESC`, ORDER BY b.created_at DESC`,
params params
); );
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// POST / - Create bin // POST / - Create bin (requires edit on the target project).
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { project_id, name, parent_id } = req.body; const { project_id, name, parent_id } = req.body;
@ -44,6 +78,7 @@ router.post('/', async (req, res, next) => {
if (!project_id || !name) { if (!project_id || !name) {
return res.status(400).json({ error: 'project_id and name are required' }); return res.status(400).json({ error: 'project_id and name are required' });
} }
await assertProjectAccess(req.user, project_id, 'edit');
const id = uuidv4(); const id = uuidv4();
@ -61,7 +96,7 @@ router.post('/', async (req, res, next) => {
}); });
// PATCH /:id - Update bin // PATCH /:id - Update bin
router.patch('/:id', async (req, res, next) => { router.patch('/:id', requireBinEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, parent_id } = req.body; const { name, parent_id } = req.body;
@ -107,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
}); });
// DELETE /:id - Delete bin // DELETE /:id - Delete bin
router.delete('/:id', async (req, res, next) => { router.delete('/:id', requireBinEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -126,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
} }
}); });
// POST /:id/assets - Add asset to bin // POST /:id/assets - Add asset to bin (requires edit on the bin's project).
router.post('/:id/assets', async (req, res, next) => { router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { asset_id } = req.body; const { asset_id } = req.body;
@ -158,8 +193,8 @@ router.post('/:id/assets', async (req, res, next) => {
} }
}); });
// DELETE /:id/assets/:assetId - Remove asset from bin // DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
router.delete('/:id/assets/:assetId', async (req, res, next) => { router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
try { try {
const { id, assetId } = req.params; const { id, assetId } = req.params;

View file

@ -1,3 +1,5 @@
// TODO(authz): per-project scoping not yet enforced. Capture sessions carry a
// project_id; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
import express from 'express'; import express from 'express';
const router = express.Router(); const router = express.Router();

View file

@ -1,5 +1,8 @@
// Asset-scoped comments for the Asset Detail page. // Asset-scoped comments for the Asset Detail page.
// //
// TODO(authz): per-project scoping not yet enforced. Comments hang off an asset
// (resolve project via the asset); adopt assertProjectAccess (auth/authz.js).
//
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router). // Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
// Express's :assetId param flows through from the parent mount. // Express's :assetId param flows through from the parent mount.

View file

@ -1,5 +1,8 @@
// External media imports — currently YouTube only. // External media imports — currently YouTube only.
// //
// TODO(authz): per-project scoping not yet enforced. Imports target a project_id;
// adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
//
// The flow mirrors upload.js: create the asset row up front with a placeholder // The flow mirrors upload.js: create the asset row up front with a placeholder
// filename (the worker fills in the real title once yt-dlp prints metadata), // filename (the worker fills in the real title once yt-dlp prints metadata),
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the // then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the

View file

@ -1,6 +1,8 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js'; import { validateUuid } from '../middleware/errors.js';
import { requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds, assertProjectAccess } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
@ -16,18 +18,29 @@ const slugify = (str) => {
.replace(/-+/g, '-'); .replace(/-+/g, '-');
}; };
// GET / - List all projects // GET / - List projects the caller can access (admins see all).
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC'); const access = await accessibleProjectIds(req.user);
if (access.all) {
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
return res.json(result.rows);
}
if (access.ids.size === 0) return res.json([]);
const ids = [...access.ids];
const result = await pool.query(
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
[ids]
);
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// POST / - Create project // POST / - Create project (admin only; new projects have no grants, so a
router.post('/', async (req, res, next) => { // scoped user could never reach one they just made).
router.post('/', requireAdmin, async (req, res, next) => {
try { try {
const { name, description } = req.body; const { name, description } = req.body;
@ -51,10 +64,11 @@ router.post('/', async (req, res, next) => {
} }
}); });
// GET /:id - Single project with asset count // GET /:id - Single project with asset count (requires view access).
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
await assertProjectAccess(req.user, id, 'view');
const result = await pool.query( const result = await pool.query(
`SELECT p.*, `SELECT p.*,
@ -76,10 +90,11 @@ router.get('/:id', async (req, res, next) => {
} }
}); });
// PATCH /:id - Update project // PATCH /:id - Update project (requires edit access).
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
await assertProjectAccess(req.user, id, 'edit');
const { name, description } = req.body; const { name, description } = req.body;
const updates = []; const updates = [];
@ -122,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
} }
}); });
// DELETE /:id - Delete project and cascade // DELETE /:id - Delete project and cascade (admin only — destructive, wipes
router.delete('/:id', async (req, res, next) => { // every asset/bin/recorder under it).
router.delete('/:id', requireAdmin, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -143,4 +159,78 @@ router.delete('/:id', async (req, res, next) => {
} }
}); });
// ── Per-project access grants (admin only) ──────────────────────────────────
// GET /:id/access — list grants with resolved user/group display names.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT pa.subject_type, pa.subject_id, pa.level, pa.granted_at,
CASE pa.subject_type
WHEN 'user' THEN u.display_name
WHEN 'group' THEN g.name
END AS subject_name,
CASE pa.subject_type
WHEN 'user' THEN u.username
ELSE NULL
END AS username
FROM project_access pa
LEFT JOIN users u ON pa.subject_type = 'user' AND u.id = pa.subject_id
LEFT JOIN groups g ON pa.subject_type = 'group' AND g.id = pa.subject_id
WHERE pa.project_id = $1
ORDER BY pa.subject_type, subject_name`,
[req.params.id]
);
res.json(rows);
} catch (err) { next(err); }
});
// POST /:id/access { subject_type, subject_id, level } — grant or update.
router.post('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { subject_type, subject_id, level } = req.body || {};
if (!['user', 'group'].includes(subject_type)) {
return res.status(400).json({ error: "subject_type must be 'user' or 'group'" });
}
if (!subject_id) return res.status(400).json({ error: 'subject_id required' });
const lvl = level || 'view';
if (!['view', 'edit'].includes(lvl)) {
return res.status(400).json({ error: "level must be 'view' or 'edit'" });
}
// Validate the subject actually exists so we don't create dead grants.
const tbl = subject_type === 'user' ? 'users' : 'groups';
const exists = await pool.query(`SELECT 1 FROM ${tbl} WHERE id = $1`, [subject_id]);
if (exists.rows.length === 0) {
return res.status(404).json({ error: subject_type + ' not found' });
}
const { rows } = await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level, granted_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, subject_type, subject_id)
DO UPDATE SET level = EXCLUDED.level, granted_by = EXCLUDED.granted_by, granted_at = NOW()
RETURNING project_id, subject_type, subject_id, level, granted_at`,
[req.params.id, subject_type, subject_id, lvl, req.user?.id || null]
);
res.status(201).json(rows[0]);
} catch (err) { next(err); }
});
// DELETE /:id/access/:subjectType/:subjectId — revoke a grant.
router.delete('/:id/access/:subjectType/:subjectId', requireAdmin, async (req, res, next) => {
try {
const { id, subjectType, subjectId } = req.params;
if (!['user', 'group'].includes(subjectType)) {
return res.status(400).json({ error: "subjectType must be 'user' or 'group'" });
}
const { rowCount } = await pool.query(
`DELETE FROM project_access
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3`,
[id, subjectType, subjectId]
);
if (rowCount === 0) return res.status(404).json({ error: 'grant not found' });
res.status(204).end();
} catch (err) { next(err); }
});
export default router; export default router;

View file

@ -1,3 +1,5 @@
// TODO(authz): per-project scoping not yet enforced. Recorders carry project_id;
// adopt assertProjectAccess/accessibleProjectIds (auth/authz.js) — see bins.js.
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import fs from 'fs'; import fs from 'fs';

View file

@ -1,4 +1,6 @@
// services/mam-api/src/routes/sequences.js // services/mam-api/src/routes/sequences.js
// TODO(authz): per-project scoping not yet enforced. Sequences belong to a
// project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js'; import { getSignedUrlForObject } from '../s3/client.js';

View file

@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import {
isAdmin,
accessibleProjectIds,
projectLevel,
assertProjectAccess,
} from '../../src/auth/authz.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
// ── isAdmin (pure, no DB) ───────────────────────────────────────────────────
test('isAdmin true only for role admin', () => {
assert.equal(isAdmin({ role: 'admin' }), true);
assert.equal(isAdmin({ role: 'editor' }), false);
assert.equal(isAdmin({ role: 'viewer' }), false);
assert.equal(isAdmin(null), false);
assert.equal(isAdmin(undefined), false);
});
// Seed helpers shared across the DB-backed cases.
async function seed(pool) {
const proj = async (name) =>
(await pool.query(
`INSERT INTO projects (name, s3_prefix) VALUES ($1, $1) RETURNING id`, [name]
)).rows[0].id;
const user = async (username, role) =>
(await pool.query(
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', $2) RETURNING id`,
[username, role]
)).rows[0].id;
const group = async (name) =>
(await pool.query(`INSERT INTO groups (name) VALUES ($1) RETURNING id`, [name])).rows[0].id;
const grantUser = (pid, uid, level) =>
pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
VALUES ($1, 'user', $2, $3)`, [pid, uid, level]);
const grantGroup = (pid, gid, level) =>
pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
VALUES ($1, 'group', $2, $3)`, [pid, gid, level]);
const addToGroup = (uid, gid) =>
pool.query(`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [uid, gid]);
return { proj, user, group, grantUser, grantGroup, addToGroup };
}
test('admin sees all projects, every project at edit', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
await s.proj('Alpha'); await s.proj('Beta');
const admin = { id: await s.user('adm', 'admin'), role: 'admin' };
const acc = await accessibleProjectIds(admin, pool);
assert.equal(acc.all, true);
assert.equal(await projectLevel(admin, '00000000-0000-4000-8000-000000000001', pool), 'edit');
await assertProjectAccess(admin, '00000000-0000-4000-8000-000000000001', 'edit', pool); // no throw
} finally { await pool.end(); }
});
test('direct user grant scopes access and respects level', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const beta = await s.proj('Beta');
const u = { id: await s.user('bob', 'editor'), role: 'editor' };
await s.grantUser(alpha, u.id, 'view');
const acc = await accessibleProjectIds(u, pool);
assert.equal(acc.all, false);
assert.deepEqual([...acc.ids], [alpha]);
assert.equal(await projectLevel(u, alpha, pool), 'view');
assert.equal(await projectLevel(u, beta, pool), null);
await assertProjectAccess(u, alpha, 'view', pool); // ok
await assert.rejects(() => assertProjectAccess(u, alpha, 'edit', pool), e => e.status === 403);
await assert.rejects(() => assertProjectAccess(u, beta, 'view', pool), e => e.status === 403);
} finally { await pool.end(); }
});
test('group grant flows through membership', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const u = { id: await s.user('carol', 'viewer'), role: 'viewer' };
const g = await s.group('broadcasters');
await s.addToGroup(u.id, g);
await s.grantGroup(alpha, g, 'edit');
assert.equal(await projectLevel(u, alpha, pool), 'edit');
const acc = await accessibleProjectIds(u, pool);
assert.deepEqual([...acc.ids], [alpha]);
await assertProjectAccess(u, alpha, 'edit', pool); // ok via group
} finally { await pool.end(); }
});
test('effective level is the max of direct + group grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const u = { id: await s.user('dan', 'editor'), role: 'editor' };
const g = await s.group('team');
await s.addToGroup(u.id, g);
await s.grantUser(alpha, u.id, 'view'); // direct: view
await s.grantGroup(alpha, g, 'edit'); // group: edit → wins
assert.equal(await projectLevel(u, alpha, pool), 'edit');
} finally { await pool.end(); }
});
test('user with no grants sees nothing', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
await s.proj('Alpha');
const u = { id: await s.user('eve', 'viewer'), role: 'viewer' };
const acc = await accessibleProjectIds(u, pool);
assert.equal(acc.ids.size, 0);
} finally { await pool.end(); }
});

View file

@ -0,0 +1,79 @@
// Regression test: GET /api/v1/assets must be scoped to the caller's accessible
// projects. A pre-fix bug returned every asset across every project to any
// authenticated user, defeating RBAC v2.
//
// Like project-access.test.js, the assets router uses the singleton pool, so we
// point DATABASE_URL at TEST_DATABASE_URL and seed through the same pool.
// Skips 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';
async function appWithAssets(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
// Importing the assets router constructs BullMQ queues; they connect lazily,
// and the list route only touches Postgres, so no Redis is needed here.
const { default: assetsRouter } = await import('../../src/routes/assets.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/assets', assetsRouter);
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 seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const asset = (pid, name) => pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status)
VALUES ($1, $2, $2, 'video', 'ready')`, [pid, name]);
await asset(alpha, 'a1'); await asset(alpha, 'a2'); await asset(beta, 'b1');
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' };
await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`,
[alpha, scoped.id]);
return { alpha, beta, admin, scoped };
}
test('GET /assets returns only assets in granted projects for scoped users', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { admin, scoped } = await seed(pool);
// Admin sees all three.
let a = await appWithAssets(admin);
let body = await (await fetch(a.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 3, 'admin should see every asset');
await a.close();
// Scoped viewer (granted only Alpha) sees the two Alpha assets, not Beta's.
let s = await appWithAssets(scoped);
body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 2, 'scoped user should see only granted-project assets');
assert.ok(body.assets.every(x => x.display_name !== 'b1'), 'must not leak ungranted-project asset');
await s.close();
} finally { await pool.end(); }
});
test('GET /assets returns nothing for a user with no grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await seed(pool);
const nobody = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('no','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const s = await appWithAssets(nobody);
const body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.deepEqual(body, { assets: [], total: 0 });
await s.close();
} finally { await pool.end(); }
});

View file

@ -0,0 +1,125 @@
// 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(); }
});

View file

@ -97,11 +97,18 @@ function App() {
); );
} }
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
// router, stale tab) get bounced home instead of a broken/forbidden page.
// The API enforces the same rules this is just UX.
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
let content; let content;
if (openAsset) { if (openAsset) {
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />; content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
} else { } else {
switch (route) { switch (effectiveRoute) {
case 'home': content = <Home navigate={navigate} />; break; case 'home': content = <Home navigate={navigate} />; break;
case 'dashboard': content = <Dashboard navigate={navigate} />; break; case 'dashboard': content = <Dashboard navigate={navigate} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break; case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;

View file

@ -258,12 +258,26 @@ function Users() {
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />} {tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
{tab === 'policies' && ( {tab === 'policies' && (
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}> <div className="panel" style={{ padding: '32px 24px', color: 'var(--text-2)' }}>
<Icon name="lock" size={24} /> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div> <Icon name="lock" size={16} />
<div style={{ fontSize: 12, marginTop: 4 }}> <div style={{ fontWeight: 600, fontSize: 14 }}>Access model</div>
Per-project and per-bin permissions are coming soon. For now, role-based access<br /> </div>
(admin / editor / viewer) is enforced API-wide. <div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7, maxWidth: 640 }}>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> full access to every
project plus user, group, cluster, and system administration.
</div>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see only the
projects they've been granted. A <em>view</em> grant is read-only; an
<em> edit</em> grant allows changes. Grants can target an individual user or a group.
</div>
<div>
Manage a project's grants from the <strong style={{ color: 'var(--text-2)' }}>Projects</strong> page
a project's <em>Manage access</em> menu. Group membership is managed on the
Groups tab above.
</div>
</div> </div>
</div> </div>
)} )}

View file

@ -49,6 +49,9 @@ function Projects({ onOpenProject, navigate }) {
const [showNew, setShowNew] = React.useState(false); const [showNew, setShowNew] = React.useState(false);
const [menuFor, setMenuFor] = React.useState(null); const [menuFor, setMenuFor] = React.useState(null);
const [renamingProject, setRenamingProject] = React.useState(null); const [renamingProject, setRenamingProject] = React.useState(null);
const [accessProject, setAccessProject] = React.useState(null);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
const refresh = React.useCallback(() => { const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/projects') window.ZAMPP_API.fetch('/projects')
@ -122,8 +125,10 @@ function Projects({ onOpenProject, navigate }) {
key={p.id} key={p.id}
project={p} project={p}
assets={ASSETS} assets={ASSETS}
canManageAccess={isAdmin}
onOpen={() => onOpenProject(p)} onOpen={() => onOpenProject(p)}
onRename={() => renameProject(p)} onRename={() => renameProject(p)}
onManageAccess={() => manageAccess(p)}
onDelete={() => deleteProject(p)} onDelete={() => deleteProject(p)}
/> />
))} ))}
@ -148,6 +153,7 @@ function Projects({ onOpenProject, navigate }) {
<div className="row-menu" onClick={e => e.stopPropagation()}> <div className="row-menu" onClick={e => e.stopPropagation()}>
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button> <button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename</button> <button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename</button>
{isAdmin && <button onClick={() => manageAccess(p)}><Icon name="users" size={11} />Manage access</button>}
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button> <button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
</div> </div>
)} )}
@ -165,6 +171,140 @@ function Projects({ onOpenProject, navigate }) {
onSaved={() => { setRenamingProject(null); refresh(); }} onSaved={() => { setRenamingProject(null); refresh(); }}
/> />
)} )}
{accessProject && (
<ProjectAccessModal
project={accessProject}
onClose={() => setAccessProject(null)}
/>
)}
</div>
);
}
// Admin-only: grant/revoke per-project access to users and groups.
// Backed by GET/POST/DELETE /api/v1/projects/:id/access.
function ProjectAccessModal({ project, onClose }) {
const [grants, setGrants] = React.useState([]);
const [users, setUsers] = React.useState([]);
const [groups, setGroups] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [err, setErr] = React.useState(null);
// Add-grant form state.
const [subjType, setSubjType] = React.useState('user');
const [subjId, setSubjId] = React.useState('');
const [level, setLevel] = React.useState('view');
const loadGrants = React.useCallback(() => {
return window.ZAMPP_API.fetch('/projects/' + project.id + '/access')
.then(list => setGrants(list || []))
.catch(e => setErr(e.message));
}, [project.id]);
React.useEffect(() => {
setLoading(true);
Promise.all([
loadGrants(),
window.ZAMPP_API.fetch('/users').then(setUsers).catch(() => setUsers([])),
window.ZAMPP_API.fetch('/groups').then(setGroups).catch(() => setGroups([])),
]).finally(() => setLoading(false));
}, [loadGrants]);
const addGrant = () => {
if (!subjId) return;
setErr(null);
window.ZAMPP_API.fetch('/projects/' + project.id + '/access', {
method: 'POST',
body: JSON.stringify({ subject_type: subjType, subject_id: subjId, level }),
})
.then(() => { setSubjId(''); return loadGrants(); })
.catch(e => setErr(e.message || 'Failed to add grant'));
};
const revoke = (g) => {
window.ZAMPP_API.fetch('/projects/' + project.id + '/access/' + g.subject_type + '/' + g.subject_id, { method: 'DELETE' })
.then(loadGrants)
.catch(e => setErr(e.message || 'Failed to revoke'));
};
// Candidates for the picker exclude subjects that already have a grant.
const grantedIds = new Set(grants.filter(g => g.subject_type === subjType).map(g => g.subject_id));
const candidates = (subjType === 'user' ? users : groups).filter(c => !grantedIds.has(c.id));
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Manage access · {project.name}</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
Admins always have full access. Grant specific users or groups view (read-only) or
edit (read-write) access to this project.
</div>
{/* Add-grant row */}
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr 90px auto', gap: 8, alignItems: 'end', marginBottom: 14 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Type</label>
<select className="field-input" value={subjType} style={{ appearance: 'auto' }}
onChange={e => { setSubjType(e.target.value); setSubjId(''); }}>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">{subjType === 'user' ? 'User' : 'Group'}</label>
<select className="field-input" value={subjId} style={{ appearance: 'auto' }}
onChange={e => setSubjId(e.target.value)}>
<option value="">Pick a {subjType}</option>
{candidates.map(c => (
<option key={c.id} value={c.id}>
{subjType === 'user' ? ('@' + c.username + (c.display_name ? ' · ' + c.display_name : '')) : c.name}
</option>
))}
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Level</label>
<select className="field-input" value={level} style={{ appearance: 'auto' }}
onChange={e => setLevel(e.target.value)}>
<option value="view">View</option>
<option value="edit">Edit</option>
</select>
</div>
<button className="btn primary sm" onClick={addGrant} disabled={!subjId}>Add</button>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
{/* Existing grants */}
<div className="panel">
{loading && <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div>}
{!loading && grants.length === 0 && (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
No grants yet only admins can see this project.
</div>
)}
{!loading && grants.map(g => (
<div key={g.subject_type + ':' + g.subject_id}
style={{ display: 'grid', gridTemplateColumns: '20px 1fr 70px 80px', gap: 10, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<Icon name={g.subject_type === 'group' ? 'users' : 'user'} size={13} />
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{g.subject_name || '(deleted)'}</div>
{g.username && <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{g.username}</div>}
</div>
<span className={`badge ${g.level === 'edit' ? 'accent' : 'neutral'}`}>{g.level}</span>
<button className="btn ghost sm danger" onClick={() => revoke(g)}>Revoke</button>
</div>
))}
</div>
</div>
<div className="modal-foot">
<button className="btn primary sm" onClick={onClose}>Done</button>
</div>
</div>
</div> </div>
); );
} }
@ -206,7 +346,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
); );
} }
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) { function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) {
const ofProject = assets.filter(a => a.project_id === project.id); const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4); const thumbAssets = ofProject.slice(0, 4);
@ -275,6 +415,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}> <div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button> <button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button> <button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button>
{canManageAccess && <button onClick={() => { setCtx(null); onManageAccess && onManageAccess(); }}><Icon name="users" size={11} />Manage access</button>}
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button> <button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
</div> </div>
)} )}
@ -284,3 +425,4 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
window.Projects = Projects; window.Projects = Projects;
window.RenameProjectModal = RenameProjectModal; window.RenameProjectModal = RenameProjectModal;
window.ProjectAccessModal = ProjectAccessModal;

View file

@ -126,13 +126,18 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
// (Capture live-signal badge previously lived here; it now belongs in the // (Capture live-signal badge previously lived here; it now belongs in the
// topbar status pip alongside the cluster pip. See issue #149.) // topbar status pip alongside the cluster pip. See issue #149.)
// Apply the live Jobs badge to the Operations section. // Apply the live Jobs badge to the Operations section, and gate the Admin
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
// This is UX only the API enforces the same rules server-side.
const isAdmin = me?.role === 'admin';
const sections = React.useMemo( const sections = React.useMemo(
() => NAV_SECTIONS.map(sec => ({ () => NAV_SECTIONS
...sec, .filter(sec => sec.label !== 'Admin' || isAdmin)
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n), .map(sec => ({
})), ...sec,
[jobsBadge] items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
})),
[jobsBadge, isAdmin]
); );
const toggleGroup = (id) => { const toggleGroup = (id) => {
setOpenGroups(prev => { setOpenGroups(prev => {