mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
a77ab365dd
commit
0b9aa56e75
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue