Phase 2.2m: schedules.js scoped to workspace_id; schedule.workspace_id inherited from target (device/group); fixes 6 pre-existing cross-tenant leaks (POST content/widget/layout/playlist accepted with no check, PUT verifyOwnership rewrite across all 6 polymorphic targets)

This commit is contained in:
ScreenTinker 2026-05-11 23:03:54 -05:00
parent a77ab365dd
commit 0b9aa56e75

View file

@ -2,7 +2,12 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { ELEVATED_ROLES } = require('../middleware/auth'); // Phase 2.2m: workspace-aware schedule access. Schedules inherit workspace_id
// from their target (device or device_group). All polymorphic references
// (content / widget / layout / playlist) must live in the same workspace as
// the target. This closes a long-standing leak where POST accepted those
// payload refs with no ownership check at all (only the target was checked).
const { accessContext } = require('../lib/tenancy');
// Helper: build the expanded schedule query for a device (device-level + group-level) // Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() { function getDeviceSchedulesQuery() {
@ -28,8 +33,52 @@ function getDeviceSchedulesQuery() {
`; `;
} }
// List schedules (filterable) // Load a schedule + access context, sending 403/404 on failure.
function loadScheduleAccess(req, res, requireWrite) {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) { res.status(404).json({ error: 'Schedule not found' }); return null; }
if (!schedule.workspace_id) { res.status(403).json({ error: 'Schedule not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(schedule.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
}
req.schedule = schedule;
req.scheduleCtx = ctx;
return schedule;
}
function requireScheduleWrite(req, res, next) {
if (!loadScheduleAccess(req, res, true)) return;
next();
}
// Verify caller has at least read access to the given workspace (used when
// resolving the target's workspace before stamping a new schedule).
function workspaceAccess(req, workspaceId) {
if (!workspaceId) return null;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
if (!ws) return null;
return accessContext(req.user.id, req.user.role, ws);
}
// Verify a referenced row exists and lives in the given workspace. Returns
// null on success, or { status, error } on failure. Used for content / widget
// / layout / playlist refs (where workspace_id IS NULL is the platform-template
// path and is always allowed) and for devices / device_groups (where
// workspace_id is required - those tables never carry template rows).
function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) {
const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id);
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
if (row.workspace_id === workspaceId) return null;
if (opts.allowNullWorkspace && row.workspace_id == null) return null;
return { status: 403, error: `${table.replace(/_/g, ' ').slice(0, -1)} is not in this workspace` };
}
// List schedules (filterable). Phase 2.2m: workspace-scoped.
router.get('/', (req, res) => { router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const { device_id, group_id, start, end } = req.query; const { device_id, group_id, start, end } = req.query;
let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name, let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color dg.name as group_name, dg.color as group_color
@ -38,11 +87,10 @@ router.get('/', (req, res) => {
LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.user_id = ?`; WHERE s.workspace_id = ?`;
const params = [req.user.id]; const params = [req.workspaceId];
if (device_id) { if (device_id) {
// Return both device-level and group-level schedules affecting this device
sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`; sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`;
params.push(device_id, device_id); params.push(device_id, device_id);
} }
@ -54,25 +102,28 @@ router.get('/', (req, res) => {
res.json(db.prepare(sql).all(...params)); res.json(db.prepare(sql).all(...params));
}); });
// Get schedules for a device (verify device belongs to user) // Get schedules for a device. Phase 2.2m: device access via workspace_id.
router.get('/device/:deviceId', (req, res) => { router.get('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ctx = workspaceAccess(req, device.workspace_id);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId); const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
res.json(schedules); res.json(schedules);
}); });
// Get expanded week view (resolves recurrences into individual events) // Expanded week view (resolves recurrences). Phase 2.2m: device access via workspace.
router.get('/week', (req, res) => { router.get('/week', (req, res) => {
const { date, device_id } = req.query; const { date, device_id } = req.query;
if (!device_id) return res.status(400).json({ error: 'device_id required' }); if (!device_id) return res.status(400).json({ error: 'device_id required' });
// Verify device ownership const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ctx = workspaceAccess(req, device.workspace_id);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const weekStart = date ? new Date(date) : new Date(); const weekStart = date ? new Date(date) : new Date();
weekStart.setHours(0, 0, 0, 0); weekStart.setHours(0, 0, 0, 0);
@ -81,17 +132,18 @@ router.get('/week', (req, res) => {
weekEnd.setDate(weekEnd.getDate() + 7); weekEnd.setDate(weekEnd.getDate() + 7);
const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id); const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
const events = []; const events = [];
for (const s of schedules) { for (const s of schedules) {
const expanded = expandSchedule(s, weekStart, weekEnd); const expanded = expandSchedule(s, weekStart, weekEnd);
events.push(...expanded); events.push(...expanded);
} }
res.json(events); res.json(events);
}); });
// Create schedule // Create schedule. Phase 2.2m: schedule.workspace_id is inherited from the
// target (device or group). Single workspace lookup also enforces caller's
// write access. Closes 4 pre-existing leaks: content / widget / layout /
// playlist were accepted with NO ownership check at all.
router.post('/', (req, res) => { router.post('/', (req, res) => {
const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
timezone, recurrence, recurrence_end, priority, color } = req.body; timezone, recurrence, recurrence_end, priority, color } = req.body;
@ -99,8 +151,6 @@ router.post('/', (req, res) => {
if (!start_time || !end_time) { if (!start_time || !end_time) {
return res.status(400).json({ error: 'start_time and end_time required' }); return res.status(400).json({ error: 'start_time and end_time required' });
} }
// Mutual exclusion: exactly one of device_id or group_id
if (device_id && group_id) { if (device_id && group_id) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' }); return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' });
} }
@ -108,28 +158,46 @@ router.post('/', (req, res) => {
return res.status(400).json({ error: 'Either device_id or group_id is required' }); return res.status(400).json({ error: 'Either device_id or group_id is required' });
} }
// Ownership checks // Resolve target's workspace_id and verify caller has write access there.
let targetWorkspaceId = null;
if (device_id) { if (device_id) {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) { if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
return res.status(403).json({ error: 'Access denied' }); targetWorkspaceId = device.workspace_id;
}
} }
if (group_id) { if (group_id) {
const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(group_id); const group = db.prepare('SELECT workspace_id FROM device_groups WHERE id = ?').get(group_id);
if (!group) return res.status(404).json({ error: 'Group not found' }); if (!group) return res.status(404).json({ error: 'Group not found' });
if (!ELEVATED_ROLES.includes(req.user.role) && group.user_id !== req.user.id) { if (!group.workspace_id) return res.status(403).json({ error: 'Group not assigned to a workspace' });
return res.status(403).json({ error: 'Access denied' }); targetWorkspaceId = group.workspace_id;
} }
const ctx = workspaceAccess(req, targetWorkspaceId);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
// Payload refs must live in the same workspace. Platform templates
// (workspace_id IS NULL) on content / widget / layout / playlist are allowed.
const refChecks = [
['content', content_id, true],
['widgets', widget_id, true],
['layouts', layout_id, true],
['playlists', playlist_id, true],
];
for (const [table, id, allowNull] of refChecks) {
if (!id) continue;
const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error });
} }
const id = uuidv4(); const id = uuidv4();
db.prepare(` db.prepare(`
INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title,
start_time, end_time, timezone, recurrence, recurrence_end, priority, color) start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null, `).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC', layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6'); recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
@ -137,14 +205,12 @@ router.post('/', (req, res) => {
res.status(201).json(schedule); res.status(201).json(schedule);
}); });
// Update schedule // Update schedule. Phase 2.2m: every polymorphic target that is changing must
router.put('/:id', (req, res) => { // live in the schedule's workspace. Closes the pre-existing leak where
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); // verifyOwnership keyed only on user_id (workspace-blind).
if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); router.put('/:id', requireScheduleWrite, (req, res) => {
const isAdmin = ELEVATED_ROLES.includes(req.user.role); const schedule = req.schedule;
if (!isAdmin && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
// If changing target, enforce mutual exclusion
const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id; const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id; const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id;
if (newDeviceId && newGroupId) { if (newDeviceId && newGroupId) {
@ -154,27 +220,21 @@ router.put('/:id', (req, res) => {
return res.status(400).json({ error: 'Either device_id or group_id is required' }); return res.status(400).json({ error: 'Either device_id or group_id is required' });
} }
// Re-verify ownership on every target field that is changing. Without this, a user // For each field changing to a non-null value, verify the referenced row
// could create a schedule on their own device and then PUT in another user's // lives in the schedule's workspace. Devices and groups must match exactly
// device_id / content_id / playlist_id to fire arbitrary content on victim devices. // (no NULL workspace path); content / widget / layout / playlist may be
function verifyOwnership(table, id) { // platform templates (NULL workspace_id).
if (!id) return null;
const row = db.prepare(`SELECT user_id FROM ${table} WHERE id = ?`).get(id);
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
if (!isAdmin && row.user_id !== req.user.id) return { status: 403, error: 'Access denied' };
return null;
}
const ownershipChecks = [ const ownershipChecks = [
['devices', req.body.device_id, schedule.device_id], ['devices', req.body.device_id, schedule.device_id, false],
['device_groups', req.body.group_id, schedule.group_id], ['device_groups', req.body.group_id, schedule.group_id, false],
['content', req.body.content_id, schedule.content_id], ['content', req.body.content_id, schedule.content_id, true],
['widgets', req.body.widget_id, schedule.widget_id], ['widgets', req.body.widget_id, schedule.widget_id, true],
['layouts', req.body.layout_id, schedule.layout_id], ['layouts', req.body.layout_id, schedule.layout_id, true],
['playlists', req.body.playlist_id, schedule.playlist_id], ['playlists', req.body.playlist_id, schedule.playlist_id, true],
]; ];
for (const [table, newVal, oldVal] of ownershipChecks) { for (const [table, newVal, oldVal, allowNull] of ownershipChecks) {
if (newVal === undefined || newVal === oldVal || !newVal) continue; if (newVal === undefined || newVal === oldVal || !newVal) continue;
const err = verifyOwnership(table, newVal); const err = checkRefInWorkspace(table, newVal, schedule.workspace_id, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error }); if (err) return res.status(err.status).json({ error: err.error });
} }
@ -186,7 +246,6 @@ router.put('/:id', (req, res) => {
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
}); });
// When switching from device to group (or vice versa), null out the other field
if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) { if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) {
updates.push('device_id = ?'); values.push(null); updates.push('device_id = ?'); values.push(null);
} }
@ -204,10 +263,7 @@ router.put('/:id', (req, res) => {
}); });
// Delete schedule // Delete schedule
router.delete('/:id', (req, res) => { router.delete('/:id', requireScheduleWrite, (req, res) => {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
if (!ELEVATED_ROLES.includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
@ -226,7 +282,6 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
return events; return events;
} }
// Parse simple RRULE
const rule = parseRRule(schedule.recurrence); const rule = parseRRule(schedule.recurrence);
if (!rule) { if (!rule) {
events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time }); events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time });
@ -254,7 +309,6 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
} }
} }
// Advance
switch (rule.freq) { switch (rule.freq) {
case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break; case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break;
case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break; case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break;