From d2a3bdfd15893294928ff15b4baa0f66bd294019 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 16 May 2026 11:50:37 -0500 Subject: [PATCH] fix(auth/me): broaden non-admin accessible_workspaces to include org_owner/org_admin paths The non-admin branch of /me's accessible_workspaces query drove from workspace_members, so users with org_owner or org_admin on an organization but no direct workspace_members row were missing those workspaces from their /me response - and therefore from the switcher dropdown. Mirrors the access logic in accessibleWorkspaceIds() (lib/tenancy.js) while keeping the full-row SELECT shape /me needs. Verified end-to-end with switcher-test@local.test acting as org_owner of Acme Studios with no workspace_members row on Studio B - Studio B now appears in /me's accessible_workspaces with workspace_role: null, can_admin: true. Also updates the stale TODO comment in tenancy.js that flagged this exact gap. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/lib/tenancy.js | 7 +++---- server/routes/auth.js | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/server/lib/tenancy.js b/server/lib/tenancy.js index a86368f..49f2ee7 100644 --- a/server/lib/tenancy.js +++ b/server/lib/tenancy.js @@ -142,10 +142,9 @@ function resolveTenancy(req, res, next) { // - 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. Also a -// candidate to broaden /me's accessible_workspaces query - currently /me only -// returns direct workspace_members for non-admins, missing the org-admin -// path. Future cleanup tracked in the handoff doc. +// 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') { diff --git a/server/routes/auth.js b/server/routes/auth.js index e0e48db..3bc899d 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -295,11 +295,14 @@ function getMicrosoftProfile(accessToken) { router.get('/me', requireAuth, resolveTenancy, (req, res) => { // Platform admins see every workspace in the system (via the LEFT JOIN they // still get their own workspace_role for direct memberships; NULL elsewhere, - // matching accessContext's actingAs semantics). Regular users see only - // workspaces they have a direct workspace_members row in. Role is read from - // the signed JWT (not user-supplied), so non-admins cannot reach the admin - // branch. No cap on the admin list yet - revisit at 50+ workspaces when - // dropdown UX without search starts to degrade. + // matching accessContext's actingAs semantics). Regular users see every + // workspace they can reach via either path: direct workspace_members row, OR + // org_owner / org_admin on the parent organization. Mirrors the access + // logic in accessibleWorkspaceIds() (lib/tenancy.js); kept as a separate + // query rather than reusing it because /me needs full row shape, not just + // IDs. Role is read from the signed JWT (not user-supplied), so non-admins + // cannot reach the admin branch. No cap on the admin list yet - revisit at + // 50+ workspaces when dropdown UX without search starts to degrade. // // Each accessible_workspaces entry also carries `can_admin: bool` so the // UI can render admin affordances (rename pencil etc.) only where the @@ -325,11 +328,12 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => { SELECT w.id, w.name, w.organization_id, o.name AS organization_name, wm.role AS workspace_role, om.role AS org_role, (SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count - FROM workspace_members wm - JOIN workspaces w ON w.id = wm.workspace_id + FROM workspaces w JOIN organizations o ON o.id = w.organization_id + LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ? - WHERE wm.user_id = ? + WHERE wm.user_id IS NOT NULL + OR (om.user_id IS NOT NULL AND om.role IN ('org_owner', 'org_admin')) ORDER BY o.name, w.name `).all(req.user.id, req.user.id);