Skip to content

Commit 04a8c0a

Browse files
improvement(admin): update defaults for better UX (#6112)
* improvement(admin): uupdate defaults for better UX * refactor: address review nits on the admin/invitation lock work Correct the attach lock-order comment. The order matches admin move, and what makes it mandatory is invitation acceptance: it holds `workspace-invitations:<id>` while waiting for the workspace row, so row-locking first (as this did) deadlocks against it. The previous ownership-transfer justification did not hold — that path takes the organization lock before its workspace rows too, so the two agree on order rather than inverting. Drop `cancelInvitation`. The `revokeInvitationAsAdmin` extraction left it with no callers, and an unlocked, unauthorized `status = 'cancelled'` flip sitting next to the fenced replacement is easy to reach for by mistake. Drop the unused `executor` parameters from `hasWorkspaceAdminAccess` and `isOrganizationAdminOrOwner`. No caller threads a transaction through either, and the former goes back to delegating to `checkWorkspaceAccess` instead of re-deriving the same permission itself. Import `chunkArray` from `@sim/utils/helpers` everywhere and remove the re-export from `batch-delete.ts`, so the symbol has one source rather than a non-barrel shim plus the package. Restore the bounded attachability check in `addDashboardOrganizationMember`: scope the query to the selected ids instead of listing every attachable workspace and scanning that array per selection. Resolve the credential-creation permission through `getEffectiveWorkspacePermission` rather than a second copy of the org-admin derivation ladder, so the rule cannot drift from the shared resolver. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(invitations): drop dead code left by the revocation extraction `revokeInvitationWorkspaceGrant` lost its only caller when the DELETE route moved to `revokeInvitationAsAdmin`, leaving a locked wrapper nothing invoked. Remove it and fold its documentation into `revokeInvitationWorkspaceGrantTx`, which direct grants and scoped revocation still call. The grant-revocation test now drives the transactional form directly, so the sibling-grant and final-grant-cancels behaviour it covers stays under test. `isSameOrgMember` has had no caller since before this branch — direct grant resolves membership through `getUserOrganization` inside its own transaction — so it and its tests go too. `getWorkspaceMembership` is no longer imported outside its module now that credential creation reads `getCredentialCreationWorkspaceContext`; make it module-private rather than leave it on the public surface. Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove four uncalled billing and large-value helpers Each was checked by hand across every file type, including barrel re-exports and string references, rather than taken from a static analyzer. `isUserMemberOfOrganization` has no reference anywhere. `reapplyPaidOrgJoinBillingForExistingMember` only ever ran from two lock-ordering tests. The transaction-enlisted form it delegated to is what the subscription webhooks call and what those tests actually assert on, so they now drive it directly. The assertions are unchanged: the wrapper contributed a transaction, an organization lock and a membership existence check, none of which appear in the recorded operations. `replaceLargeValueReferences` and `replaceLargeValueReferencesWithClient` are both thin wrappers over `replaceLargeValueReferenceKeysWithClient`, which execution logging, human-in-the-loop resume and the trace backfill all still call. The single test covering a wrapper now composes the key collection itself and targets that live helper. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c553de1 commit 04a8c0a

56 files changed

Lines changed: 3661 additions & 858 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/credentials/route.test.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,24 @@
33
*
44
* @vitest-environment node
55
*/
6-
import { auditMock, authMockFns, createMockRequest, posthogServerMock } from '@sim/testing'
6+
import {
7+
auditMock,
8+
authMockFns,
9+
createMockRequest,
10+
dbChainMockFns,
11+
posthogServerMock,
12+
resetDbChainMock,
13+
} from '@sim/testing'
714
import { beforeEach, describe, expect, it, vi } from 'vitest'
815
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
916

1017
const {
1118
mockCheckWorkspaceAccess,
12-
mockGetWorkspaceMembership,
19+
mockGetCredentialCreationWorkspaceContext,
1320
mockVerifyAndBuildServiceAccountSecret,
1421
} = vi.hoisted(() => ({
1522
mockCheckWorkspaceAccess: vi.fn(),
16-
mockGetWorkspaceMembership: vi.fn(),
23+
mockGetCredentialCreationWorkspaceContext: vi.fn(),
1724
mockVerifyAndBuildServiceAccountSecret: vi.fn(),
1825
}))
1926

@@ -25,7 +32,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
2532
}))
2633

2734
vi.mock('@/lib/credentials/environment', () => ({
28-
getWorkspaceMembership: mockGetWorkspaceMembership,
35+
getCredentialCreationWorkspaceContext: mockGetCredentialCreationWorkspaceContext,
2936
}))
3037

