mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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:
parent
797eab7c8d
commit
48902f6807
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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') || '{}');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
Loading…
Reference in a new issue