diff --git a/.env.example b/.env.example index b220868..1d18f27 100644 --- a/.env.example +++ b/.env.example @@ -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 # 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. +# +# 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 # CORS allowlist — comma-separated origins that may carry credentials to the API. diff --git a/services/mam-api/src/auth/authz.js b/services/mam-api/src/auth/authz.js new file mode 100644 index 0000000..22a37a6 --- /dev/null +++ b/services/mam-api/src/auth/authz.js @@ -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, 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; + } +} diff --git a/services/mam-api/src/db/migrations/026-project-access.sql b/services/mam-api/src/db/migrations/026-project-access.sql new file mode 100644 index 0000000..3e49d4b --- /dev/null +++ b/services/mam-api/src/db/migrations/026-project-access.sql @@ -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); diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index d7cf83f..27c69d8 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -8,7 +8,7 @@ import os from 'node:os'; import { exec } from 'node:child_process'; import pool from './db/pool.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 authRouter from './routes/auth.js'; @@ -117,8 +117,10 @@ app.use('/api/v1', (req, res, next) => { // ── API Routes ──────────────────────────────────────────────────────────────── app.use('/api/v1/auth', authRouter); -app.use('/api/v1/auth/users', usersRouter); -app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate +// User and group administration is admin-only (RBAC v2). The auth gate above +// 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/assets', assetsRouter); 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/settings', settingsRouter); 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/system', systemRouter); app.use('/api/v1/cluster', clusterRouter); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index d835c42..f942ea1 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -4,7 +4,9 @@ import { parseBearer, hashToken } from '../auth/tokens.js'; // Stable UUID matching migration 023's seeded dev 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: 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 IDLE_MS = 1 * 3600 * 1000; @@ -73,6 +75,14 @@ export async function requireAuth(req, res, next) { 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 // cookie sends, but a custom header that no
can produce hardens // against the edge cases. Applied to mutating verbs only. diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 905ab12..73a5f71 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -7,9 +7,36 @@ import pool from '../db/pool.js'; import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { validateUuid } from '../middleware/errors.js'; +import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js'; +import { requireAdmin } from '../middleware/auth.js'; 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) const parseRedisUrl = (url) => { @@ -66,6 +93,15 @@ router.get('/', async (req, res, next) => { const params = []; 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 if (include_archived !== 'true') { 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' }); } + // 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; if (durationNum !== null && !Number.isFinite(durationNum)) { 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 -router.post('/cleanup-live', async (req, res, next) => { +// POST /cleanup-live — cross-project maintenance, admin only. +router.post('/cleanup-live', requireAdmin, async (req, res, next) => { try { const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10)); const result = await pool.query( @@ -234,8 +273,8 @@ router.post('/cleanup-live', async (req, res, next) => { } catch (err) { next(err); } }); -// POST /cleanup-live-orphans -router.post('/cleanup-live-orphans', async (_req, res, next) => { +// POST /cleanup-live-orphans — cross-project maintenance, admin only. +router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => { try { const liveRoot = process.env.LIVE_DIR || '/live'; let entries; @@ -277,7 +316,7 @@ router.get('/:id', async (req, res, next) => { }); // PATCH /:id -router.patch('/:id', async (req, res, next) => { +router.patch('/:id', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; const { display_name, tags, notes, bin_id } = req.body; @@ -299,7 +338,7 @@ router.patch('/:id', async (req, res, next) => { }); // POST /:id/copy -router.post('/:id/copy', async (req, res, next) => { +router.post('/:id/copy', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; const { binId, projectId } = req.body; @@ -346,7 +385,7 @@ router.post('/:id/copy', async (req, res, next) => { }); // POST /:id/mark-empty -router.post('/:id/mark-empty', async (req, res, next) => { +router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; // 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 // flips it out of 'live', records duration + S3 keys, and kicks off the // proxy -> thumbnail -> filmstrip job chain. -router.post('/:id/finalize', async (req, res, next) => { +router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; const { hiresKey, proxyKey, duration } = req.body; @@ -436,7 +475,7 @@ router.post('/:id/finalize', async (req, res, next) => { }); // POST /:id/generate-proxy -router.post('/:id/generate-proxy', async (req, res, next) => { +router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; 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); } }); -// POST /backfill-proxies -router.post('/backfill-proxies', async (_req, res, next) => { +// POST /backfill-proxies — cross-project maintenance, admin only. +router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => { try { const targets = await pool.query( `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 // 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 { const { id } = req.params; const type = req.query.type || 'proxy'; @@ -528,7 +567,7 @@ router.get('/:id/filmstrip', async (req, res, next) => { }); // POST /:id/retry -router.post('/:id/retry', async (req, res, next) => { +router.post('/:id/retry', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; 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 -router.delete('/:id', async (req, res, next) => { +router.delete('/:id', requireAssetEdit, async (req, res, next) => { try { const { id } = req.params; 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' }); } } + // 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 expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); await pool.query( diff --git a/services/mam-api/src/routes/bins.js b/services/mam-api/src/routes/bins.js index 1fc91db..53359ec 100644 --- a/services/mam-api/src/routes/bins.js +++ b/services/mam-api/src/routes/bins.js @@ -1,25 +1,60 @@ import express from 'express'; import pool from '../db/pool.js'; import { validateUuid } from '../middleware/errors.js'; +import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js'; import { v4 as uuidv4 } from 'uuid'; 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 -// every bin across every project so the Library / asset-context-menu can -// present a global "move to bin" picker. +// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the +// project_id for mutating routes to escalate to 'edit'. +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) => { try { const { project_id } = req.query; - const params = []; - let where = ''; if (project_id) { - where = 'WHERE b.project_id = $1'; - params.push(project_id); + await assertProjectAccess(req.user, project_id, 'view'); + 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( `SELECT b.*, p.name AS project_name, (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`, params ); - res.json(result.rows); } catch (err) { next(err); } }); -// POST / - Create bin +// POST / - Create bin (requires edit on the target project). router.post('/', async (req, res, next) => { try { const { project_id, name, parent_id } = req.body; @@ -44,6 +78,7 @@ router.post('/', async (req, res, next) => { if (!project_id || !name) { return res.status(400).json({ error: 'project_id and name are required' }); } + await assertProjectAccess(req.user, project_id, 'edit'); const id = uuidv4(); @@ -61,7 +96,7 @@ router.post('/', async (req, res, next) => { }); // PATCH /:id - Update bin -router.patch('/:id', async (req, res, next) => { +router.patch('/:id', requireBinEdit, async (req, res, next) => { try { const { id } = req.params; const { name, parent_id } = req.body; @@ -107,7 +142,7 @@ router.patch('/:id', async (req, res, next) => { }); // DELETE /:id - Delete bin -router.delete('/:id', async (req, res, next) => { +router.delete('/:id', requireBinEdit, async (req, res, next) => { try { const { id } = req.params; @@ -126,8 +161,8 @@ router.delete('/:id', async (req, res, next) => { } }); -// POST /:id/assets - Add asset to bin -router.post('/:id/assets', async (req, res, next) => { +// POST /:id/assets - Add asset to bin (requires edit on the bin's project). +router.post('/:id/assets', requireBinEdit, async (req, res, next) => { try { const { id } = req.params; 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 -router.delete('/:id/assets/:assetId', async (req, res, next) => { +// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit). +router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => { try { const { id, assetId } = req.params; diff --git a/services/mam-api/src/routes/capture.js b/services/mam-api/src/routes/capture.js index 2f14630..de97a96 100644 --- a/services/mam-api/src/routes/capture.js +++ b/services/mam-api/src/routes/capture.js @@ -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'; const router = express.Router(); diff --git a/services/mam-api/src/routes/comments.js b/services/mam-api/src/routes/comments.js index 6701155..1d657de 100644 --- a/services/mam-api/src/routes/comments.js +++ b/services/mam-api/src/routes/comments.js @@ -1,5 +1,8 @@ // 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). // Express's :assetId param flows through from the parent mount. diff --git a/services/mam-api/src/routes/imports.js b/services/mam-api/src/routes/imports.js index 7540e46..d802a46 100644 --- a/services/mam-api/src/routes/imports.js +++ b/services/mam-api/src/routes/imports.js @@ -1,5 +1,8 @@ // 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 // 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 diff --git a/services/mam-api/src/routes/projects.js b/services/mam-api/src/routes/projects.js index 3524ab0..2e0529e 100644 --- a/services/mam-api/src/routes/projects.js +++ b/services/mam-api/src/routes/projects.js @@ -1,6 +1,8 @@ import express from 'express'; import pool from '../db/pool.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'; const router = express.Router(); @@ -16,18 +18,29 @@ const slugify = (str) => { .replace(/-+/g, '-'); }; -// GET / - List all projects +// GET / - List projects the caller can access (admins see all). router.get('/', async (req, res, next) => { 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); } catch (err) { next(err); } }); -// POST / - Create project -router.post('/', async (req, res, next) => { +// POST / - Create project (admin only; new projects have no grants, so a +// scoped user could never reach one they just made). +router.post('/', requireAdmin, async (req, res, next) => { try { 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) => { try { const { id } = req.params; + await assertProjectAccess(req.user, id, 'view'); const result = await pool.query( `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) => { try { const { id } = req.params; + await assertProjectAccess(req.user, id, 'edit'); const { name, description } = req.body; const updates = []; @@ -122,8 +137,9 @@ router.patch('/:id', async (req, res, next) => { } }); -// DELETE /:id - Delete project and cascade -router.delete('/:id', async (req, res, next) => { +// DELETE /:id - Delete project and cascade (admin only — destructive, wipes +// every asset/bin/recorder under it). +router.delete('/:id', requireAdmin, async (req, res, next) => { try { 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; diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 316d39e..bbf0967 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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 http from 'http'; import fs from 'fs'; diff --git a/services/mam-api/src/routes/sequences.js b/services/mam-api/src/routes/sequences.js index b7dd20c..6233a76 100644 --- a/services/mam-api/src/routes/sequences.js +++ b/services/mam-api/src/routes/sequences.js @@ -1,4 +1,6 @@ // 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 pool from '../db/pool.js'; import { getSignedUrlForObject } from '../s3/client.js'; diff --git a/services/mam-api/test/auth/authz.test.js b/services/mam-api/test/auth/authz.test.js new file mode 100644 index 0000000..4ab34a3 --- /dev/null +++ b/services/mam-api/test/auth/authz.test.js @@ -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(); } +}); diff --git a/services/mam-api/test/routes/assets-access.test.js b/services/mam-api/test/routes/assets-access.test.js new file mode 100644 index 0000000..99aa598 --- /dev/null +++ b/services/mam-api/test/routes/assets-access.test.js @@ -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(); } +}); diff --git a/services/mam-api/test/routes/project-access.test.js b/services/mam-api/test/routes/project-access.test.js new file mode 100644 index 0000000..49d3bf1 --- /dev/null +++ b/services/mam-api/test/routes/project-access.test.js @@ -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(); } +}); diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index b49e141..c595577 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -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; if (openAsset) { content = setOpenAsset(null)} />; } else { - switch (route) { + switch (effectiveRoute) { case 'home': content = ; break; case 'dashboard': content = ; break; case 'library': content = setOpenProject(null)} />; break; diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 2dde801..6098f2f 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -258,12 +258,26 @@ function Users() { {tab === 'groups' && } {tab === 'policies' && ( -
- -
Access policies
-
- Per-project and per-bin permissions are coming soon. For now, role-based access
- (admin / editor / viewer) is enforced API-wide. +
+
+ +
Access model
+
+
+
+ admin — full access to every + project plus user, group, cluster, and system administration. +
+
+ editor / viewer — see only the + projects they've been granted. A view grant is read-only; an + edit grant allows changes. Grants can target an individual user or a group. +
+
+ Manage a project's grants from the Projects page + → a project's Manage access… menu. Group membership is managed on the + Groups tab above. +
)} diff --git a/services/web-ui/public/screens-projects.jsx b/services/web-ui/public/screens-projects.jsx index d87f3e0..67b1a0f 100644 --- a/services/web-ui/public/screens-projects.jsx +++ b/services/web-ui/public/screens-projects.jsx @@ -49,6 +49,9 @@ function Projects({ onOpenProject, navigate }) { const [showNew, setShowNew] = React.useState(false); const [menuFor, setMenuFor] = 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(() => { window.ZAMPP_API.fetch('/projects') @@ -122,8 +125,10 @@ function Projects({ onOpenProject, navigate }) { key={p.id} project={p} assets={ASSETS} + canManageAccess={isAdmin} onOpen={() => onOpenProject(p)} onRename={() => renameProject(p)} + onManageAccess={() => manageAccess(p)} onDelete={() => deleteProject(p)} /> ))} @@ -148,6 +153,7 @@ function Projects({ onOpenProject, navigate }) {
e.stopPropagation()}> + {isAdmin && }
)} @@ -165,6 +171,140 @@ function Projects({ onOpenProject, navigate }) { onSaved={() => { setRenamingProject(null); refresh(); }} /> )} + {accessProject && ( + setAccessProject(null)} + /> + )} +
+ ); +} + +// 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 ( +
+
e.stopPropagation()}> +
+
Manage access · {project.name}
+ +
+
+
+ Admins always have full access. Grant specific users or groups view (read-only) or + edit (read-write) access to this project. +
+ + {/* Add-grant row */} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {err &&
{err}
} + + {/* Existing grants */} +
+ {loading &&
Loading…
} + {!loading && grants.length === 0 && ( +
+ No grants yet — only admins can see this project. +
+ )} + {!loading && grants.map(g => ( +
+ +
+
{g.subject_name || '(deleted)'}
+ {g.username &&
@{g.username}
} +
+ {g.level} + +
+ ))} +
+
+
+ +
+
); } @@ -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 thumbAssets = ofProject.slice(0, 4); @@ -275,6 +415,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
e.stopPropagation()}> + {canManageAccess && }
)} @@ -284,3 +425,4 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) { window.Projects = Projects; window.RenameProjectModal = RenameProjectModal; +window.ProjectAccessModal = ProjectAccessModal; diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 2fb2685..f92458c 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -126,13 +126,18 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { // (Capture live-signal badge previously lived here; it now belongs in the // 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( - () => NAV_SECTIONS.map(sec => ({ - ...sec, - items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n), - })), - [jobsBadge] + () => NAV_SECTIONS + .filter(sec => sec.label !== 'Admin' || isAdmin) + .map(sec => ({ + ...sec, + items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n), + })), + [jobsBadge, isAdmin] ); const toggleGroup = (id) => { setOpenGroups(prev => {