From 48902f68070c2a8eefce9d325b066ddf6f501aff Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 5 Jun 2026 10:30:21 -0500 Subject: [PATCH] feat(roles): add cross-org platform_operator staff role (#13) platform_operator is cross-org STAFF: it can see and act-as into every org and read/write workspace-scoped resources (content, playlists, layouts, schedules, devices, widgets, kiosk) anywhere - but holds NO owner-level power. Design is deny-by-default: operator is NEVER added to PLATFORM_ROLES / isPlatformRole, so every owner capability (billing, org/workspace deletion, user/role management, shared & template asset curation, branding, workspace member mgmt/rename) stays denied, and any NEW owner endpoint added later inherits that denial automatically. Operator gets power from exactly two levers: - middleware/auth.js: new PLATFORM_STAFF set + isPlatformStaff(); owner guards (PLATFORM_ROLES, requireAdmin, requireSuperAdmin) unchanged. - tenancy.js: accessContext + resolveTenancy treat staff as act-as capable; new req.isPlatformStaff / req.isPlatformOperator (req.isPlatformAdmin stays owner-only); accessibleWorkspaceIds + switch-workspace guard use staff. - permissions.js: canRead/canWrite + canAccessWorkspace (read) grant staff; canAdmin / canAdminWorkspace / isOrgAdmin / isOrgOwner stay owner-gated. Read-only edges (per review): operator may VIEW workspace member lists (canAccessWorkspace) and the unassigned device pool (devices.js), but cannot mutate either. Frontend: platform role dropdown adds "Platform operator"; the user-mgmt view stays isPlatformAdmin-gated so operators can't open it. EN i18n only. Behaviour identical under HOSTED_INSTANCE set or unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/en.js | 1 + frontend/js/views/admin.js | 4 ++-- server/lib/permissions.js | 21 ++++++++++++++++----- server/lib/tenancy.js | 34 ++++++++++++++++++++++++---------- server/middleware/auth.js | 18 +++++++++++++++++- server/routes/auth.js | 15 ++++++++++----- server/routes/devices.js | 8 +++++--- 7 files changed, 75 insertions(+), 26 deletions(-) diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ad0aa90..db04ae4 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -799,6 +799,7 @@ export default { 'admin.col.monthly': 'Monthly', 'admin.col.yearly': 'Yearly', 'admin.role.user': 'User', + 'admin.role.platform_operator': 'Platform operator', 'admin.role.platform_admin': 'Platform admin', // Legacy labels kept for back-compat with any not-yet-normalized data; the // role dropdown no longer offers these (#14 normalization). diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index 18fcf29..5307e5b 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -9,8 +9,8 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opt // #14: the platform user-management dropdown manages users.role (the // PLATFORM-level role) only - workspace/org roles are managed in the members // views. Options are the current model; the legacy 'admin'/'superadmin' strings -// were normalized away. (#13 adds 'platform_operator' to this list.) -const PLATFORM_ROLE_OPTIONS = ['user', 'platform_admin']; +// were normalized away. #13 adds 'platform_operator' (cross-org staff). +const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin']; export async function render(container) { const user = JSON.parse(localStorage.getItem('user') || '{}'); diff --git a/server/lib/permissions.js b/server/lib/permissions.js index 0db24b6..57d5565 100644 --- a/server/lib/permissions.js +++ b/server/lib/permissions.js @@ -14,20 +14,26 @@ 'use strict'; +const { isPlatformRole, isPlatformStaff } = require('../middleware/auth'); + +// #13: platform staff (admin OR operator) get cross-org read/write. canRead and +// canWrite include req.isPlatformStaff; canAdmin deliberately does NOT - it stays +// owner-gated, so operators can read/write resources everywhere but cannot +// perform workspace-admin actions (member mgmt, rename, branding, etc.). function canRead(req) { - if (req.isPlatformAdmin) return true; + if (req.isPlatformStaff) return true; if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true; return !!req.workspaceRole; // any workspace_member can read } function canWrite(req) { - if (req.isPlatformAdmin) return true; + if (req.isPlatformStaff) return true; if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true; return req.workspaceRole === 'workspace_admin' || req.workspaceRole === 'workspace_editor'; } function canAdmin(req) { - if (req.isPlatformAdmin) return true; + if (req.isPlatformAdmin) return true; // owner only - NOT platform_operator if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true; return req.workspaceRole === 'workspace_admin'; } @@ -99,7 +105,9 @@ function requireOrgOwner(req, res, next) { // active one. Does its own DB lookups against workspace_members + organization_members. function canAdminWorkspace(db, user, workspace) { if (!user || !workspace) return false; - if (user.role === 'platform_admin' || user.role === 'superadmin') return true; + // Owner only (isPlatformRole) - platform_operator is intentionally excluded, + // so operators cannot manage workspace members, rename, or set branding (#13). + if (isPlatformRole(user.role)) return true; const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?') .get(workspace.organization_id, user.id); if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true; @@ -114,7 +122,10 @@ function canAdminWorkspace(db, user, workspace) { // where resolveTenancy is not on the request (e.g. /api/workspaces/:id/members). function canAccessWorkspace(db, user, workspace) { if (!user || !workspace) return false; - if (user.role === 'platform_admin' || user.role === 'superadmin') return true; + // Read access: platform staff (admin OR operator) can view any workspace, + // including its member list (#13, read-only - mutations stay owner-gated via + // canAdminWorkspace). + if (isPlatformStaff(user.role)) return true; const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?') .get(workspace.organization_id, user.id); if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true; diff --git a/server/lib/tenancy.js b/server/lib/tenancy.js index 4b3d0ee..8c6e217 100644 --- a/server/lib/tenancy.js +++ b/server/lib/tenancy.js @@ -27,7 +27,7 @@ 'use strict'; const { db } = require('../db/database'); -const { isPlatformRole } = require('../middleware/auth'); +const { isPlatformRole, isPlatformStaff } = require('../middleware/auth'); function membershipOf(userId, workspaceId) { return db.prepare( @@ -57,13 +57,9 @@ function firstAccessibleWorkspace(userId) { } // Check whether userId can access workspace via any path (member, org admin, -// or platform admin). Returns the access context: { workspaceRole, actingAs } +// or platform staff). Returns the access context: { workspaceRole, actingAs } // or null if no access. function accessContext(userId, role, workspace) { - // #14: route through isPlatformRole so a legacy 'superadmin' is treated as a - // platform owner here too (previously this bare === check excluded it, so a - // superadmin couldn't act-as into orgs they didn't directly belong to). - const isPlatformAdmin = isPlatformRole(role); const wsMembership = membershipOf(userId, workspace.id); if (wsMembership) { return { workspaceRole: wsMembership.role, actingAs: false }; @@ -72,7 +68,13 @@ function accessContext(userId, role, workspace) { if (orgMembership && (orgMembership.role === 'org_owner' || orgMembership.role === 'org_admin')) { return { workspaceRole: null, actingAs: true }; } - if (isPlatformAdmin) { + // #14: isPlatformRole (not a bare === 'platform_admin') so a legacy + // 'superadmin' can act-as too. #13: isPlatformStaff additionally lets + // platform_operator act-as into any org. actingAs:true (workspaceRole null) + // is what skips the viewer-deny on resource writes, so staff get read/write + // in any workspace - while canAdmin()/canAdminWorkspace() stay owner-gated, + // so operators still can't perform workspace-admin actions. + if (isPlatformStaff(role)) { return { workspaceRole: null, actingAs: true }; } return null; @@ -84,8 +86,15 @@ function resolveTenancy(req, res, next) { return next(); } + // isPlatformAdmin = OWNER tier (drives canAdmin/canWrite owner short-circuits). + // isPlatformStaff = OWNER + platform_operator; drives cross-org visibility and + // act-as only. Operators get isPlatformStaff=true but isPlatformAdmin=false, + // so they can see/act-as everywhere yet hold no owner power (#13). const isPlatformAdmin = isPlatformRole(req.user.role); + const isPlatformStaffUser = isPlatformStaff(req.user.role); req.isPlatformAdmin = isPlatformAdmin; + req.isPlatformOperator = isPlatformStaffUser && !isPlatformAdmin; + req.isPlatformStaff = isPlatformStaffUser; // Build the ordered candidate list of workspace_ids to try. const candidates = []; @@ -113,8 +122,11 @@ function resolveTenancy(req, res, next) { workspace = first; const wm = membershipOf(req.user.id, first.id); context = { workspaceRole: wm.role, actingAs: false }; - } else if (isPlatformAdmin) { - // Platform admin with no direct memberships: pick any workspace (acting-as). + } else if (isPlatformStaffUser) { + // Platform staff (admin or operator) with no direct memberships: pick any + // workspace (acting-as) so they land in a usable context. #13: operators + // included here too - they have no memberships of their own but must be + // able to act-as across orgs. const any = db.prepare('SELECT * FROM workspaces LIMIT 1').get(); if (any) { workspace = any; @@ -152,7 +164,9 @@ function resolveTenancy(req, res, next) { // rather than reusing this helper (different shape needs). function accessibleWorkspaceIds(userId, role) { if (!userId) return []; - if (role === 'platform_admin' || role === 'superadmin') { + // #13: platform staff (admin OR operator) see every workspace - visibility, + // not an owner power. + if (isPlatformStaff(role)) { return db.prepare('SELECT id FROM workspaces').all().map(r => r.id); } return db.prepare(` diff --git a/server/middleware/auth.js b/server/middleware/auth.js index fbd2c62..52660bb 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -101,6 +101,22 @@ function isPlatformRole(role) { return PLATFORM_ROLES.includes(role); } +// Issue #13: platform_operator is cross-org STAFF - it can see and act-as into +// every org and read/write workspace-scoped resources there, but holds NO +// owner-level power (no billing, no org/workspace deletion, no user/role +// management, no shared/template asset curation, no branding). The owner powers +// stay gated on PLATFORM_ROLES / isPlatformRole, which operator is deliberately +// NOT a member of - so every owner capability is deny-by-default for operators, +// and any NEW owner endpoint added later inherits that denial automatically. +// +// PLATFORM_STAFF / isPlatformStaff is the union used ONLY for cross-org +// VISIBILITY + act-as + workspace-scoped read/write. It must never gate an +// owner action. +const PLATFORM_STAFF = ['superadmin', 'platform_admin', 'platform_operator']; +function isPlatformStaff(role) { + return PLATFORM_STAFF.includes(role); +} + function requireAdmin(req, res, next) { if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) { return res.status(403).json({ error: 'Admin access required' }); @@ -118,4 +134,4 @@ function requireSuperAdmin(req, res, next) { // Preferred alias for new code. const requirePlatformAdmin = requireSuperAdmin; -module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, PLATFORM_ROLES, ELEVATED_ROLES }; +module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES }; diff --git a/server/routes/auth.js b/server/routes/auth.js index b9e5ce3..196db92 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -5,7 +5,7 @@ const https = require('https'); const { v4: uuidv4 } = require('uuid'); const { OAuth2Client } = require('google-auth-library'); const { db } = require('../db/database'); -const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, PLATFORM_ROLES } = require('../middleware/auth'); +const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth'); const { resolveTenancy } = require('../lib/tenancy'); const { logActivity, getClientIp } = require('../services/activity'); const { sendSignupEmails } = require('../services/signupEmails'); @@ -324,8 +324,12 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => { // so unclaimed pair-pool devices (workspace_id IS NULL) are correctly excluded. // Microseconds per row at current scale (~37 rows worst case for platform_admin); // not optimizing - revisit if the admin list grows past a few hundred workspaces. - const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin'; - const accessible = isPlatformAdmin + // #13: platform staff (admin OR operator) SEE every workspace (visibility). + // can_admin below is computed separately from isPlatformRole (owner only), so + // operators see all workspaces but get can_admin:false on each. + const isPlatformStaffUser = isPlatformStaff(req.user.role); + const isPlatformAdmin = isPlatformRole(req.user.role); + const accessible = isPlatformStaffUser ? db.prepare(` SELECT w.id, w.name, w.organization_id, o.name AS organization_name, wm.role AS workspace_role, om.role AS org_role, @@ -385,12 +389,13 @@ router.post('/switch-workspace', requireAuth, (req, res) => { const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspace_id); if (!ws) return res.status(404).json({ error: 'Workspace not found' }); - const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin'; + // #13: platform staff (admin OR operator) can switch into any workspace. + const isPlatformStaffUser = isPlatformStaff(req.user.role); const wsMember = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(ws.id, req.user.id); const orgMember = db.prepare(` SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ? `).get(ws.organization_id, req.user.id); - const canAct = isPlatformAdmin + const canAct = isPlatformStaffUser || !!wsMember || (orgMember && (orgMember.role === 'org_owner' || orgMember.role === 'org_admin')); diff --git a/server/routes/devices.js b/server/routes/devices.js index e95ddf8..b5dbe5a 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { db } = require('../db/database'); -const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); +const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = 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'); @@ -42,9 +42,11 @@ router.get('/', (req, res) => { res.json(devices); }); -// List unclaimed provisioning devices (admin only) +// List unclaimed provisioning devices (admin only). +// #13: read-only, so platform_operator may view the pool too (cross-org staff +// troubleshooting). Claiming a device is a separate workspace-scoped mutation. router.get('/unassigned', (req, res) => { - if (!ELEVATED_ROLES.includes(req.user.role)) { + if (!ELEVATED_ROLES.includes(req.user.role) && !isPlatformStaff(req.user.role)) { return res.status(403).json({ error: 'Admin access required' }); } const devices = db.prepare(`