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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-05 10:30:21 -05:00
parent 797eab7c8d
commit 48902f6807
7 changed files with 75 additions and 26 deletions

View file

@ -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).

View file

@ -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') || '{}');

View file

@ -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;

View file

@ -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(`

View file

@ -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 };

View file

@ -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'));

View file

@ -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(`