3138
vi.mock('@/lib/credentials/oauth', () => ({
@@ -52,6 +59,7 @@ const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
5259
describe('POST /api/credentials', () => {
5360
beforeEach(() => {
5461
vi.clearAllMocks()
62+
resetDbChainMock()
5563
authMockFns.mockGetSession.mockResolvedValue({
5664
user: { id: 'user-1', name: 'Test User', email: 'test@example.com' },
5765
})
@@ -60,7 +68,12 @@ describe('POST /api/credentials', () => {
6068
canWrite: true,
6169
canAdmin: true,
6270
})
63-
mockGetWorkspaceMembership.mockResolvedValue({ ownerId: 'user-1', memberUserIds: ['user-1'] })
71+
mockGetCredentialCreationWorkspaceContext.mockResolvedValue({
72+
ownerId: 'user-1',
73+
organizationId: 'org-1',
74+
memberUserIds: ['user-1'],
75+
canWrite: true,
76+
})
6477
})
6578

6679
describe('client-credential service accounts', () => {
@@ -156,5 +169,42 @@ describe('POST /api/credentials', () => {
156169
expect(data.error).toContain('clientSecret is required')
157170
expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled()
158171
})
172+
173+
it('re-authorizes a personal credential after the shared org/user locks', async () => {
174+
mockGetCredentialCreationWorkspaceContext
175+
.mockResolvedValueOnce({
176+
ownerId: 'user-1',
177+
organizationId: 'org-1',
178+
memberUserIds: ['user-1'],
179+
canWrite: true,
180+
})
181+
.mockResolvedValueOnce({
182+
ownerId: 'org-owner',
183+
organizationId: 'org-1',
184+
memberUserIds: ['org-owner'],
185+
canWrite: false,
186+
})
187+
188+
const req = createMockRequest('POST', {
189+
workspaceId: WORKSPACE_ID,
190+
type: 'env_personal',
191+
envKey: 'MY_API_KEY',
192+
})
193+
194+
const response = await POST(req)
195+
const data = await response.json()
196+
197+
expect(response.status).toBe(403)
198+
expect(data).toEqual({ error: 'Write permission required' })
199+
expect(mockGetCredentialCreationWorkspaceContext).toHaveBeenCalledTimes(2)
200+
expect(dbChainMockFns.execute).toHaveBeenCalled()
201+
expect(mockGetCredentialCreationWorkspaceContext.mock.invocationCallOrder[0]).toBeLessThan(
202+
dbChainMockFns.execute.mock.invocationCallOrder[0]
203+
)
204+
expect(dbChainMockFns.execute.mock.invocationCallOrder.at(-1)).toBeLessThan(
205+
mockGetCredentialCreationWorkspaceContext.mock.invocationCallOrder[1]
206+
)
207+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
208+
})
159209
})
160210
})

apps/sim/app/api/credentials/route.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/api/contracts/credentials'
1414
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1515
import { getSession } from '@/lib/auth'
16+
import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
1617
import { generateRequestId } from '@/lib/core/utils/request'
1718
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1819
import {
@@ -21,7 +22,7 @@ import {
2122
SHARED_CREDENTIAL_TYPES,
2223
} from '@/lib/credentials/access'
2324
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
24-
import { getWorkspaceMembership } from '@/lib/credentials/environment'
25+
import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment'
2526
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
2627
import {
2728
ServiceAccountSecretError,
@@ -509,10 +510,52 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
509510
resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId
510511
? clientCredentialId
511512
: generateId()
512-
const { ownerId: workspaceOwnerId, memberUserIds: workspaceMemberUserIds } =
513-
await getWorkspaceMembership(workspaceId)
514513

515-
await db.transaction(async (tx) => {
514+
const creationResult = await db.transaction(async (tx) => {
515+
/**
516+
* Discover the organization lock scope inside this transaction, then
517+
* acquire the same organization → user → membership locks as org
518+
* removal/transfer and re-authorize from the transaction before writing.
519+
*
520+
* If this insert wins, transfer sees the new source-owned personal
521+
* credential and blocks. If transfer wins, its permission/member cleanup
522+
* is visible to the authoritative re-read below and the insert is
523+
* refused.
524+
*/
525+
const plannedContext = await getCredentialCreationWorkspaceContext({
526+
executor: tx,
527+
workspaceId,
528+
userId: session.user.id,
529+
})
530+
if (!plannedContext) {
531+
return { success: false as const, status: 403 as const, error: 'Write permission required' }
532+
}
533+
534+
await acquireOrganizationUserMutationLocks(tx, {
535+
userId: session.user.id,
536+
organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [],
537+
})
538+
539+
const currentContext = await getCredentialCreationWorkspaceContext({
540+
executor: tx,
541+
workspaceId,
542+
userId: session.user.id,
543+
forUpdate: true,
544+
})
545+
if (!currentContext) {
546+
return { success: false as const, status: 403 as const, error: 'Write permission required' }
547+
}
548+
if (currentContext.organizationId !== plannedContext.organizationId) {
549+
return {
550+
success: false as const,
551+
status: 409 as const,
552+
error: 'Workspace organization changed while creating the credential. Please retry.',
553+
}
554+
}
555+
if (!currentContext.canWrite) {
556+
return { success: false as const, status: 403 as const, error: 'Write permission required' }
557+
}
558+
516559
// service_account has no DB-level unique index on (workspaceId, providerId,
517560
// displayName), so we re-check inside the tx. OAuth/env_* are guarded by
518561
// partial unique indexes and fall through to the 23505 handler below.
@@ -542,9 +585,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
542585
updatedAt: now,
543586
})
544587

545-
if ((type === 'env_workspace' || type === 'service_account') && workspaceOwnerId) {
546-
if (workspaceMemberUserIds.length > 0) {
547-
for (const memberUserId of workspaceMemberUserIds) {
588+
if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) {
589+
if (currentContext.memberUserIds.length > 0) {
590+
for (const memberUserId of currentContext.memberUserIds) {
548591
const isAdmin = memberUserId === session.user.id
549592
await tx.insert(credentialMember).values({
550593
id: generateId(),
@@ -572,7 +615,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
572615
updatedAt: now,
573616
})
574617
}
618+
619+
return { success: true as const }
575620
})
621+
if (!creationResult.success) {
622+
return NextResponse.json({ error: creationResult.error }, { status: creationResult.status })
623+
}
576624

577625
const [created] = await db
578626
.select()

0 commit comments

Comments
 (0)