mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Phase 2.2i: device-groups.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (group device add, bulk content assign, bulk playlist assign); pre-emptive workspace_id stamp on ensureDevicePlaylist helper
This commit is contained in:
parent
c7f9d014ca
commit
e17538b186
|
|
@ -2,45 +2,69 @@ 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');
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
||||||
|
// Phase 2.2i: workspace-aware access. Same pattern as devices/content/widgets.
|
||||||
|
const { accessContext } = require('../lib/tenancy');
|
||||||
|
|
||||||
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||||
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
|
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
|
||||||
|
|
||||||
// Verify group belongs to the authenticated user
|
// Phase 2.2i: split read/write access checks. Both attach req.group on success.
|
||||||
function requireGroupOwnership(req, res, next) {
|
function loadGroupAccessCtx(req, res) {
|
||||||
const group = db.prepare('SELECT * FROM device_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const group = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
|
||||||
if (!group) return res.status(404).json({ error: 'group not found' });
|
if (!group) { res.status(404).json({ error: 'group not found' }); return null; }
|
||||||
req.group = group;
|
if (!group.workspace_id) { res.status(403).json({ error: 'Group not assigned to a workspace' }); return null; }
|
||||||
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(group.workspace_id);
|
||||||
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
||||||
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
||||||
|
return { group, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireGroupRead(req, res, next) {
|
||||||
|
const access = loadGroupAccessCtx(req, res);
|
||||||
|
if (!access) return;
|
||||||
|
req.group = access.group;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// List groups
|
function requireGroupWrite(req, res, next) {
|
||||||
|
const access = loadGroupAccessCtx(req, res);
|
||||||
|
if (!access) return;
|
||||||
|
if (!access.ctx.actingAs && access.ctx.workspaceRole === 'workspace_viewer') {
|
||||||
|
return res.status(403).json({ error: 'Read-only access' });
|
||||||
|
}
|
||||||
|
req.group = access.group;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// List groups in the caller's current workspace.
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
if (!req.workspaceId) return res.json([]);
|
||||||
const groups = db.prepare(`
|
const groups = db.prepare(`
|
||||||
SELECT g.*, COUNT(dgm.device_id) as device_count
|
SELECT g.*, COUNT(dgm.device_id) as device_count
|
||||||
FROM device_groups g
|
FROM device_groups g
|
||||||
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
|
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
|
||||||
WHERE g.user_id = ?
|
WHERE g.workspace_id = ?
|
||||||
GROUP BY g.id
|
GROUP BY g.id
|
||||||
ORDER BY g.name ASC
|
ORDER BY g.name ASC
|
||||||
`).all(req.user.id);
|
`).all(req.workspaceId);
|
||||||
res.json(groups);
|
res.json(groups);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create group
|
// Create group in the caller's current workspace.
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating groups.' });
|
||||||
const { name, color } = req.body;
|
const { name, color } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'name required' });
|
if (!name) return res.status(400).json({ error: 'name required' });
|
||||||
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)')
|
db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(id, req.user.id, name, color || '#3B82F6');
|
.run(id, req.user.id, req.workspaceId, name, color || '#3B82F6');
|
||||||
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
|
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update group
|
// Update group
|
||||||
router.put('/:id', requireGroupOwnership, (req, res) => {
|
router.put('/:id', requireGroupWrite, (req, res) => {
|
||||||
const { name, color } = req.body;
|
const { name, color } = req.body;
|
||||||
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
||||||
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
|
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
|
||||||
|
|
@ -49,7 +73,7 @@ router.put('/:id', requireGroupOwnership, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete group — converts group schedules to per-device schedules first
|
// Delete group — converts group schedules to per-device schedules first
|
||||||
router.delete('/:id', requireGroupOwnership, (req, res) => {
|
router.delete('/:id', requireGroupWrite, (req, res) => {
|
||||||
const groupId = req.params.id;
|
const groupId = req.params.id;
|
||||||
|
|
||||||
const convert = db.transaction(() => {
|
const convert = db.transaction(() => {
|
||||||
|
|
@ -98,7 +122,7 @@ router.delete('/:id', requireGroupOwnership, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get devices in a group
|
// Get devices in a group
|
||||||
router.get('/:id/devices', requireGroupOwnership, (req, res) => {
|
router.get('/:id/devices', requireGroupRead, (req, res) => {
|
||||||
const devices = db.prepare(`
|
const devices = db.prepare(`
|
||||||
SELECT d.* FROM devices d
|
SELECT d.* FROM devices d
|
||||||
JOIN device_group_members dgm ON d.id = dgm.device_id
|
JOIN device_group_members dgm ON d.id = dgm.device_id
|
||||||
|
|
@ -113,13 +137,18 @@ router.get('/:id/devices', requireGroupOwnership, (req, res) => {
|
||||||
// onto the group section and for the Manage modal's checkboxes, which both
|
// onto the group section and for the Manage modal's checkboxes, which both
|
||||||
// hit this endpoint. Without this, joining a group never auto-assigned the
|
// hit this endpoint. Without this, joining a group never auto-assigned the
|
||||||
// group's playlist, leaving the new device on whatever it had before.
|
// group's playlist, leaving the new device on whatever it had before.
|
||||||
router.post('/:id/devices', requireGroupOwnership, (req, res) => {
|
//
|
||||||
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
||||||
|
// checked device.user_id == caller; a workspace_admin who happened to own a
|
||||||
|
// device in another workspace could add it to a group in this workspace.
|
||||||
|
// Now: the device must belong to the same workspace as the group.
|
||||||
|
router.post('/:id/devices', requireGroupWrite, (req, res) => {
|
||||||
const { device_id } = req.body;
|
const { device_id } = req.body;
|
||||||
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
||||||
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 && device.user_id !== req.user.id) {
|
if (device.workspace_id !== req.group.workspace_id) {
|
||||||
return res.status(403).json({ error: 'Access denied' });
|
return res.status(403).json({ error: 'Device is not in this group\'s workspace' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
|
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
|
||||||
|
|
@ -145,7 +174,7 @@ router.post('/:id/devices', requireGroupOwnership, (req, res) => {
|
||||||
// - Remaining group(s) but none have a playlist → clear playlist.
|
// - Remaining group(s) but none have a playlist → clear playlist.
|
||||||
// Without this, a device dragged out of a group keeps stale playlist state
|
// Without this, a device dragged out of a group keeps stale playlist state
|
||||||
// from the group it just left.
|
// from the group it just left.
|
||||||
router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => {
|
router.delete('/:id/devices/:deviceId', requireGroupWrite, (req, res) => {
|
||||||
const deviceId = req.params.deviceId;
|
const deviceId = req.params.deviceId;
|
||||||
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
|
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
|
||||||
|
|
||||||
|
|
@ -163,13 +192,16 @@ router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => {
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure a device has a playlist; auto-create one if missing
|
// Ensure a device has a playlist; auto-create one if missing.
|
||||||
|
// Phase 2.2i: pre-emptive loop-closer for the future playlists.js migration.
|
||||||
|
// The auto-created playlist lives in the same workspace as the device, so
|
||||||
|
// once playlists.js scopes by workspace_id this helper's rows remain visible.
|
||||||
function ensureDevicePlaylist(deviceId, userId) {
|
function ensureDevicePlaylist(deviceId, userId) {
|
||||||
const device = db.prepare('SELECT playlist_id, name FROM devices WHERE id = ?').get(deviceId);
|
const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
|
||||||
if (device?.playlist_id) return device.playlist_id;
|
if (device?.playlist_id) return device.playlist_id;
|
||||||
const playlistId = uuidv4();
|
const playlistId = uuidv4();
|
||||||
db.prepare('INSERT INTO playlists (id, user_id, name, is_auto_generated) VALUES (?, ?, ?, 1)')
|
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
|
||||||
.run(playlistId, userId, `${device?.name || 'Display'} playlist`);
|
.run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
|
||||||
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
|
||||||
return playlistId;
|
return playlistId;
|
||||||
}
|
}
|
||||||
|
|
@ -190,14 +222,22 @@ function pushPlaylistToDevice(req, deviceId) {
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk assign content to all devices in a group (adds to each device's playlist)
|
// Bulk assign content to all devices in a group (adds to each device's playlist).
|
||||||
router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
||||||
|
// checked content.user_id == caller; the content could live in any workspace
|
||||||
|
// the caller had any reach into. Now: content must live in the group's
|
||||||
|
// workspace (or be a platform-template content row, workspace_id IS NULL).
|
||||||
|
router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
|
||||||
const { content_id, duration_sec } = req.body;
|
const { content_id, duration_sec } = req.body;
|
||||||
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
||||||
|
|
||||||
// Verify content belongs to the user
|
// Verify content lives in the same workspace as the group (or is a
|
||||||
const content = db.prepare('SELECT id FROM content WHERE id = ? AND user_id = ?').get(content_id, req.user.id);
|
// platform-template row).
|
||||||
|
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
|
||||||
if (!content) return res.status(404).json({ error: 'Content not found' });
|
if (!content) return res.status(404).json({ error: 'Content not found' });
|
||||||
|
if (content.workspace_id && content.workspace_id !== req.group.workspace_id) {
|
||||||
|
return res.status(403).json({ error: 'Content is not in this group\'s workspace' });
|
||||||
|
}
|
||||||
|
|
||||||
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
||||||
|
|
||||||
|
|
@ -217,12 +257,22 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
|
||||||
|
|
||||||
// Assign an existing playlist to all devices in a group, and persist the
|
// Assign an existing playlist to all devices in a group, and persist the
|
||||||
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
|
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
|
||||||
router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => {
|
//
|
||||||
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
||||||
|
// checked playlist.user_id == caller; the playlist could live in any
|
||||||
|
// workspace the caller could reach. Now: playlist must live in the group's
|
||||||
|
// workspace. Playlists don't currently have a NULL/template path - playlists.js
|
||||||
|
// migration is deferred, so this check uses the raw workspace_id column that
|
||||||
|
// 2.2i's ensureDevicePlaylist loop-closer also writes to.
|
||||||
|
router.post('/:id/assign-playlist', requireGroupWrite, (req, res) => {
|
||||||
const { playlist_id } = req.body;
|
const { playlist_id } = req.body;
|
||||||
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
|
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
|
||||||
|
|
||||||
const playlist = db.prepare('SELECT id FROM playlists WHERE id = ? AND user_id = ?').get(playlist_id, req.user.id);
|
const playlist = db.prepare('SELECT id, workspace_id FROM playlists WHERE id = ?').get(playlist_id);
|
||||||
if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
|
if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
|
||||||
|
if (playlist.workspace_id && playlist.workspace_id !== req.group.workspace_id) {
|
||||||
|
return res.status(403).json({ error: 'Playlist is not in this group\'s workspace' });
|
||||||
|
}
|
||||||
|
|
||||||
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
||||||
|
|
||||||
|
|
@ -237,8 +287,8 @@ router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => {
|
||||||
res.json({ success: true, devices_updated: members.length });
|
res.json({ success: true, devices_updated: members.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send command to all devices in a group
|
// Send command to all devices in a group (reboot/shutdown/screen on/off etc.)
|
||||||
router.post('/:id/command', requireGroupOwnership, (req, res) => {
|
router.post('/:id/command', requireGroupWrite, (req, res) => {
|
||||||
const { type, payload } = req.body;
|
const { type, payload } = req.body;
|
||||||
if (!type) return res.status(400).json({ error: 'command type required' });
|
if (!type) return res.status(400).json({ error: 'command type required' });
|
||||||
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
|
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
|
||||||
|
|
|
||||||
|
|
@ -421,7 +421,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
for (const g of (data.device_groups || [])) {
|
for (const g of (data.device_groups || [])) {
|
||||||
const newId = uuid.v4();
|
const newId = uuid.v4();
|
||||||
idMap.groups[g.id] = newId;
|
idMap.groups[g.id] = newId;
|
||||||
db.prepare(`INSERT INTO device_groups (id, user_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000));
|
db.prepare(`INSERT INTO device_groups (id, user_id, workspace_id, name, color, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000));
|
||||||
stats.device_groups++;
|
stats.device_groups++;
|
||||||
}
|
}
|
||||||
for (const gm of (data.device_group_members || [])) {
|
for (const gm of (data.device_group_members || [])) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue