From 03d0d098f574bcf1fd62be9976bcda7a966f5c2e Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:42:42 -0400 Subject: [PATCH] =?UTF-8?q?fix(auth):=20final-review=20integration=20fixes?= =?UTF-8?q?=20=E2=80=94=20Users=20page=20alias=20+=20PATCH,=20CSRF=20on=20?= =?UTF-8?q?uploads=20+=20heartbeat,=20drop=20.bak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review findings: - Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the existing SPA Users page works; add PATCH /:id for inline edits (display_name, role, password). - Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader 403s before reaching the route. - Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent heartbeats keep working when NODE_TOKEN is unset. - Remove stale auth.js.bak. --- services/mam-api/src/index.js | 1 + services/mam-api/src/middleware/auth.js | 7 +++++++ services/mam-api/src/routes/users.js | 24 +++++++++++++++++++++++ services/web-ui/public/data.jsx | 1 + services/web-ui/public/screens-admin.jsx | 1 + services/web-ui/public/screens-ingest.jsx | 1 + 6 files changed, 35 insertions(+) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 7539b44..e0a94c8 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -111,6 +111,7 @@ 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 app.use('/api/v1/auth/tokens', requireAuth, tokensRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index 1d8cb3c..2e5707a 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -69,11 +69,18 @@ export async function requireAuth(req, res, next) { const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const REQUIRED_HEADER = 'dragonflight-ui'; +// Paths exempt from the CSRF header check. Must match the SERVICE_PATHS set +// in index.js — these are non-browser service-to-service calls (node-agent +// heartbeat) where the CSRF protection doesn't apply. +const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']); + export function requireUiHeader(req, res, next) { if (!MUTATING.has(req.method)) return next(); // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not // browsers and can't be drive-by'd from another origin. if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next(); + // Service path carve-outs (e.g. node-agent heartbeat — not a browser). + if (CSRF_EXEMPT_PATHS.has(req.path)) return next(); if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next(); return res.status(403).json({ error: 'missing X-Requested-With header' }); } diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js index 1803335..b28bcb5 100644 --- a/services/mam-api/src/routes/users.js +++ b/services/mam-api/src/routes/users.js @@ -69,4 +69,28 @@ router.delete('/:id', async (req, res, next) => { } catch (err) { next(err); } }); +// PATCH /:id { display_name?, role?, password? } — generic update. +// password update goes through hashPassword; other fields are passed through. +router.patch('/:id', async (req, res, next) => { + try { + if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' }); + const sets = []; const vals = []; + if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); } + if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); } + if (typeof req.body?.password === 'string') { + if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars'); + sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()'); + vals.push(await hashPassword(req.body.password)); + } + if (sets.length === 0) return bad(res, 'nothing to update'); + vals.push(req.params.id); + const { rows } = await pool.query( + `UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING id, username, display_name, role`, + vals + ); + if (rows.length === 0) return res.status(404).json({ error: 'user not found' }); + res.json(rows[0]); + } catch (err) { next(err); } +}); + export default router; diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index af3900c..883cea4 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -308,6 +308,7 @@ async function exportSequenceEDL(sequenceId, filename) { const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', { method: 'POST', credentials: 'include', + headers: { 'X-Requested-With': 'dragonflight-ui' }, }); if (!res.ok) throw new Error('EDL export failed: ' + res.status); const blob = await res.blob(); diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 72c10bf..3d6bddd 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -2063,6 +2063,7 @@ function SdkVendorRow({ vendor, status, onDone }) { await new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id); + xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui'); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 60f772a..e68dc61 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -20,6 +20,7 @@ function _xhrPost(url, formData, onProgress) { }; xhr.onerror = () => reject(new Error('Network error')); xhr.open('POST', url); + xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui'); xhr.send(formData); }); }