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 { v4: uuidv4 } = require('uuid');
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)
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) => {
if (!req.workspaceId) return res.json([]);
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,
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 playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.user_id = ?`;
const params = [req.user.id];
WHERE s.workspace_id = ?`;
const params = [req.workspaceId];
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 = ?))`;
params.push(device_id, device_id);
}
@ -54,25 +102,28 @@ router.get('/', (req, res) => {
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) => {
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 (!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);
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) => {
const { date, device_id } = req.query;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
// Verify device ownership
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 (!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();
weekStart.setHours(0, 0, 0, 0);
@ -81,17 +132,18 @@ router.get('/week', (req, res) => {
weekEnd.setDate(weekEnd.getDate() + 7);
const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
const events = [];
for (const s of schedules) {
const expanded = expandSchedule(s, weekStart, weekEnd);
events.push(...expanded);
}
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) => {
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;
@ -99,8 +151,6 @@ router.post('/', (req, res) => {
if (!start_time || !end_time) {
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) {
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' });
}
// Ownership checks
// Resolve target's workspace_id and verify caller has write access there.
let targetWorkspaceId = null;
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 (!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' });
targetWorkspaceId = device.workspace_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 (!ELEVATED_ROLES.includes(req.user.role) && group.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
if (!group.workspace_id) return res.status(403).json({ error: 'Group not assigned to a workspace' });
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();
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).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',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
@ -137,14 +205,12 @@ router.post('/', (req, res) => {
res.status(201).json(schedule);
});
// Update schedule
router.put('/:id', (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' });
const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
// Update schedule. Phase 2.2m: every polymorphic target that is changing must
// live in the schedule's workspace. Closes the pre-existing leak where
// verifyOwnership keyed only on user_id (workspace-blind).
router.put('/:id', requireScheduleWrite, (req, res) => {
const schedule = req.schedule;
// If changing target, enforce mutual exclusion
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;
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' });
}
// Re-verify ownership on every target field that is changing. Without this, a user
// could create a schedule on their own device and then PUT in another user's
// device_id / content_id / playlist_id to fire arbitrary content on victim devices.
function verifyOwnership(table, 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;
}
// For each field changing to a non-null value, verify the referenced row
// lives in the schedule's workspace. Devices and groups must match exactly
// (no NULL workspace path); content / widget / layout / playlist may be
// platform templates (NULL workspace_id).
const ownershipChecks = [
['devices', req.body.device_id, schedule.device_id],
['device_groups', req.body.group_id, schedule.group_id],
['content', req.body.content_id, schedule.content_id],
['widgets', req.body.widget_id, schedule.widget_id],
['layouts', req.body.layout_id, schedule.layout_id],
['playlists', req.body.playlist_id, schedule.playlist_id],
['devices', req.body.device_id, schedule.device_id, false],
['device_groups', req.body.group_id, schedule.group_id, false],
['content', req.body.content_id, schedule.content_id, true],
['widgets', req.body.widget_id, schedule.widget_id, true],
['layouts', req.body.layout_id, schedule.layout_id, true],
['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;
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 });
}
@ -186,7 +246,6 @@ router.put('/:id', (req, res) => {
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'))) {
updates.push('device_id = ?'); values.push(null);
}
@ -204,10 +263,7 @@ router.put('/:id', (req, res) => {
});
// Delete schedule
router.delete('/:id', (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' });
router.delete('/:id', requireScheduleWrite, (req, res) => {
db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
@ -226,7 +282,6 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
return events;
}
// Parse simple RRULE
const rule = parseRRule(schedule.recurrence);
if (!rule) {
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) {
case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break;
case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break;