From 9584b99c8aaea2500c59db18adcaf70da1a84deb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 14 Feb 2026 14:10:56 -0800 Subject: [PATCH] address bugbot --- .../[id]/members/[memberId]/route.ts | 27 +---- .../app/api/workspaces/members/[id]/route.ts | 27 +---- apps/sim/lib/credentials/access.ts | 108 +++++++++++++++++- 3 files changed, 114 insertions(+), 48 deletions(-) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index 73c47d890..30afdda57 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -22,9 +22,10 @@ */ import { db } from '@sim/db' -import { credential, credentialMember, permissions, user, workspace } from '@sim/db/schema' +import { permissions, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' +import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -215,27 +216,7 @@ export const DELETE = withAdminAuthParams(async (_, context) => { await db.delete(permissions).where(eq(permissions.id, memberId)) - // Revoke credential memberships for all credentials in this workspace - const workspaceCredentialIds = await db - .select({ id: credential.id }) - .from(credential) - .where(eq(credential.workspaceId, workspaceId)) - - if (workspaceCredentialIds.length > 0) { - await db - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where( - and( - eq(credentialMember.userId, existingMember.userId), - eq(credentialMember.status, 'active'), - inArray( - credentialMember.credentialId, - workspaceCredentialIds.map((c) => c.id) - ) - ) - ) - } + await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId) logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { userId: existingMember.userId, diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index ae677e27b..9d9364807 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -1,10 +1,11 @@ import { db } from '@sim/db' -import { credential, credentialMember, permissions, workspace } from '@sim/db/schema' +import { permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceMemberAPI') @@ -101,27 +102,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i ) ) - // Revoke credential memberships for all credentials in this workspace - const workspaceCredentialIds = await db - .select({ id: credential.id }) - .from(credential) - .where(eq(credential.workspaceId, workspaceId)) - - if (workspaceCredentialIds.length > 0) { - await db - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where( - and( - eq(credentialMember.userId, userId), - eq(credentialMember.status, 'active'), - inArray( - credentialMember.credentialId, - workspaceCredentialIds.map((c) => c.id) - ) - ) - ) - } + await revokeWorkspaceCredentialMemberships(workspaceId, userId) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 21b12b04d..18573879a 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -1,8 +1,11 @@ import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { credential, credentialMember, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, ne } from 'drizzle-orm' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +const logger = createLogger('CredentialAccess') + type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect @@ -60,3 +63,104 @@ export async function getCredentialActorContext( isAdmin, } } + +/** + * Revokes all credential memberships for a user across a workspace. + * Before revoking, ensures the workspace owner is an admin on any credential + * where the removed user is the sole active admin, preventing orphaned credentials. + */ +export async function revokeWorkspaceCredentialMemberships( + workspaceId: string, + userId: string +): Promise { + const workspaceCredentialIds = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.workspaceId, workspaceId)) + + if (workspaceCredentialIds.length === 0) return + + const credIds = workspaceCredentialIds.map((c) => c.id) + + const [workspaceRow] = await db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + const ownerId = workspaceRow?.ownerId + + if (ownerId && ownerId !== userId) { + const userAdminMemberships = await db + .select({ credentialId: credentialMember.credentialId }) + .from(credentialMember) + .where( + and( + eq(credentialMember.userId, userId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + inArray(credentialMember.credentialId, credIds) + ) + ) + + for (const { credentialId: credId } of userAdminMemberships) { + const otherAdmins = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, credId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + ne(credentialMember.userId, userId) + ) + ) + .limit(1) + + if (otherAdmins.length > 0) continue + + const now = new Date() + const [existingOwnerMembership] = await db + .select({ id: credentialMember.id, status: credentialMember.status }) + .from(credentialMember) + .where(and(eq(credentialMember.credentialId, credId), eq(credentialMember.userId, ownerId))) + .limit(1) + + if (existingOwnerMembership) { + await db + .update(credentialMember) + .set({ role: 'admin', status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existingOwnerMembership.id)) + } else { + await db.insert(credentialMember).values({ + id: crypto.randomUUID(), + credentialId: credId, + userId: ownerId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: ownerId, + createdAt: now, + updatedAt: now, + }) + } + + logger.info('Assigned workspace owner as credential admin before member removal', { + credentialId: credId, + ownerId, + removedUserId: userId, + }) + } + } + + await db + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where( + and( + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active'), + inArray(credentialMember.credentialId, credIds) + ) + ) +}