Phase 2.2g: reports.js scoped to workspace_id; fixes pre-existing /export and /uptime cross-tenant leaks

This commit is contained in:
ScreenTinker 2026-05-11 21:36:54 -05:00
parent 0d642e4d80
commit f17d757ba0

View file

@ -1,18 +1,25 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Helper: scope reports to user's devices // Phase 2.2g: scope reports to the caller's current workspace.
function getUserDeviceFilter(user) { // No platform_admin bypass - cross-workspace reporting comes from
if (PLATFORM_ROLES.includes(user.role)) return { sql: '', params: [] }; // switch-workspace, not a magic role-based "see all" path. This matches
return { sql: ' AND d.user_id = ?', params: [user.id] }; // the precedent set in devices.js.
function getWorkspaceDeviceFilter(req) {
if (!req.workspaceId) return { sql: ' AND 1=0', params: [] }; // no workspace -> empty result
return { sql: ' AND d.workspace_id = ?', params: [req.workspaceId] };
}
function getWorkspaceDeviceSubquery(req) {
if (!req.workspaceId) return { sql: ' AND device_id IN (SELECT id FROM devices WHERE 1=0)', params: [] };
return { sql: ' AND device_id IN (SELECT id FROM devices WHERE workspace_id = ?)', params: [req.workspaceId] };
} }
// Query play logs // Query play logs
router.get('/plays', (req, res) => { router.get('/plays', (req, res) => {
const { device_id, content_id, start, end, limit: lim } = req.query; const { device_id, content_id, start, end, limit: lim } = req.query;
const scope = getUserDeviceFilter(req.user); const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT pl.*, d.name as device_name let sql = `SELECT pl.*, d.name as device_name
FROM play_logs pl FROM play_logs pl
JOIN devices d ON pl.device_id = d.id JOIN devices d ON pl.device_id = d.id
@ -36,13 +43,10 @@ router.get('/summary', (req, res) => {
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400; const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
let deviceFilter = ''; // Phase 2.2g: workspace-scope all summary queries, no admin bypass.
const params = [startEpoch, endEpoch]; const wsScope = getWorkspaceDeviceSubquery(req);
// Scope to user's devices (non-admin) let deviceFilter = wsScope.sql;
if (!ELEVATED_ROLES.includes(req.user.role)) { const params = [startEpoch, endEpoch, ...wsScope.params];
deviceFilter += ' AND device_id IN (SELECT id FROM devices WHERE user_id = ?)';
params.push(req.user.id);
}
if (device_id) { deviceFilter += ' AND device_id = ?'; params.push(device_id); } if (device_id) { deviceFilter += ' AND device_id = ?'; params.push(device_id); }
// Overall stats // Overall stats
@ -112,14 +116,17 @@ router.get('/summary', (req, res) => {
}); });
}); });
// Export CSV // Export CSV. Phase 2.2g: workspace-scoped. Previously this route had no scope
// filter at all - any authenticated user could export the entire platform's
// play_logs. The added WHERE clause closes that pre-existing cross-tenant leak.
router.get('/export', (req, res) => { router.get('/export', (req, res) => {
const { device_id, start, end } = req.query; const { device_id, start, end } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : 0; const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : 0;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
let sql = `SELECT pl.*, d.name as device_name FROM play_logs pl JOIN devices d ON pl.device_id = d.id WHERE pl.started_at >= ? AND pl.started_at <= ?`; const scope = getWorkspaceDeviceFilter(req);
const params = [startEpoch, endEpoch]; let sql = `SELECT pl.*, d.name as device_name FROM play_logs pl JOIN devices d ON pl.device_id = d.id WHERE pl.started_at >= ? AND pl.started_at <= ?${scope.sql}`;
const params = [startEpoch, endEpoch, ...scope.params];
if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); } if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); }
sql += ' ORDER BY pl.started_at ASC'; sql += ' ORDER BY pl.started_at ASC';
@ -137,20 +144,24 @@ router.get('/export', (req, res) => {
res.send(csv); res.send(csv);
}); });
// Device uptime report // Device uptime report. Phase 2.2g: workspace-scoped. Previously this route
// had no scope filter at all - any authenticated user could see telemetry
// summaries for every device on the platform. The added WHERE clause closes
// that pre-existing cross-tenant leak.
router.get('/uptime', (req, res) => { router.get('/uptime', (req, res) => {
const { device_id, start, end } = req.query; const { device_id, start, end } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400; const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT dt.device_id, d.name as device_name, let sql = `SELECT dt.device_id, d.name as device_name,
COUNT(*) as heartbeat_count, COUNT(*) as heartbeat_count,
MIN(dt.reported_at) as first_seen, MIN(dt.reported_at) as first_seen,
MAX(dt.reported_at) as last_seen MAX(dt.reported_at) as last_seen
FROM device_telemetry dt FROM device_telemetry dt
JOIN devices d ON dt.device_id = d.id JOIN devices d ON dt.device_id = d.id
WHERE dt.reported_at >= ? AND dt.reported_at <= ?`; WHERE dt.reported_at >= ? AND dt.reported_at <= ?${scope.sql}`;
const params = [startEpoch, endEpoch]; const params = [startEpoch, endEpoch, ...scope.params];
if (device_id) { sql += ' AND dt.device_id = ?'; params.push(device_id); } if (device_id) { sql += ' AND dt.device_id = ?'; params.push(device_id); }
sql += ' GROUP BY dt.device_id ORDER BY d.name'; sql += ' GROUP BY dt.device_id ORDER BY d.name';