mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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>
148 lines
5.6 KiB
JavaScript
148 lines
5.6 KiB
JavaScript
// Phase 2.1: permission helpers.
|
|
//
|
|
// Routes call these as Express middleware to gate access, or as predicate
|
|
// functions to branch within a handler. They presume resolveTenancy has
|
|
// already attached req.workspaceId / req.workspaceRole / req.orgRole /
|
|
// req.isPlatformAdmin.
|
|
//
|
|
// Layering (top wins):
|
|
// 1. req.isPlatformAdmin -> allow anything
|
|
// 2. req.orgRole in {org_owner, org_admin} -> allow read/write/admin within the org
|
|
// org_owner also has billing.write and org.delete (not exposed in 2.1)
|
|
// 3. req.workspaceRole in {workspace_admin, workspace_editor, workspace_viewer}
|
|
// gates resource access per the role's bands
|
|
|
|
'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.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.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; // owner only - NOT platform_operator
|
|
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
|
|
return req.workspaceRole === 'workspace_admin';
|
|
}
|
|
|
|
function isOrgAdmin(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
return req.orgRole === 'org_owner' || req.orgRole === 'org_admin';
|
|
}
|
|
|
|
function isOrgOwner(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
return req.orgRole === 'org_owner';
|
|
}
|
|
|
|
// ---- middleware variants ----
|
|
|
|
function requireWorkspace(req, res, next) {
|
|
if (!req.workspaceId) {
|
|
return res.status(403).json({ error: 'No workspace context' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireWorkspaceRead(req, res, next) {
|
|
if (!canRead(req)) {
|
|
return res.status(403).json({ error: 'Workspace access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireWorkspaceWrite(req, res, next) {
|
|
if (!canWrite(req)) {
|
|
return res.status(403).json({ error: 'Workspace editor or admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireWorkspaceAdmin(req, res, next) {
|
|
if (!canAdmin(req)) {
|
|
return res.status(403).json({ error: 'Workspace admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireOrgAdmin(req, res, next) {
|
|
if (!isOrgAdmin(req)) {
|
|
return res.status(403).json({ error: 'Organization admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireOrgOwner(req, res, next) {
|
|
if (!isOrgOwner(req)) {
|
|
return res.status(403).json({ error: 'Organization owner required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// #14: the dead/stricter requirePlatformAdmin that used to live here (bare
|
|
// `=== 'platform_admin'`, excluding legacy superadmin) was removed. The single
|
|
// platform-admin guard is requirePlatformAdmin in server/middleware/auth.js,
|
|
// which is the alias every route already imports and which accepts the full
|
|
// PLATFORM_ROLES set via isPlatformRole().
|
|
|
|
// Decoupled "can admin this workspace" predicate. Unlike canAdmin(req) above,
|
|
// this takes an explicit (user, workspace) pair instead of reading from req,
|
|
// so it works for routes that operate on a target workspace specified by URL
|
|
// param (rename, future settings/delete) rather than the caller's currently
|
|
// active one. Does its own DB lookups against workspace_members + organization_members.
|
|
function canAdminWorkspace(db, user, workspace) {
|
|
if (!user || !workspace) return false;
|
|
// 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;
|
|
const wm = db.prepare('SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
|
|
.get(workspace.id, user.id);
|
|
return wm && wm.role === 'workspace_admin';
|
|
}
|
|
|
|
// Read-access companion to canAdminWorkspace. Same (user, workspace) shape but
|
|
// accepts any workspace_members role (admin/editor/viewer) in addition to the
|
|
// org / platform paths. Used by GET endpoints on a URL-param target workspace
|
|
// where resolveTenancy is not on the request (e.g. /api/workspaces/:id/members).
|
|
function canAccessWorkspace(db, user, workspace) {
|
|
if (!user || !workspace) return false;
|
|
// 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;
|
|
const wm = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
|
|
.get(workspace.id, user.id);
|
|
return !!wm;
|
|
}
|
|
|
|
module.exports = {
|
|
// boolean predicates
|
|
canRead, canWrite, canAdmin, canAdminWorkspace, canAccessWorkspace, isOrgAdmin, isOrgOwner,
|
|
// express middleware
|
|
requireWorkspace,
|
|
requireWorkspaceRead,
|
|
requireWorkspaceWrite,
|
|
requireWorkspaceAdmin,
|
|
requireOrgAdmin,
|
|
requireOrgOwner,
|
|
};
|