mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-18 20:22:42 -06:00
The legacy /api/auth/users dropdown could write 'superadmin' and 'admin' role strings that not every code path recognized. Some checks matched only 'platform_admin' (tenancy accessContext/resolveTenancy), so a 'superadmin' user could list orgs but not act-as into them. Normalize to the current two-tier platform model (users.role holds the PLATFORM role only; org/workspace roles live in the membership tables): - Migration (idempotent, exact-string): superadmin -> platform_admin, admin -> user. No-ops on rows already in the current model. - Add isPlatformRole() helper in middleware/auth.js; route the two superadmin-excluding checks in tenancy.js through it so a stray 'superadmin' is never treated as lower-privileged (fixes act-as). - Remove the dead/stricter requirePlatformAdmin in permissions.js (bare === 'platform_admin'); the single guard is the one in middleware/auth.js. - Recovery-token default role admin -> platform_admin so emergency recovery keeps full access once 'admin' no longer implies elevation. - PUT /api/auth/users/:id/role whitelist -> ['user','platform_admin']; self-demote guard retargeted via isPlatformRole. - Frontend: platform user-management dropdown now offers User / Platform admin only; owner-delete guard and settings highlight use isPlatformAdmin. EN i18n: add admin.role.platform_admin. Behaviour is identical under HOSTED_INSTANCE set or unset; the migration only touches exact legacy strings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
6.6 KiB
JavaScript
176 lines
6.6 KiB
JavaScript
// Phase 2.1: per-request tenancy resolver.
|
|
//
|
|
// Runs after requireAuth (which sets req.user and req.jwtWorkspaceId).
|
|
// Resolves the active workspace context for this request and attaches:
|
|
//
|
|
// req.workspaceId string | null the workspace this request operates in
|
|
// req.workspace object | null the full workspaces row
|
|
// req.organizationId string | null parent org of req.workspace
|
|
// req.workspaceRole string | null 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
|
|
// req.orgRole string | null 'org_owner' | 'org_admin'
|
|
// req.isPlatformAdmin boolean true when req.user.role is a platform-owner role
|
|
// (isPlatformRole: platform_admin / legacy superadmin)
|
|
// req.actingAs boolean true when the user reached this workspace via
|
|
// org-level or platform-level access rather than
|
|
// a direct workspace_members row
|
|
//
|
|
// Resolution order, top wins:
|
|
// 1. X-Workspace-Id header (for explicit per-request override)
|
|
// 2. ?workspace_id= query param (same purpose, easier in browser dev)
|
|
// 3. JWT current_workspace_id (the user's last switched-to workspace)
|
|
// 4. First workspace_members row for user (sorted by joined_at ASC)
|
|
// 5. For platform_admin only: any workspace
|
|
//
|
|
// Steps 1-3 are validated against access. If a stale value (e.g. user was
|
|
// removed from the workspace) is found, it's discarded and we fall through.
|
|
|
|
'use strict';
|
|
|
|
const { db } = require('../db/database');
|
|
const { isPlatformRole } = require('../middleware/auth');
|
|
|
|
function membershipOf(userId, workspaceId) {
|
|
return db.prepare(
|
|
'SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?'
|
|
).get(workspaceId, userId);
|
|
}
|
|
|
|
function orgMembershipOf(userId, organizationId) {
|
|
return db.prepare(
|
|
'SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?'
|
|
).get(organizationId, userId);
|
|
}
|
|
|
|
function loadWorkspace(workspaceId) {
|
|
if (!workspaceId) return null;
|
|
return db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
|
|
}
|
|
|
|
function firstAccessibleWorkspace(userId) {
|
|
return db.prepare(`
|
|
SELECT w.* FROM workspaces w
|
|
JOIN workspace_members wm ON wm.workspace_id = w.id
|
|
WHERE wm.user_id = ?
|
|
ORDER BY wm.joined_at ASC
|
|
LIMIT 1
|
|
`).get(userId);
|
|
}
|
|
|
|
// Check whether userId can access workspace via any path (member, org admin,
|
|
// or platform admin). 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 };
|
|
}
|
|
const orgMembership = orgMembershipOf(userId, workspace.organization_id);
|
|
if (orgMembership && (orgMembership.role === 'org_owner' || orgMembership.role === 'org_admin')) {
|
|
return { workspaceRole: null, actingAs: true };
|
|
}
|
|
if (isPlatformAdmin) {
|
|
return { workspaceRole: null, actingAs: true };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveTenancy(req, res, next) {
|
|
if (!req.user) {
|
|
// Should not happen when chained after requireAuth, but tolerate optionalAuth flows.
|
|
return next();
|
|
}
|
|
|
|
const isPlatformAdmin = isPlatformRole(req.user.role);
|
|
req.isPlatformAdmin = isPlatformAdmin;
|
|
|
|
// Build the ordered candidate list of workspace_ids to try.
|
|
const candidates = [];
|
|
const headerWs = (req.headers['x-workspace-id'] || '').trim();
|
|
if (headerWs) candidates.push(headerWs);
|
|
if (req.query && req.query.workspace_id) candidates.push(String(req.query.workspace_id));
|
|
if (req.jwtWorkspaceId) candidates.push(req.jwtWorkspaceId);
|
|
|
|
let workspace = null;
|
|
let context = null;
|
|
for (const wsId of candidates) {
|
|
const ws = loadWorkspace(wsId);
|
|
if (!ws) continue;
|
|
const ctx = accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) continue;
|
|
workspace = ws;
|
|
context = ctx;
|
|
break;
|
|
}
|
|
|
|
if (!workspace) {
|
|
// Fall back to the user's first workspace_members row.
|
|
const first = firstAccessibleWorkspace(req.user.id);
|
|
if (first) {
|
|
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).
|
|
const any = db.prepare('SELECT * FROM workspaces LIMIT 1').get();
|
|
if (any) {
|
|
workspace = any;
|
|
context = { workspaceRole: null, actingAs: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (workspace) {
|
|
req.workspaceId = workspace.id;
|
|
req.workspace = workspace;
|
|
req.organizationId = workspace.organization_id;
|
|
req.workspaceRole = context.workspaceRole;
|
|
req.actingAs = context.actingAs;
|
|
const orgMembership = orgMembershipOf(req.user.id, workspace.organization_id);
|
|
req.orgRole = orgMembership ? orgMembership.role : null;
|
|
} else {
|
|
req.workspaceId = null;
|
|
req.workspace = null;
|
|
req.organizationId = null;
|
|
req.workspaceRole = null;
|
|
req.orgRole = null;
|
|
req.actingAs = false;
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
// Enumerate every workspace_id the given user has any path into:
|
|
// - direct workspace_members rows
|
|
// - any workspace in an org where they are org_owner / org_admin
|
|
// - platform_admin / superadmin: every workspace in the system
|
|
// Used by socket.io rooms (Phase 2.3) to scope outbound broadcasts. /me's
|
|
// accessible_workspaces query mirrors this access logic but selects full rows
|
|
// rather than reusing this helper (different shape needs).
|
|
function accessibleWorkspaceIds(userId, role) {
|
|
if (!userId) return [];
|
|
if (role === 'platform_admin' || role === 'superadmin') {
|
|
return db.prepare('SELECT id FROM workspaces').all().map(r => r.id);
|
|
}
|
|
return db.prepare(`
|
|
SELECT workspace_id AS id FROM workspace_members WHERE user_id = ?
|
|
UNION
|
|
SELECT w.id FROM workspaces w
|
|
JOIN organization_members om ON om.organization_id = w.organization_id
|
|
WHERE om.user_id = ? AND om.role IN ('org_owner', 'org_admin')
|
|
`).all(userId, userId).map(r => r.id);
|
|
}
|
|
|
|
module.exports = {
|
|
resolveTenancy,
|
|
// Exported for testing / direct use by routes that need ad-hoc checks.
|
|
accessContext,
|
|
membershipOf,
|
|
orgMembershipOf,
|
|
firstAccessibleWorkspace,
|
|
accessibleWorkspaceIds,
|
|
};
|