From 8028c4c4dd4e7d01380c32764dc6da5c58046443 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 19:27:51 -0400 Subject: [PATCH] feat(auth): bound-hostname tokens for node-agent + return role from /me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requireAuth bearer path now selects api_tokens.bound_hostname and users.role, populates req.tokenBoundHostname and req.user.role. /cluster/heartbeat can now authenticate via a bound api_token (issued via POST /auth/tokens with bound_hostname). - routes/tokens.js POST accepts bound_hostname; GET returns it so users can see which tokens are bound. - Remove /cluster/heartbeat from SERVICE_PATHS so requireAuth runs on it (the bearer auth handles the gate; the heartbeat handler still enforces the body.hostname === bound match). - /auth/me now returns role (final-review I2). Closes the gap where every signed-in user appeared as 'viewer' in the UI regardless of actual role. - loadUser SELECTs role for session auth. - Backend tests still 37/15/0/22 — no test changes needed; existing token CRUD tests stay passing since bound_hostname is optional. --- services/mam-api/src/index.js | 13 ++++--------- services/mam-api/src/middleware/auth.js | 24 ++++++++++++++++++------ services/mam-api/src/routes/auth.js | 7 ++++++- services/mam-api/src/routes/tokens.js | 20 +++++++++++++------- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index e0a94c8..dad13ca 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -93,18 +93,13 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' })); // ── Auth gate ───────────────────────────────────────────────────────────────── // req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login. const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']); -// Service-auth carve-outs: node-agent uses migration 019's bound-hostname -// api_token mechanism, not user auth. Today only /cluster/heartbeat is -// reached without a user session — operator/UI endpoints in cluster.js -// (containers restart, DELETE /:id, blackmagic device queries) ARE expected -// to require auth. If node-agent grows another endpoint, add it here. -// TODO: long-term, issue node-agent a real bound api_token and drop this carve-out. -const SERVICE_PATHS = new Set(['/cluster/heartbeat']); +// node-agent now authenticates /cluster/heartbeat with a bound api_token +// (migration 019 + bound_hostname on the token). requireAuth handles the +// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in +// routes/cluster.js verifies body.hostname matches that binding. app.use('/api/v1', requireUiHeader); -// then the existing gate: app.use('/api/v1', (req, res, next) => { if (UNAUTH_PATHS.has(req.path)) return next(); - if (SERVICE_PATHS.has(req.path)) return next(); return requireAuth(req, res, next); }); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index 374aacf..d835c42 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -18,7 +18,7 @@ async function destroyAnd401(req, res) { async function loadUser(id) { const { rows } = await pool.query( - `SELECT id, username, display_name FROM users WHERE id = $1`, [id]); + `SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]); return rows[0] || null; } @@ -48,13 +48,23 @@ export async function requireAuth(req, res, next) { if (bearer) { const hash = hashToken(bearer); const { rows } = await pool.query( - `SELECT t.id AS token_id, t.user_id, t.expires_at, u.username, u.display_name + `SELECT t.id AS token_id, t.user_id, t.expires_at, t.bound_hostname, + u.username, u.display_name, u.role FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = $1`, [hash]); if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) { pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id]) .catch(err => console.error('[auth] token last_used_at update failed:', err.message)); - req.user = { id: rows[0].user_id, username: rows[0].username, display_name: rows[0].display_name }; + req.user = { + id: rows[0].user_id, + username: rows[0].username, + display_name: rows[0].display_name, + role: rows[0].role, + }; + // Per migration 019: tokens with a bound_hostname can only be used by + // node-agents reporting that hostname. The /cluster/heartbeat handler + // enforces this; we just surface the binding here. + if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname; return next(); } } @@ -69,9 +79,11 @@ 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. +// Paths exempt from the CSRF header check. The bearer-auth exemption (above) +// already covers node-agent because it sends Authorization: Bearer; this set +// is the belt for any future service path that might call us without a +// bearer header. Today it just lets an unauthenticated heartbeat probe +// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF. const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']); export function requireUiHeader(req, res, next) { diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 4e52a02..b2dc7b3 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -120,7 +120,12 @@ router.post('/logout', (req, res) => { // GET /api/v1/auth/me router.get('/me', requireAuth, (req, res) => { - res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name }); + res.json({ + id: req.user.id, + username: req.user.username, + display_name: req.user.display_name, + role: req.user.role, + }); }); // POST /api/v1/auth/password { current_password, new_password } diff --git a/services/mam-api/src/routes/tokens.js b/services/mam-api/src/routes/tokens.js index 195d2bc..92e21ad 100644 --- a/services/mam-api/src/routes/tokens.js +++ b/services/mam-api/src/routes/tokens.js @@ -10,24 +10,30 @@ const router = express.Router(); router.get('/', async (req, res, next) => { try { const { rows } = await pool.query( - `SELECT id, name, token_prefix AS prefix, last_used_at, expires_at, created_at + `SELECT id, name, token_prefix AS prefix, bound_hostname, last_used_at, expires_at, created_at FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]); res.json(rows); } catch (err) { next(err); } }); -// POST / — create a new token +// POST / — create a new token. Optional bound_hostname binds the token to a +// specific node-agent hostname (migration 019) — the /cluster/heartbeat handler +// rejects heartbeats whose body.hostname doesn't match a bound token. router.post('/', async (req, res, next) => { try { - const { name, expires_at } = req.body || {}; + const { name, expires_at, bound_hostname } = req.body || {}; if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' }); + if (bound_hostname !== undefined && bound_hostname !== null && typeof bound_hostname !== 'string') { + return res.status(400).json({ error: 'bound_hostname must be a string' }); + } const raw = generateToken(); const { rows } = await pool.query( - `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, name, token_prefix AS prefix, expires_at, created_at`, - [req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), expires_at || null]); + `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, token_prefix AS prefix, bound_hostname, expires_at, created_at`, + [req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), + expires_at || null, bound_hostname?.trim() || null]); res.status(201).json({ ...rows[0], token: raw }); // raw token shown exactly once } catch (err) { next(err); } });