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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-05-16 11:50:37 -05:00
parent 3294525f4c
commit d2a3bdfd15
2 changed files with 15 additions and 12 deletions

View file

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

View file

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