Phase 2.2a: devices.js scoped to workspace_id; pair flow stamps workspace_id on claim

This commit is contained in:
ScreenTinker 2026-05-11 20:33:58 -05:00
parent ac3eb74122
commit afd2a10df2
2 changed files with 42 additions and 20 deletions

View file

@ -2,10 +2,20 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2a: workspace-aware access. accessContext returns { workspaceRole, actingAs }
// or null based on the caller's reach into a specific workspace.
const { accessContext } = require('../lib/tenancy');
// List devices for current user (admins see all) // List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
// workspace is resolved by resolveTenancy middleware from JWT or query/header
// override. Platform_admin and org_owner/admin see whichever workspace they
// are currently switched into (cross-workspace visibility comes from
// switch-workspace, not from a special list filter).
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = PLATFORM_ROLES.includes(req.user.role); if (!req.workspaceId) return res.json([]);
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
const devices = db.prepare(` const devices = db.prepare(`
SELECT d.*, SELECT d.*,
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb, t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
@ -25,10 +35,10 @@ router.get('/', (req, res) => {
INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
) s ON d.id = s.device_id ) s ON d.id = s.device_id
${isAdmin ? 'WHERE d.user_id IS NOT NULL' : 'WHERE d.user_id IS NOT NULL AND (d.user_id = ? OR d.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))'} WHERE d.workspace_id = ?
ORDER BY d.created_at ASC ORDER BY d.created_at ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...(isAdmin ? [] : [req.user.id, req.user.id]), Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0); `).all(req.workspaceId, limit, offset);
res.json(devices); res.json(devices);
}); });
@ -50,12 +60,15 @@ router.get('/unassigned', (req, res) => {
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id); const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
// Check access: admin, owner, or team member // Phase 2.2a: workspace-aware read check. accessContext returns null when
if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) { // the caller has no path (direct member, org-level acting-as, or platform_admin)
const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; // to the device's workspace.
if (!teamAccess) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
device._teamRole = teamAccess.role; // Pass team role for frontend to check const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
} const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (ctx.workspaceRole) device._workspaceRole = ctx.workspaceRole; // Pass to frontend
if (ctx.actingAs) device._actingAs = true;
const telemetry = db.prepare( const telemetry = db.prepare(
'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20' 'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20'
@ -106,16 +119,21 @@ router.get('/:id', (req, res) => {
res.json({ ...device, telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog }); res.json({ ...device, telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
}); });
// Helper: check device ownership // Helper: check device write access via the workspace the device belongs to.
// Phase 2.2a: replaces user_id + team_members check. Allows: platform_admin,
// org_owner/admin of the device's org (acting-as), workspace_admin/editor of
// the device's workspace. Denies workspace_viewer and non-members.
function checkDeviceOwnership(req, res) { function checkDeviceOwnership(req, res) {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; } if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
// Check team membership const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!teamAccess || teamAccess.role === 'viewer') { if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
res.status(403).json({ error: 'Access denied' }); return null; // ctx.actingAs covers platform_admin and org_owner/admin paths (always writable).
} // Direct workspace members: workspace_viewer is read-only.
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
} }
return device; return device;
} }

View file

@ -429,16 +429,20 @@ const originalProvisionRoute = require('./routes/provisioning');
// Override provision to also notify device via WS // Override provision to also notify device via WS
const { checkDeviceLimit } = require('./middleware/subscription'); const { checkDeviceLimit } = require('./middleware/subscription');
app.post('/api/provision/pair', requireAuth, checkDeviceLimit, (req, res) => { app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, (req, res) => {
const { pairing_code, name } = req.body; const { pairing_code, name } = req.body;
if (!pairing_code) return res.status(400).json({ error: 'pairing_code required' }); if (!pairing_code) return res.status(400).json({ error: 'pairing_code required' });
// Phase 2.2a: pair into the caller's current workspace. Refusing on no
// context prevents the regression window where a newly-paired device
// would have workspace_id NULL and be invisible to workspace-filtered lists.
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before pairing.' });
const device = db.prepare('SELECT * FROM devices WHERE pairing_code = ?').get(pairing_code); const device = db.prepare('SELECT * FROM devices WHERE pairing_code = ?').get(pairing_code);
if (!device) return res.status(404).json({ error: 'No device found with that pairing code' }); if (!device) return res.status(404).json({ error: 'No device found with that pairing code' });
const deviceName = name || 'Display ' + (db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(req.user.id).count + 1); const deviceName = name || 'Display ' + (db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(req.user.id).count + 1);
db.prepare("UPDATE devices SET pairing_code = NULL, name = ?, user_id = ?, status = 'online', updated_at = strftime('%s','now') WHERE id = ?") db.prepare("UPDATE devices SET pairing_code = NULL, name = ?, user_id = ?, workspace_id = ?, status = 'online', updated_at = strftime('%s','now') WHERE id = ?")
.run(deviceName, req.user.id, device.id); .run(deviceName, req.user.id, req.workspaceId, device.id);
// Link fingerprint to user // Link fingerprint to user
db.prepare("UPDATE device_fingerprints SET user_id = ?, device_id = ? WHERE device_id = ?") db.prepare("UPDATE device_fingerprints SET user_id = ?, device_id = ? WHERE device_id = ?")