From a4610e8d0d247f8ff533ab99f9241fdedb31a920 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 11 May 2026 21:04:03 -0500 Subject: [PATCH] Phase 2.2c: content_folders gets workspace_id (schema + backfill); folders.js scoped; content.js folder-move strict same-workspace --- server/db/database.js | 43 +++++++++++++++++ server/routes/content.js | 15 +++--- server/routes/folders.js | 99 ++++++++++++++++++++++++---------------- 3 files changed, 111 insertions(+), 46 deletions(-) diff --git a/server/db/database.js b/server/db/database.js index f524cac..34d84f3 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -90,6 +90,9 @@ const migrations = [ "ALTER TABLE video_wall_devices ADD COLUMN canvas_y REAL", "ALTER TABLE video_wall_devices ADD COLUMN canvas_width REAL", "ALTER TABLE video_wall_devices ADD COLUMN canvas_height REAL", + // Phase 2.2c: content_folders gets workspace_id. Phase 1 missed this table. + "ALTER TABLE content_folders ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)", + "CREATE INDEX IF NOT EXISTS idx_content_folders_workspace ON content_folders(workspace_id)", ]; for (const sql of migrations) { try { db.exec(sql); } catch (e) { /* already exists */ } @@ -327,6 +330,46 @@ function migrateGroupSchedules() { migrateGroupSchedules(); +// Phase 2.2c migration: backfill content_folders.workspace_id from owner's +// default workspace. The ALTER lives in the migrations array above; this +// one-shot populates the column for any rows that pre-date it. +const PHASE6_MIGRATION_ID = 'phase6_content_folders_workspace'; + +function migrateFolderWorkspaceIds() { + const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE6_MIGRATION_ID); + if (already) return; + + // Check the column exists before trying to backfill. (Defensive: on a fresh + // install the schema.sql defines content_folders without the column, the + // ALTER above adds it, and we proceed; but if anything went sideways we + // skip rather than throw.) + const cols = db.prepare("PRAGMA table_info(content_folders)").all(); + if (!cols.some(c => c.name === 'workspace_id')) { + console.warn('Phase 2.2c migration: content_folders.workspace_id column missing, skipping backfill'); + return; + } + + const stmt = db.prepare(` + UPDATE content_folders SET workspace_id = ( + SELECT w.id FROM workspaces w + JOIN workspace_members wm ON wm.workspace_id = w.id + WHERE wm.user_id = content_folders.user_id + ORDER BY wm.joined_at ASC LIMIT 1 + ) + WHERE workspace_id IS NULL AND user_id IS NOT NULL + `); + + const tx = db.transaction(() => { + const result = stmt.run(); + db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE6_MIGRATION_ID); + return result.changes; + }); + const changes = tx(); + if (changes > 0) console.log(`Phase 2.2c migration: backfilled workspace_id on ${changes} content_folders row(s).`); +} + +migrateFolderWorkspaceIds(); + // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) function pruneTelemetry(deviceId) { db.prepare(` diff --git a/server/routes/content.js b/server/routes/content.js index 4cb7a01..7ea63f2 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -284,15 +284,16 @@ router.put('/:id', (req, res) => { } if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); } if (folder_id !== undefined) { - // Verify the destination folder belongs to the same user. Only superadmin gets - // cross-user access — matches the policy in routes/folders.js so a plain "admin" - // can't move content into a folder they can't see in GET /api/folders. + // Phase 2.2c: target folder must live in the same workspace as the + // content row being modified. Strict same-workspace check - no + // platform_admin override, because cross-workspace folder references + // break the isolation model. To move content across workspaces, switch + // workspace first. if (folder_id) { - const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id); + const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folder_id); if (!target) return res.status(400).json({ error: 'Invalid folder_id' }); - const isSuperadmin = PLATFORM_ROLES.includes(req.user.role); - if (!isSuperadmin && target.user_id !== req.user.id) { - return res.status(403).json({ error: 'Cannot move content to another user\'s folder' }); + if (target.workspace_id !== content.workspace_id) { + return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' }); } } updates.push('folder_id = ?'); diff --git a/server/routes/folders.js b/server/routes/folders.js index 9f23e0f..079efa6 100644 --- a/server/routes/folders.js +++ b/server/routes/folders.js @@ -3,75 +3,92 @@ const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); const { PLATFORM_ROLES } = require('../middleware/auth'); +// Phase 2.2c: workspace-aware access. Mirrors devices.js / content.js. +const { accessContext } = require('../lib/tenancy'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -// Per-user folder cap. The route has no rate limit (multer doesn't go through the -// global API limiter chain), so without a count cap a single account could insert -// millions of rows. 100 is a generous ceiling for a real organisational hierarchy -// — admins/superadmins are exempt because they may manage cross-user data. -const MAX_FOLDERS_PER_USER = 100; +// Per-workspace folder cap. The route has no rate limit (multer doesn't go +// through the global API limiter chain), so without a count cap a workspace +// could insert millions of rows. 100 is generous for a real org hierarchy. +const MAX_FOLDERS_PER_WORKSPACE = 100; -// Verify a folder belongs to the current user (or null = root, also allowed). -// Returns the row, or null if it exists but isn't owned by the user. +// Resolve a folder and the caller's access to its workspace. Returns: +// { row, ctx } - access granted; ctx.workspaceRole / ctx.actingAs available +// { row: { id: null } } - root (no folder id supplied) - always accessible +// null - folder not found or no access // -// Only superadmin gets cross-user access — matching the GET /api/folders listing -// (which has always been superadmin-only). The previous mismatch let a regular -// "admin" mutate folders they couldn't see, so the inconsistency was exploitable. -function ownedFolder(req, folderId) { - if (!folderId) return { id: null }; +// Platform-template folders (workspace_id IS NULL) are readable by anyone. +// Writable only by platform_admin (same shape as content.js). +function accessibleFolder(req, folderId, requireWrite = false) { + if (!folderId) return { row: { id: null }, ctx: null }; if (!UUID_RE.test(folderId)) return null; const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId); if (!row) return null; - const isSuperadmin = PLATFORM_ROLES.includes(req.user.role); - if (!isSuperadmin && row.user_id !== req.user.id) return null; - return row; + + // Platform-template path + if (!row.workspace_id) { + if (requireWrite && !PLATFORM_ROLES.includes(req.user.role)) return null; + return { row, ctx: null }; + } + + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(row.workspace_id); + const ctx = ws && accessContext(req.user.id, req.user.role, ws); + if (!ctx) return null; + if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') return null; + return { row, ctx }; } -// List folders for the current user. Returns the full tree as a flat array; -// the client builds the hierarchy from parent_id. +// List folders accessible to the caller in their current workspace. +// Includes platform-template folders (workspace_id IS NULL) for everyone. router.get('/', (req, res) => { - const isAdmin = PLATFORM_ROLES.includes(req.user.role); - const rows = isAdmin - ? db.prepare('SELECT * FROM content_folders ORDER BY name COLLATE NOCASE').all() - : db.prepare('SELECT * FROM content_folders WHERE user_id = ? ORDER BY name COLLATE NOCASE').all(req.user.id); + if (!req.workspaceId) return res.json([]); + const rows = db.prepare( + 'SELECT * FROM content_folders WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY name COLLATE NOCASE' + ).all(req.workspaceId); res.json(rows); }); -// Create a folder. +// Create a folder in the caller's current workspace. router.post('/', (req, res) => { + if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating folders.' }); const name = (req.body.name || '').trim(); if (!name) return res.status(400).json({ error: 'name is required' }); if (name.length > 100) return res.status(400).json({ error: 'name too long' }); - const isSuperadmin = PLATFORM_ROLES.includes(req.user.role); - if (!isSuperadmin) { - const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id); - if (count >= MAX_FOLDERS_PER_USER) { + // Per-workspace cap. Platform_admin exempt (cross-workspace admin tooling). + if (!PLATFORM_ROLES.includes(req.user.role)) { + const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE workspace_id = ?').get(req.workspaceId); + if (count >= MAX_FOLDERS_PER_WORKSPACE) { return res.status(429).json({ - error: `Folder limit reached (${MAX_FOLDERS_PER_USER}). Delete unused folders before creating more.` + error: `Folder limit reached (${MAX_FOLDERS_PER_WORKSPACE}). Delete unused folders before creating more.` }); } } const parentId = req.body.parent_id || null; if (parentId) { - const parent = ownedFolder(req, parentId); - if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' }); + const parent = accessibleFolder(req, parentId, true); + if (!parent || parent.row.id === null) return res.status(400).json({ error: 'Invalid parent_id' }); + // Parent must be in the same workspace as the new folder. + if (parent.row.workspace_id !== req.workspaceId) { + return res.status(400).json({ error: 'Parent folder is in a different workspace' }); + } } const id = uuidv4(); db.prepare( - 'INSERT INTO content_folders (id, user_id, parent_id, name) VALUES (?, ?, ?, ?)' - ).run(id, req.user.id, parentId, name); + 'INSERT INTO content_folders (id, user_id, workspace_id, parent_id, name) VALUES (?, ?, ?, ?, ?)' + ).run(id, req.user.id, req.workspaceId, parentId, name); res.status(201).json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(id)); }); // Rename / move a folder. router.put('/:id', (req, res) => { - const folder = ownedFolder(req, req.params.id); - if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' }); + const access = accessibleFolder(req, req.params.id, true); + if (!access || access.row.id === null) return res.status(404).json({ error: 'Folder not found' }); + const folder = access.row; const updates = []; const values = []; @@ -88,10 +105,14 @@ router.put('/:id', (req, res) => { const newParent = req.body.parent_id || null; if (newParent === folder.id) return res.status(400).json({ error: 'Folder cannot be its own parent' }); if (newParent) { - const parent = ownedFolder(req, newParent); - if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' }); + const parent = accessibleFolder(req, newParent, true); + if (!parent || parent.row.id === null) return res.status(400).json({ error: 'Invalid parent_id' }); + // New parent must be in the same workspace as this folder. + if (parent.row.workspace_id !== folder.workspace_id) { + return res.status(400).json({ error: 'Cannot move folder to a parent in another workspace' }); + } // Reject cycles: walk up from the new parent and ensure we never hit this folder. - let cursor = parent; + let cursor = parent.row; const seen = new Set([folder.id]); while (cursor && cursor.parent_id) { if (seen.has(cursor.parent_id)) { @@ -115,10 +136,10 @@ router.put('/:id', (req, res) => { // Delete a folder. Content inside it falls back to root via ON DELETE SET NULL. // Subfolders cascade-delete; if the user wants to keep them they should move them first. router.delete('/:id', (req, res) => { - const folder = ownedFolder(req, req.params.id); - if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' }); + const access = accessibleFolder(req, req.params.id, true); + if (!access || access.row.id === null) return res.status(404).json({ error: 'Folder not found' }); - db.prepare('DELETE FROM content_folders WHERE id = ?').run(folder.id); + db.prepare('DELETE FROM content_folders WHERE id = ?').run(access.row.id); res.json({ success: true }); });