feat(auth): bound-hostname tokens for node-agent + return role from /me

- 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.
This commit is contained in:
Zac Gaetano 2026-05-27 19:27:51 -04:00
parent e6da1432e5
commit 8028c4c4dd
4 changed files with 41 additions and 23 deletions

View file

@ -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);
});

View file

@ -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) {

View file

@ -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 }

View file

@ -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); }
});