diff --git a/server/routes/schedules.js b/server/routes/schedules.js index c2a043f..df16f0f 100644 --- a/server/routes/schedules.js +++ b/server/routes/schedules.js @@ -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;