From 8d78ecbf19d4e811c244fd3b8bcf7cc0399e399d Mon Sep 17 00:00:00 2001 From: "mini.jeong" Date: Thu, 21 May 2026 19:18:45 +0900 Subject: [PATCH 1/5] fix(credentials): reflect workspace permission in credential member role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace admin users were incorrectly assigned 'member' role on credential_member when workspace-scoped secrets were created or synced. Only the workspace owner got 'admin'. Now workspace permissions table is consulted: owner/admin → credential admin, write/read → member. - environment.ts: query workspace permissions in ensureWorkspaceCredentialMemberships - route.ts POST: apply same mapping during credential creation --- apps/sim/app/api/credentials/route.ts | 25 +++++++++++++++++++------ apps/sim/lib/credentials/environment.ts | 22 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 64a3d3f9511..2ea65e684e9 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credential, credentialMember, workspace } from '@sim/db/schema' +import { account, credential, credentialMember, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -535,17 +535,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) { - const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId) + const [workspaceUserIds, wsPermissionRows] = await Promise.all([ + getWorkspaceMemberUserIds(workspaceId), + db + .select({ userId: permissions.userId, permissionType: permissions.permissionType }) + .from(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)) + ), + ]) + const wsPermissionByUser = new Map( + wsPermissionRows.map((row) => [row.userId, row.permissionType]) + ) if (workspaceUserIds.length > 0) { for (const memberUserId of workspaceUserIds) { + const wsPermission = wsPermissionByUser.get(memberUserId) + const isAdmin = + memberUserId === workspaceRow.ownerId || + memberUserId === session.user.id || + wsPermission === 'admin' await tx.insert(credentialMember).values({ id: generateId(), credentialId, userId: memberUserId, - role: - memberUserId === workspaceRow.ownerId || memberUserId === session.user.id - ? 'admin' - : 'member', + role: isAdmin ? 'admin' : 'member', status: 'active', joinedAt: now, invitedBy: session.user.id, diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 0ace9884075..2b325c70632 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -64,10 +64,20 @@ export async function getUserWorkspaceIds(userId: string): Promise { async function ensureWorkspaceCredentialMemberships( credentialId: string, memberUserIds: string[], - ownerUserId: string + ownerUserId: string, + workspaceId: string ) { if (!memberUserIds.length) return + const workspacePermissionRows = await db + .select({ userId: permissions.userId, permissionType: permissions.permissionType }) + .from(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + + const wsPermissionByUser = new Map( + workspacePermissionRows.map((row) => [row.userId, row.permissionType]) + ) + const existingMemberships = await db .select({ id: credentialMember.id, @@ -87,7 +97,8 @@ async function ensureWorkspaceCredentialMemberships( const now = new Date() for (const memberUserId of memberUserIds) { - const targetRole = memberUserId === ownerUserId ? 'admin' : 'member' + const wsPermission = wsPermissionByUser.get(memberUserId) + const targetRole = memberUserId === ownerUserId || wsPermission === 'admin' ? 'admin' : 'member' const existing = byUserId.get(memberUserId) if (existing) { if (existing.status === 'revoked') { @@ -182,7 +193,12 @@ export async function syncWorkspaceEnvCredentials(params: { } for (const credentialId of credentialIdsToEnsureMembership) { - await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, workspaceRow.ownerId) + await ensureWorkspaceCredentialMemberships( + credentialId, + memberUserIds, + workspaceRow.ownerId, + workspaceId + ) } if (normalizedKeys.length > 0) { From 1bbf7c6ce49cd6efe5396c22f6011ddf58bcf6d6 Mon Sep 17 00:00:00 2001 From: "mini.jeong" Date: Thu, 21 May 2026 19:35:28 +0900 Subject: [PATCH 2/5] fix(credentials): apply permission mapping in createWorkspaceEnvCredentials Address Bugbot review: the parallel credential creation path (createWorkspaceEnvCredentials) still used owner-only admin logic. Now queries workspace permissions table for consistent role mapping. --- apps/sim/lib/credentials/environment.ts | 35 +++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 2b325c70632..2fa72709d13 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -269,19 +269,32 @@ export async function createWorkspaceEnvCredentials(params: { if (createdIds.length === 0 || memberUserIds.length === 0) return + const wsPermissionRows = await db + .select({ userId: permissions.userId, permissionType: permissions.permissionType }) + .from(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + + const wsPermissionByUser = new Map( + wsPermissionRows.map((row) => [row.userId, row.permissionType]) + ) + // Bulk-insert memberships for all new credentials × all workspace members in one query const membershipValues = createdIds.flatMap((credentialId) => - memberUserIds.map((memberUserId) => ({ - id: generateId(), - credentialId, - userId: memberUserId, - role: (memberUserId === ownerUserId ? 'admin' : 'member') as 'admin' | 'member', - status: 'active' as const, - joinedAt: now, - invitedBy: ownerUserId, - createdAt: now, - updatedAt: now, - })) + memberUserIds.map((memberUserId) => { + const wsPermission = wsPermissionByUser.get(memberUserId) + const isAdmin = memberUserId === ownerUserId || wsPermission === 'admin' + return { + id: generateId(), + credentialId, + userId: memberUserId, + role: (isAdmin ? 'admin' : 'member') as 'admin' | 'member', + status: 'active' as const, + joinedAt: now, + invitedBy: ownerUserId, + createdAt: now, + updatedAt: now, + } + }) ) await db.insert(credentialMember).values(membershipValues).onConflictDoNothing() From 71db264902c85549173e117de5eb0868a8cb4f0f Mon Sep 17 00:00:00 2001 From: "mini.jeong" Date: Thu, 21 May 2026 19:45:18 +0900 Subject: [PATCH 3/5] perf(credentials): hoist permissions query out of per-credential loop Address Bugbot review: permissions query was executed N times (once per credential) inside ensureWorkspaceCredentialMemberships loop. Now queried once in the caller and passed as a Map parameter. --- apps/sim/lib/credentials/environment.ts | 41 ++++++++++++------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 2fa72709d13..ec5c6aec530 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -65,19 +65,10 @@ async function ensureWorkspaceCredentialMemberships( credentialId: string, memberUserIds: string[], ownerUserId: string, - workspaceId: string + wsPermissionByUser: Map ) { if (!memberUserIds.length) return - const workspacePermissionRows = await db - .select({ userId: permissions.userId, permissionType: permissions.permissionType }) - .from(permissions) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - - const wsPermissionByUser = new Map( - workspacePermissionRows.map((row) => [row.userId, row.permissionType]) - ) - const existingMemberships = await db .select({ id: credentialMember.id, @@ -137,17 +128,25 @@ export async function syncWorkspaceEnvCredentials(params: { actingUserId: string }) { const { workspaceId, envKeys, actingUserId } = params - const [[workspaceRow], memberUserIds] = await Promise.all([ + const [[workspaceRow], memberUserIds, wsPermissionRows] = await Promise.all([ db .select({ ownerId: workspace.ownerId }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1), getWorkspaceMemberUserIds(workspaceId), + db + .select({ userId: permissions.userId, permissionType: permissions.permissionType }) + .from(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), ]) if (!workspaceRow) return + const wsPermissionByUser = new Map( + wsPermissionRows.map((row) => [row.userId, row.permissionType]) + ) + const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean))) const existingCredentials = await db .select({ @@ -197,7 +196,7 @@ export async function syncWorkspaceEnvCredentials(params: { credentialId, memberUserIds, workspaceRow.ownerId, - workspaceId + wsPermissionByUser ) } @@ -232,18 +231,25 @@ export async function createWorkspaceEnvCredentials(params: { const keys = Array.from(new Set(newKeys.filter(Boolean))) if (keys.length === 0) return - const [[workspaceRow], memberUserIds] = await Promise.all([ + const [[workspaceRow], memberUserIds, wsPermissionRows] = await Promise.all([ db .select({ ownerId: workspace.ownerId }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1), getWorkspaceMemberUserIds(workspaceId), + db + .select({ userId: permissions.userId, permissionType: permissions.permissionType }) + .from(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), ]) if (!workspaceRow) return const ownerUserId = workspaceRow.ownerId + const wsPermissionByUser = new Map( + wsPermissionRows.map((row) => [row.userId, row.permissionType]) + ) const now = new Date() const createdIds: string[] = [] @@ -269,15 +275,6 @@ export async function createWorkspaceEnvCredentials(params: { if (createdIds.length === 0 || memberUserIds.length === 0) return - const wsPermissionRows = await db - .select({ userId: permissions.userId, permissionType: permissions.permissionType }) - .from(permissions) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - - const wsPermissionByUser = new Map( - wsPermissionRows.map((row) => [row.userId, row.permissionType]) - ) - // Bulk-insert memberships for all new credentials × all workspace members in one query const membershipValues = createdIds.flatMap((credentialId) => memberUserIds.map((memberUserId) => { From d1891c9e2be331ddf6996f9d40a1687a0c72d7a9 Mon Sep 17 00:00:00 2001 From: "mini.jeong" Date: Thu, 21 May 2026 20:08:21 +0900 Subject: [PATCH 4/5] perf(credentials): eliminate redundant permissions query Derive memberUserIds from wsPermissionRows + workspace owner instead of calling getWorkspaceMemberUserIds separately. This removes a duplicate query on the permissions table at every call site. --- apps/sim/app/api/credentials/route.ts | 19 +++++++++---------- apps/sim/lib/credentials/environment.ts | 12 ++++++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 2ea65e684e9..8193f892af5 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -22,7 +22,6 @@ import { normalizeAtlassianDomain, validateAtlassianServiceAccount, } from '@/lib/credentials/atlassian-service-account' -import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth' import { @@ -535,18 +534,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) { - const [workspaceUserIds, wsPermissionRows] = await Promise.all([ - getWorkspaceMemberUserIds(workspaceId), - db - .select({ userId: permissions.userId, permissionType: permissions.permissionType }) - .from(permissions) - .where( - and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)) - ), - ]) + const wsPermissionRows = await db + .select({ userId: permissions.userId, permissionType: permissions.permissionType }) + .from(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)) + ) const wsPermissionByUser = new Map( wsPermissionRows.map((row) => [row.userId, row.permissionType]) ) + const workspaceUserIds = Array.from( + new Set([workspaceRow.ownerId, ...wsPermissionRows.map((row) => row.userId)]) + ) if (workspaceUserIds.length > 0) { for (const memberUserId of workspaceUserIds) { const wsPermission = wsPermissionByUser.get(memberUserId) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index ec5c6aec530..9b63709db29 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -128,13 +128,12 @@ export async function syncWorkspaceEnvCredentials(params: { actingUserId: string }) { const { workspaceId, envKeys, actingUserId } = params - const [[workspaceRow], memberUserIds, wsPermissionRows] = await Promise.all([ + const [[workspaceRow], wsPermissionRows] = await Promise.all([ db .select({ ownerId: workspace.ownerId }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1), - getWorkspaceMemberUserIds(workspaceId), db .select({ userId: permissions.userId, permissionType: permissions.permissionType }) .from(permissions) @@ -146,6 +145,9 @@ export async function syncWorkspaceEnvCredentials(params: { const wsPermissionByUser = new Map( wsPermissionRows.map((row) => [row.userId, row.permissionType]) ) + const memberUserIds = Array.from( + new Set([workspaceRow.ownerId, ...wsPermissionRows.map((row) => row.userId)]) + ) const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean))) const existingCredentials = await db @@ -231,13 +233,12 @@ export async function createWorkspaceEnvCredentials(params: { const keys = Array.from(new Set(newKeys.filter(Boolean))) if (keys.length === 0) return - const [[workspaceRow], memberUserIds, wsPermissionRows] = await Promise.all([ + const [[workspaceRow], wsPermissionRows] = await Promise.all([ db .select({ ownerId: workspace.ownerId }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1), - getWorkspaceMemberUserIds(workspaceId), db .select({ userId: permissions.userId, permissionType: permissions.permissionType }) .from(permissions) @@ -250,6 +251,9 @@ export async function createWorkspaceEnvCredentials(params: { const wsPermissionByUser = new Map( wsPermissionRows.map((row) => [row.userId, row.permissionType]) ) + const memberUserIds = Array.from( + new Set([ownerUserId, ...wsPermissionRows.map((row) => row.userId)]) + ) const now = new Date() const createdIds: string[] = [] From 78b6c883343a338da6d039e03ddc20a448e3b5cd Mon Sep 17 00:00:00 2001 From: "mini.jeong" Date: Thu, 21 May 2026 20:14:31 +0900 Subject: [PATCH 5/5] fix(credentials): remove session.user.id from admin check for consistency The credential creator (session.user.id) was always granted admin role regardless of their workspace permission. This created inconsistency with environment.ts sync logic which correctly derives role solely from workspace permission. Now both paths use the same mapping. --- apps/sim/app/api/credentials/route.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 8193f892af5..bdaeff24619 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -549,10 +549,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (workspaceUserIds.length > 0) { for (const memberUserId of workspaceUserIds) { const wsPermission = wsPermissionByUser.get(memberUserId) - const isAdmin = - memberUserId === workspaceRow.ownerId || - memberUserId === session.user.id || - wsPermission === 'admin' + const isAdmin = memberUserId === workspaceRow.ownerId || wsPermission === 'admin' await tx.insert(credentialMember).values({ id: generateId(), credentialId,