diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 0010a8af252..e07c8f5f6bd 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -30,7 +30,10 @@ export const GET = defineInternalJsonRoute({ rateLimit, errorPolicy: internalCredentialDetailErrorPolicy, parseOptions: credentialValidationParseOptions, - mapInput: ({ params }) => ({ credentialId: params.id }), + mapInput: ({ params, query }) => ({ + credentialId: params.id, + ...(query.workspaceId ? { assertedWorkspaceId: query.workspaceId } : {}), + }), useCase: getWorkspaceCredentialUseCase, present: ({ credential, access }) => ({ credential: toWorkspaceCredential(credential, access), @@ -44,7 +47,11 @@ export const PUT = defineInternalJsonRoute({ rateLimit, errorPolicy: internalCredentialErrorPolicy, parseOptions: credentialValidationParseOptions, - mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + mapInput: ({ params, body, query }) => ({ + credentialId: params.id, + ...body, + ...(query.workspaceId ? { assertedWorkspaceId: query.workspaceId } : {}), + }), useCase: updateWorkspaceCredentialUseCase, present: ({ credential, access }) => ({ credential: toWorkspaceCredential(credential, access), @@ -58,7 +65,10 @@ export const DELETE = defineInternalJsonRoute({ rateLimit, errorPolicy: internalCredentialErrorPolicy, parseOptions: credentialValidationParseOptions, - mapInput: ({ params }) => ({ credentialId: params.id }), + mapInput: ({ params, query }) => ({ + credentialId: params.id, + ...(query.workspaceId ? { workspaceId: query.workspaceId } : {}), + }), useCase: deleteCredentialUseCase, present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts index c7119a9ca52..f2d3fe05e63 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts @@ -4,8 +4,8 @@ import { useCallback, useState } from 'react' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { useUnsavedChangesGuard } from '@/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard' import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials' -import { useUnsavedChangesGuard } from './use-unsaved-changes-guard' const logger = createLogger('CredentialDetailForm') @@ -26,6 +26,7 @@ export interface CredentialDetailFormSection { } interface UseCredentialDetailFormParams { + workspaceId?: string credential: WorkspaceCredential | null isAdmin: boolean /** Where the back link / discard navigates to. */ @@ -47,12 +48,13 @@ interface UseCredentialDetailFormParams { * into that one save and one guard. */ export function useCredentialDetailForm({ + workspaceId, credential, isAdmin, backHref, section, }: UseCredentialDetailFormParams) { - const updateCredential = useUpdateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential(workspaceId) const [displayNameDraft, setDisplayNameDraft] = useState('') const [descriptionDraft, setDescriptionDraft] = useState('') diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx index 70e653f9ebe..aa8a2751673 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx @@ -44,7 +44,7 @@ function PersonalTokenForm({ const [host, setHost] = useState(instanceUrl ? new URL(instanceUrl).host : 'gitlab.com') const [token, setToken] = useState('') const create = useCreateWorkspaceCredential() - const update = useUpdateWorkspaceCredential() + const update = useUpdateWorkspaceCredential(workspaceId) const pending = create.isPending || update.isPending const error = (credentialId ? update.error : create.error)?.message function submit() { @@ -88,7 +88,7 @@ function PersonalTokenForm({ type='custom' title='Personal access token' required - hint='Use a token with the api scope. Only you can use this connection.' + hint='Only you can use this connection.' > ( () => credentials.find((c) => c.id === credentialId) ?? null, @@ -107,7 +107,12 @@ export function ConnectedCredentialDetail({ const [isShareModalOpen, setIsShareModalOpen] = useState(false) const [reconnectOpen, setReconnectOpen] = useState(false) - const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref }) + const form = useCredentialDetailForm({ + credential, + isAdmin, + backHref: integrationsHref, + workspaceId, + }) const oauthServiceNameByProviderId = useMemo( () => new Map(oauthConnections.map((service) => [service.providerId, service.name])), diff --git a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx index d9d42c05d6f..706750f21ad 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx @@ -84,7 +84,7 @@ export function OrganizationAccountProviderCatalog({ Add provider - + ({ - queryKey: workspaceCredentialKeys.detail(credentialId), + queryKey: workspaceCredentialKeys.detailForWorkspace(credentialId, workspaceId), queryFn: async ({ signal }) => { if (!credentialId) return null const data = await requestJson(getWorkspaceCredentialContract, { params: { id: credentialId }, + query: { workspaceId }, signal, }) return data.credential ?? null @@ -103,6 +109,7 @@ export function useCreateWorkspaceCredential() { }, onSettled: () => Promise.all([ + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists(), }), @@ -114,7 +121,7 @@ export function useCreateWorkspaceCredential() { }) } -export function useUpdateWorkspaceCredential() { +export function useUpdateWorkspaceCredential(workspaceId?: string) { const queryClient = useQueryClient() return useMutation({ @@ -130,6 +137,7 @@ export function useUpdateWorkspaceCredential() { return requestJson(updateWorkspaceCredentialContract, { params: { id: credentialId }, body, + query: { workspaceId }, }) }, onMutate: async (variables) => { @@ -142,7 +150,7 @@ export function useUpdateWorkspaceCredential() { queryKey: workspaceCredentialKeys.lists(), }) const previousDetail = queryClient.getQueryData( - workspaceCredentialKeys.detail(variables.credentialId) + workspaceCredentialKeys.detailForWorkspace(variables.credentialId, workspaceId) ) /** Applies the in-flight edit to one cached credential. */ @@ -163,7 +171,7 @@ export function useUpdateWorkspaceCredential() { * Discard to restore the pre-save value over the committed one. */ queryClient.setQueryData( - workspaceCredentialKeys.detail(variables.credentialId), + workspaceCredentialKeys.detailForWorkspace(variables.credentialId, workspaceId), (old) => (old ? withEdit(old) : old) ) @@ -185,13 +193,14 @@ export function useUpdateWorkspaceCredential() { } if (context?.previousDetail !== undefined) { queryClient.setQueryData( - workspaceCredentialKeys.detail(variables.credentialId), + workspaceCredentialKeys.detailForWorkspace(variables.credentialId, workspaceId), context.previousDetail ) } }, onSettled: (_data, _error, variables) => Promise.all([ + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(variables.credentialId), }), @@ -206,15 +215,19 @@ export function useUpdateWorkspaceCredential() { }) } -export function useDeleteWorkspaceCredential() { +export function useDeleteWorkspaceCredential(workspaceId?: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: async (credentialId: string) => { - return requestJson(deleteWorkspaceCredentialContract, { params: { id: credentialId } }) + return requestJson(deleteWorkspaceCredentialContract, { + params: { id: credentialId }, + query: { workspaceId }, + }) }, onSettled: (_data, _error, credentialId) => Promise.all([ + queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) }), queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }), queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY }), diff --git a/apps/sim/hooks/queries/scoped-credentials.ts b/apps/sim/hooks/queries/scoped-credentials.ts index a30bac8bea6..93eaa1e84be 100644 --- a/apps/sim/hooks/queries/scoped-credentials.ts +++ b/apps/sim/hooks/queries/scoped-credentials.ts @@ -105,7 +105,11 @@ export function useUpdateScopedCredential() { params: { id: credentialId }, body: { ...body, organizationId: body.organizationId }, }) - return requestJson(updateWorkspaceCredentialContract, { params: { id: credentialId }, body }) + return requestJson(updateWorkspaceCredentialContract, { + params: { id: credentialId }, + body, + query: { workspaceId: 'workspaceId' in input ? input.workspaceId : undefined }, + }) }, onSuccess: reconcile, }) diff --git a/apps/sim/hooks/queries/utils/credential-keys.ts b/apps/sim/hooks/queries/utils/credential-keys.ts index 3c5db3dde50..1be218be3a7 100644 --- a/apps/sim/hooks/queries/utils/credential-keys.ts +++ b/apps/sim/hooks/queries/utils/credential-keys.ts @@ -19,6 +19,10 @@ export const workspaceCredentialKeys = { details: () => [...workspaceCredentialKeys.all, 'detail'] as const, detail: (credentialId?: string) => [...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const, + detailForWorkspace: (credentialId?: string, workspaceId?: string) => + workspaceId + ? ([...workspaceCredentialKeys.detail(credentialId), 'workspace', workspaceId] as const) + : workspaceCredentialKeys.detail(credentialId), members: (credentialId?: string) => [...workspaceCredentialKeys.detail(credentialId), 'members'] as const, /** diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 6d42557e34c..cfd1409957d 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { ATLASSIAN_PRODUCTS, @@ -36,7 +36,8 @@ export const workspaceCredentialRoleSchema = z.enum(['admin', 'member']) export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked']) export const workspaceCredentialSchema = z.object({ id: z.string(), - workspaceId: z.string(), + workspaceId: z.string().nullable(), + organizationId: organizationIdSchema.optional(), type: workspaceCredentialTypeSchema, displayName: z.string(), description: z.string().nullable(), @@ -466,6 +467,7 @@ export const getWorkspaceCredentialContract = defineRouteContract({ method: 'GET', path: '/api/credentials/[id]', params: credentialIdParamsSchema, + query: z.object({ workspaceId: workspaceIdSchema.optional() }), response: { mode: 'json', schema: z.object({ @@ -528,6 +530,7 @@ export const updateWorkspaceCredentialContract = defineRouteContract({ method: 'PUT', path: '/api/credentials/[id]', params: credentialIdParamsSchema, + query: z.object({ workspaceId: workspaceIdSchema.optional() }), body: updateCredentialByIdBodySchema, response: { mode: 'json', @@ -541,6 +544,7 @@ export const deleteWorkspaceCredentialContract = defineRouteContract({ method: 'DELETE', path: '/api/credentials/[id]', params: credentialIdParamsSchema, + query: z.object({ workspaceId: workspaceIdSchema.optional() }), response: { mode: 'json', schema: z.object({ diff --git a/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts b/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts new file mode 100644 index 00000000000..af892e6145a --- /dev/null +++ b/apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts @@ -0,0 +1,365 @@ +/** Real storage, encryption, migration, and authorization; no external GitLab calls. */ +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + member, + organization, + permissions, + resourcePolicy, + user, + workspace, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' +import { resolvePersonalToken } from '@/lib/credentials/application/resolve-personal-token' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' +import { decryptPersonalToken, encryptPersonalToken } from '@/lib/credentials/gitlab-personal-token' +import { + createPersonalTokenCredential, + getPersonalTokenCredentials, + updatePersonalTokenCredential, +} from '@/lib/credentials/personal-tokens' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { migrateGitLabPersonalTokens } from '@/scripts/migrate-gitlab-personal-tokens' + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithValidation: async (url: string) => + Response.json( + url.endsWith('/user') + ? { id: 42, username: 'fixture', name: 'Fixture', state: 'active', bot: false } + : { user_id: 42, active: true, revoked: false, scopes: ['api'], expires_at: null } + ), +})) + +describe('organization personal tokens', () => { + let ids: ReturnType + const tokenSecret = 'isolated-gitlab-token-fixture' + function fixtureIds() { + return { + owner: generateId(), + other: generateId(), + org: generateId(), + foreignOrg: generateId(), + first: generateId(), + second: generateId(), + foreign: generateId(), + group: generateId(), + legacyGroup: generateId(), + enrollment: generateId(), + token: generateId(), + } + } + const identity = () => ({ + providerId: 'gitlab' as const, + ownerUserId: ids.owner, + subjectId: '42', + instanceUrl: 'https://gitlab.example.test', + }) + const principal = (userId = ids.owner) => ({ + kind: 'session' as const, + userId, + sessionId: 'isolated-session', + }) + const resolve = (workspaceId = ids.second, userId = ids.owner) => + resolvePersonalToken.execute({ + principal: principal(userId), + input: { + credentialId: ids.token, + assertedWorkspaceId: workspaceId, + expectedProviderId: 'gitlab', + }, + }) + const migrate = (apply = true) => migrateGitLabPersonalTokens({ organizationId: ids.org, apply }) + async function storedToken() { + const [row] = await db.select().from(credential).where(eq(credential.id, ids.token)) + if (!row) throw new Error('Fixture token missing') + return row + } + + beforeEach(async () => { + ids = fixtureIds() + const now = new Date() + await db.insert(user).values( + [ids.owner, ids.other].map((id) => ({ + id, + name: 'Token fixture', + email: `${id}@fixture.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(organization).values( + [ids.org, ids.foreignOrg].map((id) => ({ + id, + name: 'Token fixture organization', + slug: id, + })) + ) + await db.insert(member).values([ + { id: generateId(), organizationId: ids.org, userId: ids.owner, role: 'member' }, + { id: generateId(), organizationId: ids.org, userId: ids.other, role: 'admin' }, + ]) + await db.insert(workspace).values( + [ids.first, ids.second, ids.foreign].map((id) => ({ + id, + name: 'Token fixture workspace', + organizationId: id === ids.foreign ? ids.foreignOrg : ids.org, + ownerId: ids.owner, + billedAccountUserId: ids.owner, + })) + ) + await db.insert(permissions).values( + [ids.first, ids.second, ids.foreign].flatMap((id) => + [ids.owner, ids.other].map((userId) => ({ + id: generateId(), + userId, + entityId: id, + entityType: 'workspace' as const, + permissionType: 'admin' as const, + })) + ) + ) + await db.insert(credentialGroup).values([ + { + id: ids.group, + organizationId: ids.org, + publicId: generateId(), + name: 'Connected accounts', + options: [], + createdBy: ids.owner, + }, + { + id: ids.legacyGroup, + workspaceId: ids.first, + publicId: generateId(), + name: 'Legacy accounts', + options: [], + createdBy: ids.owner, + }, + ]) + await db.insert(resourcePolicy).values({ + id: generateId(), + organizationId: ids.org, + resourceType: 'credential_group', + resourceId: ids.group, + document: buildOrganizationAccountAccessPolicy(ids.group, []), + createdBy: ids.owner, + }) + await db.insert(credentialGroupEnrollment).values({ + id: ids.enrollment, + credentialGroupId: ids.legacyGroup, + userId: ids.owner, + email: `${ids.owner}@fixture.test`, + status: 'completed', + invitationTokenHash: sha256Hex(generateId()), + invitationExpiresAt: now, + invitedAt: now, + }) + await db.insert(credential).values({ + id: ids.token, + workspaceId: ids.first, + type: 'personal_token', + providerId: 'gitlab', + displayName: 'Personal GitLab', + createdBy: ids.owner, + providerSubjectId: '42', + providerTenantId: identity().instanceUrl, + grantedScopes: ['api'], + credentialGroupEnrollmentId: ids.enrollment, + encryptedPersonalToken: await encryptPersonalToken({ + ...identity(), + workspaceId: ids.first, + accessToken: tokenSecret, + }), + }) + }) + afterEach(async () => { + await db + .delete(permissions) + .where(inArray(permissions.entityId, [ids.first, ids.second, ids.foreign])) + await db.delete(organization).where(inArray(organization.id, [ids.org, ids.foreignOrg])) + await db.delete(user).where(inArray(user.id, [ids.owner, ids.other])) + }) + + it('dry-runs without changing ownership, ciphertext, or enrollments', async () => { + const before = await storedToken() + expect(await migrate(false)).toEqual({ mode: 'dry-run', processed: 1 }) + expect(await storedToken()).toEqual(before) + expect( + await db + .select() + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, ids.group)) + ).toHaveLength(0) + }) + + it('creates one organization token and reconnects and rotates the same identity', async () => { + await db.delete(credential).where(eq(credential.id, ids.token)) + const input = { + userId: ids.owner, + accounts: { organizationId: ids.org, credentialGroupId: ids.group }, + providerId: 'gitlab', + apiToken: tokenSecret, + domain: 'gitlab.example.test', + } + const created = await createPersonalTokenCredential(input) + expect(created.credential).toMatchObject({ + workspaceId: null, + organizationId: ids.org, + createdBy: ids.owner, + }) + const reconnected = await createPersonalTokenCredential({ + ...input, + apiToken: 'reconnected-fixture', + }) + expect(reconnected).toMatchObject({ created: false, credential: { id: created.credential.id } }) + await updatePersonalTokenCredential({ + credential: reconnected.credential, + apiToken: 'rotated-fixture', + }) + const [rotated] = await db + .select() + .from(credential) + .where(eq(credential.id, created.credential.id)) + expect(rotated).toMatchObject({ workspaceId: null, organizationId: ids.org }) + await expect( + decryptPersonalToken(rotated!.encryptedPersonalToken!, { + ...identity(), + organizationId: ids.org, + }) + ).resolves.toBe('rotated-fixture') + }) + + it('preserves the credential ID, rebinds encryption, and is idempotent', async () => { + expect(await migrate()).toEqual({ mode: 'applied', processed: 1 }) + const current = await storedToken() + expect(current).toMatchObject({ + id: ids.token, + workspaceId: null, + organizationId: ids.org, + createdBy: ids.owner, + }) + const [enrollment] = await db + .select() + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, current.credentialGroupEnrollmentId!)) + expect(enrollment).toMatchObject({ userId: ids.owner, credentialGroupId: ids.group }) + await expect( + decryptPersonalToken(current.encryptedPersonalToken!, { + ...identity(), + organizationId: ids.org, + }) + ).resolves.toBe(tokenSecret) + await expect( + decryptPersonalToken(current.encryptedPersonalToken!, { + ...identity(), + workspaceId: ids.first, + }) + ).rejects.toThrow('binding') + expect(await migrate()).toEqual({ mode: 'applied', processed: 0 }) + }) + + it('uses the same connection across workspaces and survives deletion of the original workspace', async () => { + await migrate() + for (const workspaceId of [ids.first, ids.second]) { + expect( + (await getPersonalTokenCredentials(workspaceId, ids.owner)).map((row) => row.id) + ).toEqual([ids.token]) + await expect(resolve(workspaceId)).resolves.toMatchObject({ accessToken: tokenSecret }) + const listed = await listVisibleWorkspaceCredentials({ + workspaceId, + userId: ids.owner, + workspaceAccess: { canAdmin: false }, + types: ['personal_token'], + }) + expect(listed.data).toEqual([ + expect.objectContaining({ id: ids.token, workspaceId: null, organizationId: ids.org }), + ]) + } + await db.delete(workspace).where(eq(workspace.id, ids.first)) + await expect(resolve()).resolves.toMatchObject({ accessToken: tokenSecret }) + }) + + it('denies another person, another organization, and removed organization membership', async () => { + await migrate() + expect(await getPersonalTokenCredentials(ids.second, ids.other)).toEqual([]) + expect(await getPersonalTokenCredentials(ids.foreign, ids.owner)).toEqual([]) + await expect(resolve(ids.second, ids.other)).rejects.toThrow() + await expect(resolve(ids.foreign)).rejects.toThrow() + await db + .delete(member) + .where(and(eq(member.organizationId, ids.org), eq(member.userId, ids.owner))) + expect(await getPersonalTokenCredentials(ids.second, ids.owner)).toEqual([]) + await expect(resolve()).rejects.toThrow() + }) + + it('rechecks revocation before use and manages the token from another workspace', async () => { + await migrate() + await updateWorkspaceCredentialUseCase.execute({ + principal: principal(), + input: { + credentialId: ids.token, + assertedWorkspaceId: ids.second, + displayName: 'Renamed GitLab', + }, + }) + expect((await storedToken()).displayName).toBe('Renamed GitLab') + await db + .update(credentialGroupEnrollment) + .set({ revokedAt: new Date(), status: 'revoked' }) + .where(eq(credentialGroupEnrollment.credentialGroupId, ids.group)) + await expect(resolve()).rejects.toThrow() + await deleteCredentialUseCase.execute({ + principal: principal(), + input: { credentialId: ids.token, workspaceId: ids.second }, + }) + expect(await db.select().from(credential).where(eq(credential.id, ids.token))).toHaveLength(0) + }) + + it('refuses duplicate identities without choosing or overwriting a token', async () => { + const original = await storedToken() + await db.insert(credential).values({ + ...original, + id: generateId(), + workspaceId: ids.second, + encryptedPersonalToken: await encryptPersonalToken({ + ...identity(), + workspaceId: ids.second, + accessToken: 'second-fixture-token', + }), + }) + await expect(migrate()).rejects.toThrow('Duplicate GitLab identities') + expect(await storedToken()).toEqual(original) + }) + + it('refuses ciphertext bound to a different workspace without changing the row', async () => { + await db + .update(credential) + .set({ + encryptedPersonalToken: await encryptPersonalToken({ + ...identity(), + workspaceId: ids.second, + accessToken: tokenSecret, + }), + }) + .where(eq(credential.id, ids.token)) + await expect(migrate()).rejects.toThrow('binding') + expect((await storedToken()).workspaceId).toBe(ids.first) + }) + + it('does not revive a revoked source enrollment', async () => { + await db + .update(credentialGroupEnrollment) + .set({ status: 'revoked', revokedAt: new Date() }) + .where(eq(credentialGroupEnrollment.id, ids.enrollment)) + await expect(migrate()).rejects.toThrow('inactive or mismatched') + expect((await storedToken()).workspaceId).toBe(ids.first) + }) +}) diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 0e4e8b5cca6..2a14fbbabb7 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -195,7 +195,7 @@ export function canUseCredential(access: CredentialActorContext): boolean { export async function getCredentialActorContext( credentialId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess } + options?: { workspaceAccess?: WorkspaceAccess; workspaceId?: string } ): Promise { const [credentialRow] = await db .select() @@ -203,7 +203,12 @@ export async function getCredentialActorContext( .where(eq(credential.id, credentialId)) .limit(1) - if (!credentialRow?.workspaceId) { + const organizationToken = + credentialRow?.type === 'personal_token' && + credentialRow.organizationId && + !credentialRow.workspaceId + const workspaceId = organizationToken ? options?.workspaceId : credentialRow?.workspaceId + if (!credentialRow || !workspaceId) { return { credential: null, member: null, @@ -214,17 +219,31 @@ export async function getCredentialActorContext( } const workspaceAccess = await resolveWorkspaceAccess( - credentialRow.workspaceId, + workspaceId, userId, options?.workspaceAccess ) if (credentialRow.type === 'personal_token') { + let hasAccess = workspaceAccess.hasAccess + if (organizationToken) { + const [membership] = await db + .select({ id: member.id }) + .from(member) + .where( + and(eq(member.organizationId, credentialRow.organizationId!), eq(member.userId, userId)) + ) + .limit(1) + hasAccess = + hasAccess && + Boolean(membership) && + workspaceAccess.workspace?.organizationId === credentialRow.organizationId + } return { - credential: credentialRow.createdBy === userId ? credentialRow : null, + credential: credentialRow.createdBy === userId && hasAccess ? credentialRow : null, member: null, - hasWorkspaceAccess: workspaceAccess.hasAccess, - canWriteWorkspace: workspaceAccess.canWrite, - isAdmin: credentialRow.createdBy === userId && workspaceAccess.hasAccess, + hasWorkspaceAccess: hasAccess, + canWriteWorkspace: hasAccess && workspaceAccess.canWrite, + isAdmin: credentialRow.createdBy === userId && hasAccess, } } const [memberRow] = await db diff --git a/apps/sim/lib/credentials/api/route-policies.test.ts b/apps/sim/lib/credentials/api/route-policies.test.ts index 3172bfa24ab..14dbcaa4ad4 100644 --- a/apps/sim/lib/credentials/api/route-policies.test.ts +++ b/apps/sim/lib/credentials/api/route-policies.test.ts @@ -103,7 +103,9 @@ describe('personal account connection errors', () => { ) expect(response).toMatchObject({ status: 409, - body: { error: 'Ask a workspace admin to configure this integration in Connected accounts' }, + body: { + error: 'Ask an organization admin to configure this integration in organization settings', + }, }) }) diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts index bc33b7bb34a..89b54e4665a 100644 --- a/apps/sim/lib/credentials/api/route-policies.ts +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -75,7 +75,7 @@ export const internalPersonalCredentialConnectionErrorPolicy = extendInternalErr (error) => { if (error instanceof CredentialGroupProviderConfigurationError) { return internalErrorResponse(409, { - error: 'Ask a workspace admin to configure this integration in Connected accounts', + error: 'Ask an organization admin to configure this integration in organization settings', }) } const status = diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts index 9e827870de6..70f1e96b97c 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -90,11 +90,18 @@ export function defineAuthorizedCredentialUseCase< async authorizeResource({ principal, context }) { const actor = await getCredentialActorContext( context.credential.id, - requireCredentialExecutionUserId(principal) + requireCredentialExecutionUserId(principal), + { workspaceId: context.workspaceId } ) if ( !actor.credential || - actor.credential.workspaceId !== context.workspaceId || + !( + actor.credential.workspaceId === context.workspaceId || + (actor.credential.type === 'personal_token' && + !actor.credential.workspaceId && + actor.credential.organizationId === context.workspaceOrganizationId && + Boolean(context.workspaceOrganizationId)) + ) || !actor.hasWorkspaceAccess ) { throw new OrchestrationError('not_found', 'Credential not found') diff --git a/apps/sim/lib/credentials/application/credential-context.ts b/apps/sim/lib/credentials/application/credential-context.ts index 9ba2a13131e..bb5107fa233 100644 --- a/apps/sim/lib/credentials/application/credential-context.ts +++ b/apps/sim/lib/credentials/application/credential-context.ts @@ -22,9 +22,29 @@ export async function resolveCredentialApplicationContext( ? await getWorkspaceCredential({ workspaceId: assertedWorkspace.workspaceId, credentialId: input.credentialId, + ...(assertedWorkspace.workspaceOrganizationId + ? { organizationId: assertedWorkspace.workspaceOrganizationId } + : {}), }) : await getCredentialById(input.credentialId) - if (!credential?.workspaceId) throw new OrchestrationError('not_found', 'Credential not found') + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.organizationId) { + if ( + credential.type !== 'personal_token' || + credential.workspaceId || + !assertedWorkspace || + credential.organizationId !== assertedWorkspace.workspaceOrganizationId + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + return { ...assertedWorkspace, credential } + } + if ( + !credential.workspaceId || + (assertedWorkspace && credential.workspaceId !== assertedWorkspace.workspaceId) + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } const workspace = assertedWorkspace ?? (await loadActiveWorkspaceApplicationContext(credential.workspaceId)) if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index 0ba3a46a136..70bd44bb00b 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ getActor: vi.fn(), updateRecord: vi.fn(), createRecord: vi.fn(), + personalAccounts: vi.fn(), + createPersonalToken: vi.fn(), })) const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig @@ -44,6 +46,18 @@ vi.mock('@/lib/credentials/orchestration', () => ({ createCredentialRecord: mocks.createRecord, isProviderOutageCode: () => false, })) +vi.mock('@/lib/credentials/application/workspace-personal-accounts', () => ({ + requireWorkspacePersonalAccounts: mocks.personalAccounts, +})) +vi.mock('@/lib/credentials/personal-tokens', () => ({ + createPersonalTokenCredential: mocks.createPersonalToken, + updatePersonalTokenCredential: vi.fn(), +})) +vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: vi.fn() })) +vi.mock('@/lib/integrations/principal-scope.server', () => ({ allowedIntegrationTypes: vi.fn() })) +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: () => ({ isCredentialVisible: () => true }), +})) vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn() })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) @@ -414,3 +428,53 @@ describe('personal-credential capability', () => { expect(result.credential).toEqual(created) }) }) + +describe('personal-token organization enrollment', () => { + const accounts = { organizationId: 'organization', credentialGroupId: 'organization-group' } + const tokenInput = { + workspaceId: WORKSPACE_ID, + type: 'personal_token' as const, + providerId: 'gitlab', + displayName: 'My GitLab', + apiToken: 'personal-token', + } + + beforeEach(() => { + vi.clearAllMocks() + resolveGroupConfigMock.mockResolvedValue(null) + mocks.loadWorkspace.mockResolvedValue({ ...workspace, workspaceOrganizationId: 'organization' }) + mocks.resolvePermission.mockResolvedValue('write') + mocks.personalAccounts.mockResolvedValue(accounts) + }) + + it('passes the authorized organization group into token creation', async () => { + const created = { ...credential, type: 'personal_token', providerId: 'gitlab' } + mocks.createPersonalToken.mockResolvedValue({ + success: true, + created: true, + credential: created, + }) + mocks.getActor.mockResolvedValue({ credential: created, isAdmin: true }) + await createWorkspaceCredential.execute({ principal: sessionPrincipal, input: tokenInput }) + expect(mocks.personalAccounts).toHaveBeenCalledWith( + sessionPrincipal, + expect.objectContaining({ workspaceOrganizationId: 'organization' }) + ) + expect(mocks.createPersonalToken).toHaveBeenCalledWith({ + ...tokenInput, + userId: 'user-1', + accounts, + }) + expect(mocks.createRecord).not.toHaveBeenCalled() + }) + + it('refuses unapproved organization access before verifying or storing a token', async () => { + mocks.personalAccounts.mockRejectedValueOnce(new Error('Organization accounts unavailable')) + await expect( + createWorkspaceCredential.execute({ principal: sessionPrincipal, input: tokenInput }) + ).rejects.toThrow('Organization accounts unavailable') + expect(mocks.createPersonalToken).not.toHaveBeenCalled() + expect(mocks.createRecord).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index d5072a9e37f..dcc6aa02074 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -15,6 +15,7 @@ import { } from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' +import { requireWorkspacePersonalAccounts } from '@/lib/credentials/application/workspace-personal-accounts' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { createCredentialRecord, @@ -230,11 +231,17 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ } const result = input.type === 'personal_token' - ? await createPersonalTokenCredential({ ...input, userId }) + ? await createPersonalTokenCredential({ + ...input, + userId, + accounts: await requireWorkspacePersonalAccounts(principal, context), + }) : await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) if (!result.success) throwCredentialMutationFailure(result) if (!result.credential) throw new Error('Credential creation succeeded without a credential') - const access = await getCredentialActorContext(result.credential.id, userId) + const access = await getCredentialActorContext(result.credential.id, userId, { + workspaceId: context.workspaceId, + }) if (!access.credential || !canUseCredential(access)) { throw new Error('Created credential is not visible to its creator') } @@ -284,6 +291,7 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ export interface GetWorkspaceCredentialInput { credentialId: string + assertedWorkspaceId?: string } export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ @@ -300,9 +308,9 @@ export type UpdateWorkspaceCredentialInput = Omit< 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' > & { /** - * Workspace the caller asserts owns the credential; a mismatch is concealed as - * a not-found. The internal surface omits it and resolves the credential's own - * workspace instead, which is what it did before this field existed. + * Execution workspace asserted by the caller. Required for organization personal + * tokens; workspace-owned credentials can resolve their own workspace when omitted. + * A mismatched owner organization or workspace is concealed as a not-found. */ assertedWorkspaceId?: string } @@ -339,7 +347,8 @@ export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCas if (!result.success) throwCredentialMutationFailure(result) const access = await getCredentialActorContext( context.credential.id, - requirePrincipalSubjectUserId(principal) + requirePrincipalSubjectUserId(principal), + { workspaceId: context.workspaceId } ) if (!access.credential || !access.isAdmin) { throw new Error('Updated credential is no longer visible to its administrator') diff --git a/apps/sim/lib/credentials/application/personal-connection.test.ts b/apps/sim/lib/credentials/application/personal-connection.test.ts index 3b9918897dc..c1b3190ffb6 100644 --- a/apps/sim/lib/credentials/application/personal-connection.test.ts +++ b/apps/sim/lib/credentials/application/personal-connection.test.ts @@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({ personal: vi.fn(), oauthContext: vi.fn(), startOAuth: vi.fn(), + organizationMembership: vi.fn(), + available: vi.fn(), + policy: vi.fn(), })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.workspace, @@ -27,7 +30,17 @@ vi.mock('@/lib/credentials/application/provider-catalog', () => ({ listCredentialProviderCatalog: mocks.catalog, })) vi.mock('@/lib/credential-groups/credentials', () => ({ - loadWorkspaceAccountsCredentialListContext: mocks.group, + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + requireOrganizationMembership: mocks.organizationMembership, +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.policy, + ResourcePolicyNotFoundError: class extends Error {}, })) vi.mock('@/lib/credential-groups/enrollments', () => ({ getCredentialGroupOAuthContextForEnrollment: mocks.oauthContext, @@ -40,13 +53,15 @@ vi.mock('@/lib/credential-groups/self-enrollment', () => ({ vi.mock('@/lib/credentials/personal', () => ({ getPersonalOAuthCredentials: mocks.personal })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { startPersonalCredentialConnection } from '@/lib/credentials/application/personal-connection' const principal: Principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } const input = { workspaceId: 'workspace', providerId: 'confluence' } const group = { credentialGroupId: 'canonical-group', - workspaceId: 'workspace', + workspaceId: null, + organizationId: 'organization', status: 'active', options: [{ id: 'option', provider: 'confluence', status: 'active' }], } @@ -60,10 +75,15 @@ describe('personal connection launch', () => { vi.clearAllMocks() mocks.workspace.mockResolvedValue({ workspaceId: 'workspace', - workspaceOrganizationId: null, + workspaceOrganizationId: 'organization', allowPersonalApiKeys: true, }) mocks.permission.mockResolvedValue('read') + mocks.organizationMembership.mockResolvedValue({ userId: 'viewer', role: 'member' }) + mocks.available.mockResolvedValue(true) + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('canonical-group', ['workspace']), + }) mocks.catalog.mockResolvedValue([ { type: 'oauth', @@ -89,7 +109,7 @@ describe('personal connection launch', () => { }) expect(mocks.oauthContext).toHaveBeenCalledWith( { - workspaceId: 'workspace', + organizationId: 'organization', credentialGroupId: 'canonical-group', enrollmentId: 'enrollment', email: 'viewer@example.com', @@ -103,14 +123,24 @@ describe('personal connection launch', () => { ) expect(mocks.enroll).toHaveBeenCalledWith({ userId: 'viewer', - workspaceId: 'workspace', + organizationId: 'organization', credentialGroupId: 'canonical-group', }) expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.group).toHaveBeenCalledWith({ + kind: 'organization', + organizationId: 'organization', + }) + expect(mocks.organizationMembership).toHaveBeenCalledWith( + principal, + 'organization', + 'member', + 'integrations.manage' + ) expect(mocks.catalog).toHaveBeenCalledWith(principal, expect.any(Object), 'managed_oauth') }) - it('connects a configured Slack workspace app through its enrollment', async () => { + it('connects a configured organization Slack app through its enrollment', async () => { mocks.catalog.mockResolvedValue([ { type: 'oauth', @@ -145,23 +175,19 @@ describe('personal connection launch', () => { expect(mocks.enroll).not.toHaveBeenCalled() }) - it('does not let a reader add a provider to workspace configuration', async () => { + it('does not let a reader add a provider to organization configuration', async () => { mocks.group.mockResolvedValue({ ...group, options: [] }) - await expect(execute()).rejects.toThrow('Ask a workspace admin') + await expect(execute()).rejects.toThrow('Ask an organization admin') expect(mocks.ensure).not.toHaveBeenCalled() expect(mocks.enroll).not.toHaveBeenCalled() }) - it('lets an admin configure the standard provider once before connecting their own account', async () => { + it('requires provider setup in organization settings even for a workspace admin', async () => { mocks.permission.mockResolvedValue('admin') - mocks.group.mockResolvedValueOnce({ ...group, options: [] }).mockResolvedValueOnce(group) - await execute() - expect(mocks.ensure).toHaveBeenCalledWith('workspace', 'viewer', { - provider: 'confluence', - label: 'Confluence', - required: false, - }) - expect(mocks.enroll).toHaveBeenCalledTimes(1) + mocks.group.mockResolvedValue({ ...group, options: [] }) + await expect(execute()).rejects.toThrow('Ask an organization admin') + expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() }) it.each([ @@ -206,7 +232,9 @@ describe('personal connection launch', () => { authorizationOptions: [{ providerId: 'slack' }], }, ]) - await expect(execute({ providerId: 'slack' })).rejects.toThrow('Configure Slack') + await expect(execute({ providerId: 'slack' })).rejects.toThrow( + 'enable Slack in organization settings' + ) expect(mocks.ensure).not.toHaveBeenCalled() }) @@ -214,4 +242,71 @@ describe('personal connection launch', () => { mocks.enroll.mockRejectedValue(new Error('Access revoked')) await expect(execute()).rejects.toThrow('Access revoked') }) + + it('does not create a group when the organization has not configured accounts', async () => { + mocks.group.mockResolvedValue(null) + await expect(execute()).rejects.toThrow('set up Connected accounts in organization settings') + expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() + }) + + it('refuses personal workspaces before looking up organization accounts', async () => { + mocks.workspace.mockResolvedValue({ + workspaceId: 'workspace', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + await expect(execute()).rejects.toThrow('does not belong to an organization') + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() + }) + + it('requires organization membership even when the caller administers the workspace', async () => { + mocks.permission.mockResolvedValue('admin') + mocks.organizationMembership.mockRejectedValueOnce(new Error('Organization not found')) + await expect(execute()).rejects.toThrow('Organization not found') + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() + }) + + it('honors the organization feature flag before enrollment', async () => { + mocks.available.mockResolvedValue(false) + await expect(execute()).rejects.toThrow('not available') + expect(mocks.available).toHaveBeenCalledWith({ + kind: 'organization', + organizationId: 'organization', + }) + expect(mocks.policy).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() + }) + + it('connects the person’s own account without granting their workspace workflow access', async () => { + mocks.policy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('canonical-group', []), + }) + await expect(execute()).resolves.toMatchObject({ providerId: 'confluence' }) + expect(mocks.enroll).toHaveBeenCalledWith({ + organizationId: 'organization', + credentialGroupId: 'canonical-group', + userId: 'viewer', + }) + }) + + it('propagates policy read failures without provisioning or enrollment', async () => { + mocks.policy.mockRejectedValueOnce(new Error('Database unavailable')) + await expect(execute()).rejects.toThrow('Database unavailable') + expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() + }) + + it('rejects workspace keys before loading protected context', async () => { + await expect( + startPersonalCredentialConnection.execute({ + principal: { kind: 'workspace_api_key', keyId: 'key', workspaceId: 'workspace' }, + input, + }) + ).rejects.toThrow() + expect(mocks.workspace).not.toHaveBeenCalled() + expect(mocks.enroll).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/application/personal-connection.ts b/apps/sim/lib/credentials/application/personal-connection.ts index 0c000b2a731..cdb123d7102 100644 --- a/apps/sim/lib/credentials/application/personal-connection.ts +++ b/apps/sim/lib/credentials/application/personal-connection.ts @@ -1,29 +1,26 @@ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' -import type { StartPersonalCredentialConnectionBody } from '@/lib/api/contracts/credentials' -import { - defineAuthorizedWorkspaceUseCase, - InsufficientWorkspacePermissionsError, - requireCurrentHumanRole, -} from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { loadWorkspaceAccountsCredentialListContext } from '@/lib/credential-groups/credentials' import { getCredentialGroupOAuthContextForEnrollment } from '@/lib/credential-groups/enrollments' import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' -import { - findCredentialGroupProviderFromProviderId, - isCredentialGroupStandardOAuthProvider, -} from '@/lib/credential-groups/providers' +import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' -import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' import { credentialOperations } from '@/lib/credentials/application/operations' import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' +import { requireWorkspacePersonalAccounts } from '@/lib/credentials/application/workspace-personal-accounts' import { getPersonalOAuthCredentials } from '@/lib/credentials/personal' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' -/** Connects the authenticated person through the workspace's canonical account enrollment. */ +export interface StartPersonalCredentialConnectionInput { + workspaceId: string + providerId: string + credentialId?: string +} + +/** Connects the authenticated person through the workspace's organization account enrollment. */ export const startPersonalCredentialConnection = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.startPersonalConnection, - resolveContext: async ({ input }: { input: StartPersonalCredentialConnectionBody }) => { + resolveContext: async ({ input }: { input: StartPersonalCredentialConnectionInput }) => { const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context @@ -42,6 +39,7 @@ export const startPersonalCredentialConnection = defineAuthorizedWorkspaceUseCas if (!provider || !service) { throw new OrchestrationError('validation', 'This integration cannot be connected here') } + const group = await requireWorkspacePersonalAccounts(principal, context) if (input.credentialId) { const credentials = await getPersonalOAuthCredentials( context.workspaceId, @@ -56,42 +54,15 @@ export const startPersonalCredentialConnection = defineAuthorizedWorkspaceUseCas throw new OrchestrationError('forbidden', 'You can only reconnect your own account') } } - let group = await loadWorkspaceAccountsCredentialListContext(context.workspaceId) - if (!group || !group.options.some((option) => option.provider === provider)) { - try { - await requireCurrentHumanRole(userId, context, 'admin') - } catch (error) { - if (!(error instanceof InsufficientWorkspacePermissionsError)) throw error - throw new OrchestrationError( - 'forbidden', - `Ask a workspace admin to enable ${service.name} in Connected accounts` - ) - } - if (!isCredentialGroupStandardOAuthProvider(provider)) { - throw new OrchestrationError( - 'validation', - 'Configure Slack sign-in in Connected accounts first' - ) - } - await ensureWorkspaceAccountsGroup(context.workspaceId, userId, { - provider, - label: service.name, - required: false, - }) - group = await loadWorkspaceAccountsCredentialListContext(context.workspaceId) - } - if (group?.status !== 'active') { - throw new OrchestrationError('forbidden', 'Connected accounts is disabled in this workspace') - } const options = group.options.filter((option) => option.provider === provider) if (options.length !== 1 || options[0]?.status !== 'active') { throw new OrchestrationError( 'conflict', - `Ask a workspace admin to enable ${service.name} in Connected accounts` + `Ask an organization admin to enable ${service.name} in organization settings` ) } const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment({ - workspaceId: context.workspaceId, + organizationId: group.organizationId, credentialGroupId: group.credentialGroupId, userId, }) @@ -99,7 +70,7 @@ export const startPersonalCredentialConnection = defineAuthorizedWorkspaceUseCas if (!token) throw new Error('Account enrollment did not return an invitation token') const oauth = await getCredentialGroupOAuthContextForEnrollment( { - workspaceId: context.workspaceId, + organizationId: group.organizationId, credentialGroupId: group.credentialGroupId, enrollmentId: enrollment.id, email: enrollment.email, diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts index c87028b9323..50a45db01dd 100644 --- a/apps/sim/lib/credentials/application/presentation.ts +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -36,7 +36,7 @@ export function toWorkspaceCredential( row: CredentialRow | VisibleWorkspaceCredential, access?: CredentialActorContext ): WorkspaceCredential { - if (!row.workspaceId) + if (!row.workspaceId && !(row.type === 'personal_token' && row.organizationId)) throw new Error('Workspace credential presentation requires workspace ownership') const type = requireOrdinaryCredentialType(row.type) if (!row.createdBy) throw new Error(`Credential ${row.id} has no creator`) @@ -47,6 +47,7 @@ export function toWorkspaceCredential( return { id: row.id, workspaceId: row.workspaceId, + ...(row.organizationId ? { organizationId: row.organizationId } : {}), type, displayName: row.displayName, description: row.description, diff --git a/apps/sim/lib/credentials/application/resolve-personal-token.ts b/apps/sim/lib/credentials/application/resolve-personal-token.ts index 582b666cf2e..1901ee3aa19 100644 --- a/apps/sim/lib/credentials/application/resolve-personal-token.ts +++ b/apps/sim/lib/credentials/application/resolve-personal-token.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' @@ -41,14 +42,14 @@ export const resolvePersonalToken = defineAuthorizedCredentialUseCase({ ) } await requirePersonalTokenEnrollment({ - workspaceId: context.workspaceId, + ...resourceScopeFields(resourceScopeFromOwner(current)), userId, enrollmentId: current.credentialGroupEnrollmentId, }) const accessToken = await decryptPersonalToken(current.encryptedPersonalToken, { providerId: 'gitlab', ownerUserId: userId, - workspaceId: context.workspaceId, + ...resourceScopeFields(resourceScopeFromOwner(current)), subjectId: current.providerSubjectId, instanceUrl: current.providerTenantId, }) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 20828646e5a..fe6e214b090 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -239,7 +239,9 @@ describe('credential service-account application operations', () => { input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, }) - expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId) + expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId, { + workspaceId: WORKSPACE_ID, + }) expect(mocks.deleteRecord).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/credentials/application/workspace-personal-accounts.ts b/apps/sim/lib/credentials/application/workspace-personal-accounts.ts new file mode 100644 index 00000000000..d24d0d6c5b5 --- /dev/null +++ b/apps/sim/lib/credentials/application/workspace-personal-accounts.ts @@ -0,0 +1,40 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { requireOrganizationMembership } from '@/lib/core/application/organization-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' + +/** + * Resolves personal enrollment after the calling operation authorizes its workspace. + * Connecting one's own account does not grant the workspace access to it from workflows. + */ +export async function requireWorkspacePersonalAccounts( + principal: Principal, + context: WorkspaceAuthorizationContext +) { + const organizationId = context.workspaceOrganizationId + if (!organizationId) { + throw new OrchestrationError('forbidden', 'This workspace does not belong to an organization') + } + await requireOrganizationMembership(principal, organizationId, 'member', 'integrations.manage') + if (!(await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }))) { + throw new OrchestrationError('not_found', 'Organization connected accounts are not available') + } + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId, + }) + if (!group) { + throw new OrchestrationError( + 'not_found', + 'Ask an organization admin to set up Connected accounts in organization settings' + ) + } + if (group.status !== 'active') { + throw new OrchestrationError('forbidden', 'Connected accounts is disabled in this organization') + } + await requireOrganizationAccountsSetup(organizationId, group.credentialGroupId) + return { ...group, organizationId } +} diff --git a/apps/sim/lib/credentials/gitlab-personal-token.test.ts b/apps/sim/lib/credentials/gitlab-personal-token.test.ts index a76729339df..d05e208b512 100644 --- a/apps/sim/lib/credentials/gitlab-personal-token.test.ts +++ b/apps/sim/lib/credentials/gitlab-personal-token.test.ts @@ -93,6 +93,23 @@ describe('GitLab personal token verification', () => { await expect(verifyGitLabPersonalToken('secret')).rejects.toThrow('GitLab rejected this token') expect(mocks.fetch).toHaveBeenCalledTimes(1) }) + it('binds organization tokens independently of the execution workspace', async () => { + mocks.encrypt.mockResolvedValue({ encrypted: 'ciphertext' }) + const { workspaceId: _workspaceId, ...identity } = envelope + const organizationEnvelope = { ...identity, organizationId: 'organization' } + await encryptPersonalToken(organizationEnvelope) + expect(JSON.parse(mocks.encrypt.mock.calls[0][0])).toEqual(organizationEnvelope) + mocks.decrypt.mockResolvedValue({ decrypted: JSON.stringify(organizationEnvelope) }) + const { accessToken, ...expected } = organizationEnvelope + await expect(decryptPersonalToken('ciphertext', expected)).resolves.toBe(accessToken) + await expect( + decryptPersonalToken('ciphertext', { ...expected, organizationId: 'another-org' }) + ).rejects.toThrow('binding') + await expect( + decryptPersonalToken('ciphertext', { ...expected, workspaceId: 'workspace' }) + ).rejects.toThrow() + }) + it('encrypts the token together with immutable user and instance bindings', async () => { mocks.encrypt.mockResolvedValue({ encrypted: 'ciphertext' }) expect(await encryptPersonalToken(envelope)).toBe('ciphertext') diff --git a/apps/sim/lib/credentials/gitlab-personal-token.ts b/apps/sim/lib/credentials/gitlab-personal-token.ts index c62112e19da..e1253723839 100644 --- a/apps/sim/lib/credentials/gitlab-personal-token.ts +++ b/apps/sim/lib/credentials/gitlab-personal-token.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { normalizeGitLabHost } from '@/tools/gitlab/utils' @@ -21,14 +22,19 @@ const gitLabTokenSchema = z.object({ .regex(/^\d{4}-\d{2}-\d{2}$/) .nullable(), }) -const tokenEnvelopeSchema = z.object({ - providerId: z.literal('gitlab'), - ownerUserId: z.string().min(1), - workspaceId: z.string().min(1), - subjectId: z.string().min(1), - instanceUrl: z.string().url(), - accessToken: z.string().min(1).max(4096), -}) +const tokenEnvelopeSchema = z + .object({ + providerId: z.literal('gitlab'), + ownerUserId: z.string().min(1), + workspaceId: z.string().min(1).optional(), + organizationId: z.string().min(1).optional(), + subjectId: z.string().min(1), + instanceUrl: z.string().url(), + accessToken: z.string().min(1).max(4096), + }) + .refine((value) => Boolean(value.workspaceId) !== Boolean(value.organizationId), { + message: 'Personal token requires exactly one organization or workspace owner', + }) export type PersonalTokenEnvelope = z.output /** Verifies identity and scopes only at the exact HTTPS instance chosen by the person. */ @@ -93,13 +99,10 @@ export async function decryptPersonalToken( ): Promise { const { decrypted } = await decryptSecret(encrypted) const parsed = tokenEnvelopeSchema.parse(JSON.parse(decrypted)) - for (const key of [ - 'providerId', - 'ownerUserId', - 'workspaceId', - 'subjectId', - 'instanceUrl', - ] as const) { + if (!sameResourceScope(resourceScopeFromOwner(parsed), resourceScopeFromOwner(expected))) { + throw new Error('Stored personal token binding is invalid') + } + for (const key of ['providerId', 'ownerUserId', 'subjectId', 'instanceUrl'] as const) { if (parsed[key] !== expected[key]) throw new Error('Stored personal token binding is invalid') } return parsed.accessToken diff --git a/apps/sim/lib/credentials/personal-tokens.test.ts b/apps/sim/lib/credentials/personal-tokens.test.ts index afc896e4f8f..e2ccb46b477 100644 --- a/apps/sim/lib/credentials/personal-tokens.test.ts +++ b/apps/sim/lib/credentials/personal-tokens.test.ts @@ -4,14 +4,14 @@ import { eq, inArray, isNull } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - group: vi.fn(), + setup: vi.fn(), enroll: vi.fn(), verify: vi.fn(), encrypt: vi.fn(), lock: vi.fn(), })) -vi.mock('@/lib/credential-groups/credentials', () => ({ - loadWorkspaceAccountsCredentialListContext: mocks.group, +vi.mock('@/lib/credential-groups/organization-setup', () => ({ + requireOrganizationAccountsSetup: mocks.setup, })) vi.mock('@/lib/credential-groups/self-enrollment', () => ({ createViewerCredentialGroupEnrollment: mocks.enroll, @@ -33,8 +33,8 @@ import { import type { CredentialRow } from '@/lib/credentials/queries' const input = { - workspaceId: 'workspace', userId: 'owner', + accounts: { organizationId: 'organization', credentialGroupId: 'group' }, providerId: 'gitlab', apiToken: 'personal-secret', domain: 'gitlab.example.test', @@ -49,7 +49,8 @@ const verified = { } const current = { id: 'token', - workspaceId: 'workspace', + workspaceId: null, + organizationId: 'organization', createdBy: 'owner', type: 'personal_token', providerId: 'gitlab', @@ -57,15 +58,21 @@ const current = { providerTenantId: 'https://gitlab.example.test', credentialGroupEnrollmentId: 'enrollment', } as CredentialRow -function binding() { - queueTableRows(schemaMock.credentialGroupEnrollment, [{ id: 'enrollment' }]) +function binding(organizationId: string | null = 'organization') { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + id: 'enrollment', + credentialGroupId: 'group', + organizationId, + workspaceOrganizationId: 'organization', + }, + ]) } function expectLiveBinding() { - expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroup.workspaceId, 'workspace') expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroup.status, 'active') expect(eq).toHaveBeenCalledWith(schemaMock.user.id, 'owner') expect(eq).toHaveBeenCalledWith(schemaMock.user.emailVerified, true) - expect(eq).toHaveBeenCalledWith(schemaMock.user.email, schemaMock.credentialGroupEnrollment.email) + expect(eq).toHaveBeenCalledWith(schemaMock.user.id, schemaMock.credentialGroupEnrollment.userId) expect(inArray).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.status, [ 'invited', 'in_progress', @@ -78,11 +85,7 @@ describe('personal GitLab tokens in Connected accounts', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mocks.group.mockResolvedValue({ - credentialGroupId: 'group', - workspaceId: 'workspace', - status: 'active', - }) + mocks.setup.mockResolvedValue(undefined) mocks.enroll.mockResolvedValue({ enrollment: { id: 'enrollment' }, invitationLink: 'unused' }) mocks.verify.mockResolvedValue(verified) mocks.encrypt.mockResolvedValue('ciphertext') @@ -100,23 +103,25 @@ describe('personal GitLab tokens in Connected accounts', () => { dbChainMockFns.returning.mockResolvedValueOnce([current]) const result = await createPersonalTokenCredential(input) expect(result.created).toBe(true) - expect(mocks.group).toHaveBeenCalledWith('workspace') expect(mocks.enroll).toHaveBeenCalledWith({ - workspaceId: 'workspace', + organizationId: 'organization', userId: 'owner', credentialGroupId: 'group', }) expect(mocks.lock).toHaveBeenCalledWith(expect.anything(), 'enrollment') - expect(dbChainMockFns.for).toHaveBeenCalledWith('share') + expect(dbChainMockFns.for).toHaveBeenCalledWith('share', expect.anything()) expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ credentialGroupEnrollmentId: 'enrollment', + workspaceId: null, + organizationId: 'organization', createdBy: 'owner', providerSubjectId: '42', providerTenantId: verified.instanceUrl, encryptedPersonalToken: 'ciphertext', }) ) + expect(mocks.setup).toHaveBeenCalledWith('organization', 'group', expect.anything()) expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ status: 'in_progress' }) ) @@ -130,7 +135,7 @@ describe('personal GitLab tokens in Connected accounts', () => { expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledWith( expect.objectContaining({ target: [ - schemaMock.credential.workspaceId, + schemaMock.credential.organizationId, schemaMock.credential.createdBy, schemaMock.credential.providerId, schemaMock.credential.providerTenantId, @@ -150,15 +155,35 @@ describe('personal GitLab tokens in Connected accounts', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() expectLiveBinding() }) - it.each([null, { credentialGroupId: 'group', status: 'disabled' }])( - 'refuses missing or disabled canonical groups before provider calls', - async (group) => { - mocks.group.mockResolvedValue(group) - await expect(createPersonalTokenCredential(input)).rejects.toThrow('not available') - expect(mocks.verify).not.toHaveBeenCalled() - expect(mocks.enroll).not.toHaveBeenCalled() - } - ) + it('refuses missing organization account setup before persisting the token', async () => { + binding() + mocks.setup.mockRejectedValueOnce(new Error('Organization setup unavailable')) + await expect(createPersonalTokenCredential(input)).rejects.toThrow( + 'Organization setup unavailable' + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('preserves existing workspace enrollments during the organization cutover', async () => { + binding(null) + await requirePersonalTokenEnrollment({ + workspaceId: 'workspace', + userId: 'owner', + enrollmentId: 'enrollment', + }) + expect(mocks.setup).not.toHaveBeenCalled() + expectLiveBinding() + }) + + it('rechecks organization account setup before rotating a personal token', async () => { + binding() + mocks.setup.mockRejectedValueOnce(new Error('Organization setup unavailable')) + await expect( + updatePersonalTokenCredential({ credential: current, apiToken: 'rotated' }) + ).rejects.toThrow('Organization setup unavailable') + expect(mocks.verify).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) it('lists only the verified owner’s currently usable enrollment and includes its update time', async () => { const updatedAt = new Date('2026-09-01T00:00:00Z') queueTableRows(schemaMock.credential, [ @@ -218,7 +243,7 @@ describe('personal GitLab tokens in Connected accounts', () => { expect(mocks.encrypt).toHaveBeenCalledWith( expect.objectContaining({ ownerUserId: 'owner', - workspaceId: 'workspace', + organizationId: 'organization', subjectId: '42', instanceUrl: verified.instanceUrl, }) @@ -229,7 +254,7 @@ describe('personal GitLab tokens in Connected accounts', () => { expect(update).not.toHaveProperty('providerSubjectId') expect(update).not.toHaveProperty('providerTenantId') expect(update).not.toHaveProperty('credentialGroupEnrollmentId') - expect(eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace') + expect(eq).toHaveBeenCalledWith(schemaMock.credential.organizationId, 'organization') expect(eq).toHaveBeenCalledWith(schemaMock.credential.providerSubjectId, '42') expectLiveBinding() }) diff --git a/apps/sim/lib/credentials/personal-tokens.ts b/apps/sim/lib/credentials/personal-tokens.ts index c96a84507ef..41cbd2b2ba3 100644 --- a/apps/sim/lib/credentials/personal-tokens.ts +++ b/apps/sim/lib/credentials/personal-tokens.ts @@ -3,14 +3,21 @@ import { credential, credentialGroup, credentialGroupEnrollment, - foldedEmail, + member, user, + workspace, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { loadWorkspaceAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { + type ResourceOwner, + resourceScopeFields, + resourceScopeFromOwner, +} from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' import { encryptPersonalToken, @@ -53,16 +60,17 @@ export async function getPersonalTokenCredentials( eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) ) .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) - .innerJoin( - user, - and( - eq(user.id, credential.createdBy), - eq(foldedEmail(user.email), credentialGroupEnrollment.email) - ) - ) + .innerJoin(user, eq(user.id, credentialGroupEnrollment.userId)) + .innerJoin(workspace, eq(workspace.id, workspaceId)) .where( and( - eq(credential.workspaceId, workspaceId), + or( + eq(credential.workspaceId, workspaceId), + and( + eq(credential.organizationId, workspace.organizationId), + isNull(credential.workspaceId) + ) + ), eq(credential.type, 'personal_token'), credentialId === undefined ? undefined : eq(credential.id, credentialId), eq(credential.createdBy, userId), @@ -88,10 +96,20 @@ export async function getPersonalTokenCredentials( function liveEnrollmentConditions(workspaceId: string, userId: string) { return [ - eq(credentialGroup.workspaceId, workspaceId), + or( + and(eq(credentialGroup.workspaceId, workspaceId), isNull(credentialGroup.organizationId)), + and( + eq(credentialGroup.organizationId, workspace.organizationId), + isNull(credentialGroup.workspaceId) + ) + ), eq(credentialGroup.status, 'active'), eq(user.id, userId), eq(user.emailVerified, true), + or( + isNull(credentialGroup.organizationId), + sql`exists (select 1 from ${member} where ${member.organizationId} = ${credentialGroup.organizationId} and ${member.userId} = ${userId})` + ), inArray(credentialGroupEnrollment.status, ['invited', 'in_progress', 'completed']), isNull(credentialGroupEnrollment.revokedAt), ] @@ -99,10 +117,11 @@ function liveEnrollmentConditions(workspaceId: string, userId: string) { /** Rechecks the canonical group and the verified person behind a bound token before every use. */ export async function requirePersonalTokenEnrollment( - input: { workspaceId: string; userId: string; enrollmentId: string | null }, + input: ResourceOwner & { userId: string; enrollmentId: string | null }, executor: DbOrTx = db, lock = false ): Promise { + const scope = resourceScopeFromOwner(input) if (!input.enrollmentId) throw new OrchestrationError( 'forbidden', @@ -110,28 +129,55 @@ export async function requirePersonalTokenEnrollment( ) if (lock) await lockCredentialGroupEnrollmentLifecycle(executor, input.enrollmentId) const query = executor - .select({ id: credentialGroupEnrollment.id }) + .select({ + id: credentialGroupEnrollment.id, + credentialGroupId: credentialGroup.id, + organizationId: credentialGroup.organizationId, + }) .from(credentialGroupEnrollment) .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) - .innerJoin(user, eq(foldedEmail(user.email), credentialGroupEnrollment.email)) + .innerJoin(user, eq(user.id, credentialGroupEnrollment.userId)) + .leftJoin( + workspace, + scope.kind === 'workspace' ? eq(workspace.id, scope.workspaceId) : sql`false` + ) .where( and( eq(credentialGroupEnrollment.id, input.enrollmentId), - ...liveEnrollmentConditions(input.workspaceId, input.userId) + ...(scope.kind === 'workspace' + ? liveEnrollmentConditions(scope.workspaceId, input.userId) + : [ + resourceScopeCondition(credentialGroup, scope), + eq(credentialGroup.status, 'active'), + eq(user.id, input.userId), + eq(user.emailVerified, true), + inArray(credentialGroupEnrollment.status, ['invited', 'in_progress', 'completed']), + isNull(credentialGroupEnrollment.revokedAt), + sql`exists (select 1 from ${member} where ${member.organizationId} = ${scope.organizationId} and ${member.userId} = ${input.userId})`, + ]) ) ) .limit(1) - const [binding] = await (lock ? query.for('share') : query) + const [binding] = await (lock + ? query.for('share', { of: [credentialGroupEnrollment, credentialGroup, user] }) + : query) if (!binding) throw new OrchestrationError( 'forbidden', 'Your personal account is no longer available in Connected accounts' ) + if (binding.organizationId) { + await requireOrganizationAccountsSetup( + binding.organizationId, + binding.credentialGroupId, + executor + ) + } } export interface CreatePersonalTokenParams { - workspaceId: string userId: string + accounts: { organizationId: string; credentialGroupId: string } providerId?: string apiToken?: string domain?: string @@ -143,29 +189,24 @@ export interface CreatePersonalTokenParams { export async function createPersonalTokenCredential(input: CreatePersonalTokenParams) { if (input.providerId !== 'gitlab' || !input.apiToken) throw new OrchestrationError('validation', 'A personal GitLab access token is required') - const group = await loadWorkspaceAccountsCredentialListContext(input.workspaceId) - if (!group || group.status !== 'active') - throw new OrchestrationError( - 'forbidden', - 'Connected accounts is not available in this workspace' - ) const verified = await verifyGitLabPersonalToken(input.apiToken, input.domain) const encryptedPersonalToken = await encryptPersonalToken({ providerId: verified.providerId, ownerUserId: input.userId, - workspaceId: input.workspaceId, + organizationId: input.accounts.organizationId, subjectId: verified.subjectId, instanceUrl: verified.instanceUrl, accessToken: input.apiToken, }) const { enrollment } = await createViewerCredentialGroupEnrollment({ userId: input.userId, - workspaceId: input.workspaceId, - credentialGroupId: group.credentialGroupId, + organizationId: input.accounts.organizationId, + credentialGroupId: input.accounts.credentialGroupId, }) const values = { type: 'personal_token' as const, - workspaceId: input.workspaceId, + organizationId: input.accounts.organizationId, + workspaceId: null, createdBy: input.userId, credentialGroupEnrollmentId: enrollment.id, providerId: verified.providerId, @@ -181,7 +222,11 @@ export async function createPersonalTokenCredential(input: CreatePersonalTokenPa } return db.transaction(async (tx) => { await requirePersonalTokenEnrollment( - { workspaceId: input.workspaceId, userId: input.userId, enrollmentId: enrollment.id }, + { + organizationId: input.accounts.organizationId, + userId: input.userId, + enrollmentId: enrollment.id, + }, tx, true ) @@ -199,7 +244,7 @@ export async function createPersonalTokenCredential(input: CreatePersonalTokenPa .values({ id: generateId(), ...values }) .onConflictDoNothing({ target: [ - credential.workspaceId, + credential.organizationId, credential.createdBy, credential.providerId, credential.providerTenantId, @@ -230,7 +275,8 @@ export async function createPersonalTokenCredential(input: CreatePersonalTokenPa }) .where( and( - eq(credential.workspaceId, input.workspaceId), + eq(credential.organizationId, input.accounts.organizationId), + isNull(credential.workspaceId), eq(credential.type, 'personal_token'), eq(credential.createdBy, input.userId), eq(credential.providerId, 'gitlab'), @@ -261,7 +307,6 @@ export interface UpdatePersonalTokenParams { export async function updatePersonalTokenCredential(input: UpdatePersonalTokenParams) { const current = input.credential if ( - !current.workspaceId || !current.createdBy || !current.providerTenantId || !current.providerSubjectId || @@ -269,13 +314,13 @@ export async function updatePersonalTokenCredential(input: UpdatePersonalTokenPa ) throw new Error('Personal token identity is incomplete') const { - workspaceId, createdBy: ownerUserId, providerTenantId: instanceUrl, providerSubjectId: subjectId, } = current + const scope = resourceScopeFromOwner(current) const enrollmentBinding = { - workspaceId: current.workspaceId, + ...resourceScopeFields(scope), userId: ownerUserId, enrollmentId: current.credentialGroupEnrollmentId, } @@ -311,7 +356,7 @@ export async function updatePersonalTokenCredential(input: UpdatePersonalTokenPa updates.encryptedPersonalToken = await encryptPersonalToken({ providerId: 'gitlab', ownerUserId: current.createdBy, - workspaceId: current.workspaceId, + ...resourceScopeFields(scope), subjectId: current.providerSubjectId, instanceUrl: current.providerTenantId, accessToken: input.apiToken, @@ -332,7 +377,7 @@ export async function updatePersonalTokenCredential(input: UpdatePersonalTokenPa eq(credential.id, current.id), eq(credential.type, 'personal_token'), eq(credential.createdBy, ownerUserId), - eq(credential.workspaceId, workspaceId), + resourceScopeCondition(credential, scope), eq(credential.providerTenantId, instanceUrl), eq(credential.providerSubjectId, subjectId) ) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 3862b98b6e1..819fb20a27d 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' +import { credential, credentialMember, member, workspace } from '@sim/db/schema' import { and, eq, inArray, isNotNull, notInArray, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import { @@ -32,7 +32,8 @@ export type CredentialRow = typeof credential.$inferSelect export interface VisibleWorkspaceCredential { id: string - workspaceId: string + workspaceId: string | null + organizationId?: string | null type: CredentialRow['type'] displayName: string description: string | null @@ -89,6 +90,22 @@ const CREDENTIAL_SORTS = { /** One page of workspace credentials plus the keys that resume it. */ export type WorkspaceCredentialPage = KeysetPage +/** Personal organization tokens are visible only to their owner in the same organization. */ +function visibleCredentialScope(workspaceId: string, userId?: string) { + return or( + eq(credential.workspaceId, workspaceId), + userId + ? and( + eq(credential.type, 'personal_token'), + eq(credential.createdBy, userId), + sql`exists (select 1 from ${workspace} inner join ${member} on ${member.organizationId} = ${workspace.organizationId} + where ${workspace.id} = ${workspaceId} and ${workspace.organizationId} = ${credential.organizationId} + and ${member.userId} = ${userId} and ${workspace.archivedAt} is null)` + ) + : undefined + ) +} + /** * The credentials a user may see in a workspace. * @@ -135,7 +152,7 @@ export async function listVisibleWorkspaceCredentials(params: { } = params const whereClauses = [ - eq(credential.workspaceId, workspaceId), + visibleCredentialScope(workspaceId, userId), notInArray(credential.type, ['managed_oauth', 'managed_mcp']), isNotNull(credential.createdBy), or(sql`${credential.type} <> 'personal_token'`, eq(credential.createdBy, userId)), @@ -170,6 +187,7 @@ export async function listVisibleWorkspaceCredentials(params: { .select({ id: credential.id, workspaceId: credential.workspaceId, + organizationId: credential.organizationId, type: credential.type, displayName: credential.displayName, description: credential.description, @@ -208,7 +226,7 @@ export async function listVisibleWorkspaceCredentials(params: { const rows = await (limit === undefined ? query : query.limit(limit + 1)) const mapped = rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => { - if (!rest.workspaceId) + if (!rest.workspaceId && !(rest.type === 'personal_token' && rest.organizationId)) throw new Error('Workspace credential query returned an unscoped credential') if (!rest.createdBy) throw new Error(`Credential ${rest.id} has no creator`) return { @@ -312,6 +330,7 @@ export async function listWorkspacePrincipalCredentials(params: { export async function getWorkspaceCredential(params: { workspaceId: string credentialId: string + organizationId?: string }): Promise { const [row] = await db .select() @@ -319,7 +338,15 @@ export async function getWorkspaceCredential(params: { .where( and( eq(credential.id, params.credentialId), - eq(credential.workspaceId, params.workspaceId), + or( + eq(credential.workspaceId, params.workspaceId), + params.organizationId + ? and( + eq(credential.type, 'personal_token'), + eq(credential.organizationId, params.organizationId) + ) + : undefined + ), notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) @@ -345,7 +372,7 @@ export async function findWorkspaceCredentialLookup(params: { .where( and( eq(credential.id, params.credentialId), - eq(credential.workspaceId, params.workspaceId), + visibleCredentialScope(params.workspaceId, params.userId), notInArray(credential.type, ['managed_oauth', 'managed_mcp']), params.userId ? or(sql`${credential.type} <> 'personal_token'`, eq(credential.createdBy, params.userId)) diff --git a/apps/sim/lib/workspaces/create.test.ts b/apps/sim/lib/workspaces/create.test.ts index bea2ebfdf7a..771740f8fca 100644 --- a/apps/sim/lib/workspaces/create.test.ts +++ b/apps/sim/lib/workspaces/create.test.ts @@ -13,17 +13,19 @@ const { mockResolveGoverningPermissionGroupOrganization, mockLockWorkspaceCreationContext, mockGetWorkspaceInvitePolicy, + mockCreateWorkspaceAccountsGroup, } = vi.hoisted(() => ({ mockResolveGoverningPermissionGroupOrganization: vi.fn(), mockLockWorkspaceCreationContext: vi.fn(), mockGetWorkspaceInvitePolicy: vi.fn(), + mockCreateWorkspaceAccountsGroup: vi.fn(), })) /** The starter workflow is not what these cases are about, and it reaches the block registry. */ vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) vi.mock('@/lib/credential-groups/workspace-accounts', () => ({ - createWorkspaceAccountsGroup: vi.fn(), + createWorkspaceAccountsGroup: mockCreateWorkspaceAccountsGroup, })) vi.mock('@/lib/workflows/defaults', () => ({ @@ -115,6 +117,8 @@ describe('createWorkspace capability-gate placement', () => { await createWorkspace({ ...params, skipDefaultWorkflow: true }) + expect(mockCreateWorkspaceAccountsGroup).not.toHaveBeenCalled() + expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith(tx, { userId: 'creator-1', organizationId: 'org-1', @@ -170,6 +174,8 @@ describe('createDefaultPersonalWorkspaceInTransaction', () => { userName: 'Ada Lovelace', }) + expect(mockCreateWorkspaceAccountsGroup).not.toHaveBeenCalled() + expect(mockResolveGoverningPermissionGroupOrganization).not.toHaveBeenCalled() expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith(tx, { userId: 'user-1', diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index dd3ed03a541..44d4e8e77b3 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -3,7 +3,6 @@ import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/sc import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { PlatformEvents } from '@/lib/core/telemetry' -import { createWorkspaceAccountsGroup } from '@/lib/credential-groups/workspace-accounts' import type { DbOrTx } from '@/lib/db/types' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -78,7 +77,7 @@ export interface TransactionalCreateWorkspaceParams extends CreateWorkspaceParam * The caller supplies the creation-policy snapshot. This function revalidates * that snapshot — including the `workspace.create` capability under the * permission-group advisory lock — before inserting the workspace, owner - * permission, connected accounts, and optional starter workflow atomically. + * permission and optional starter workflow atomically. */ export async function createWorkspaceInTransaction( tx: DbOrTx, @@ -145,8 +144,6 @@ export async function createWorkspaceInTransaction( } await tx.insert(permissions).values(permissionRows) - await createWorkspaceAccountsGroup(tx, workspaceId, userId) - if (defaultWorkflowArtifacts) { await tx.insert(workflow).values({ id: workflowId, diff --git a/apps/sim/scripts/migrate-gitlab-personal-tokens.md b/apps/sim/scripts/migrate-gitlab-personal-tokens.md new file mode 100644 index 00000000000..7c22dbc0283 --- /dev/null +++ b/apps/sim/scripts/migrate-gitlab-personal-tokens.md @@ -0,0 +1,30 @@ +# GitLab personal-token ownership migration + +New personal GitLab connections belong to the organization and the connecting Sim user. A workspace is an execution context, not the credential owner. Tokens remain private to their owner; moving them does not grant workflow access. + +Deploy the organization-token application changes completely before running this command. The application supports existing workspace tokens during this transition. The schema already contains the organization owner column and unique index, so this is a data migration rather than a new Drizzle schema migration. + +From `apps/sim`, with the intended database and its existing encryption key configured: + +```sh +bun run scripts/migrate-gitlab-personal-tokens.ts --organization-id= +bun run scripts/migrate-gitlab-personal-tokens.ts --organization-id= --apply +``` + +The first command is a dry run. It validates the existing encrypted bindings and the destination organization without changing credentials or enrollments. Review that result before applying. + +The command pages through at most 100 candidate IDs at a time and commits each credential independently. It preserves the credential ID, provider account, scopes, expiry, and revocation fields; re-encrypts the secret with organization ownership; and binds it to the owner's organization enrollment. A verified legacy enrollment can seed a new organization enrollment without issuing an invitation. Existing organization setup and workspace access policies remain unchanged. + +Migration stops on duplicate provider identities, missing organization setup, missing or unverified membership, revoked or conflicting enrollments, invalid ciphertext, or a concurrent identity change. It does not pick a token to discard, reactivate revoked enrollment access, or recreate organization policies. Resolve the reported condition before retrying. Previously committed credentials are skipped on rerun. + +Run the command separately for each organization. Tokens in workspaces with no organization need an explicit destination decision; the command does not assign one. Old workspace groups remain available for other legacy connections. After every organization is migrated and old application versions are retired, the legacy personal-token index and workspace-envelope support can be removed in a later change. + +Verification: + +- One credential ID appears for its owner in multiple workspaces in the organization. +- Reconnect and rotation update that same credential. +- Other people, including workspace administrators, cannot use the token. +- Deleting the original workspace does not delete the migrated credential. +- Disconnect removes the organization connection across its workspaces. + +The migrated encrypted payload cannot be read by older application code that expects workspace ownership. Do not roll back to that code after applying this data migration without a corresponding data rollback or reconnect procedure. diff --git a/apps/sim/scripts/migrate-gitlab-personal-tokens.ts b/apps/sim/scripts/migrate-gitlab-personal-tokens.ts new file mode 100644 index 00000000000..4ea212c4a25 --- /dev/null +++ b/apps/sim/scripts/migrate-gitlab-personal-tokens.ts @@ -0,0 +1,310 @@ +#!/usr/bin/env bun + +/** + * Rebinds workspace GitLab tokens to their organization's existing Connected accounts group. + * Run after the organization-token application code is fully deployed. Defaults to dry-run: + * bun run scripts/migrate-gitlab-personal-tokens.ts --organization-id= [--apply] + * Uses the configured database and encryption key. No provider requests or invitation emails. + */ +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + member, + user, + workspace, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { normalizeEmail } from '@sim/utils/string' +import { and, asc, eq, gt, isNull, ne, or, sql } from 'drizzle-orm' +import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' +import { decryptPersonalToken, encryptPersonalToken } from '@/lib/credentials/gitlab-personal-token' +import type { DbOrTx } from '@/lib/db/types' + +const logger = createLogger('MigrateGitLabPersonalTokens') +const BATCH_SIZE = 100 + +interface MigrationOptions { + organizationId: string + apply: boolean +} + +function organizationTokens(organizationId: string) { + return and( + eq(credential.type, 'personal_token'), + or(eq(credential.organizationId, organizationId), eq(workspace.organizationId, organizationId)) + ) +} + +/** Refuses to choose between independently connected tokens for the same provider identity. */ +async function assertUniqueIdentities(executor: DbOrTx, organizationId: string) { + const duplicates = await executor + .select({ userId: credential.createdBy }) + .from(credential) + .leftJoin(workspace, eq(workspace.id, credential.workspaceId)) + .where(organizationTokens(organizationId)) + .groupBy( + credential.createdBy, + credential.providerId, + credential.providerTenantId, + credential.providerSubjectId + ) + .having(sql`count(*) > 1`) + .limit(1) + if (duplicates.length) + throw new Error( + 'Duplicate GitLab identities exist in this organization. Resolve the duplicate connections before migrating.' + ) +} + +async function migrateToken(executor: DbOrTx, credentialId: string, options: MigrationOptions) { + const [initial] = await executor + .select() + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + if (!initial) throw new Error('Migration credential disappeared') + if (initial.organizationId === options.organizationId && !initial.workspaceId) return false + if (!initial.credentialGroupEnrollmentId) + throw new Error(`Credential ${credentialId} requires a verified enrollment before migration`) + await lockCredentialGroupEnrollmentLifecycle(executor, initial.credentialGroupEnrollmentId) + const [current] = await executor + .select() + .from(credential) + .where(eq(credential.id, credentialId)) + .for('update') + .limit(1) + if (!current) throw new Error('Migration credential disappeared') + if (current.organizationId === options.organizationId && !current.workspaceId) return false + if (current.credentialGroupEnrollmentId !== initial.credentialGroupEnrollmentId) + throw new Error('Enrollment changed during migration; retry the command') + if ( + current.type !== 'personal_token' || + current.providerId !== 'gitlab' || + !current.workspaceId || + current.organizationId || + !current.createdBy || + !current.providerSubjectId || + !current.providerTenantId || + !current.encryptedPersonalToken + ) { + throw new Error(`Credential ${credentialId} has an incomplete personal-token identity`) + } + const [sourceWorkspace] = await executor + .select({ organizationId: workspace.organizationId }) + .from(workspace) + .where(eq(workspace.id, current.workspaceId)) + .for('share') + .limit(1) + if (sourceWorkspace?.organizationId !== options.organizationId) + throw new Error('Workspace organization changed during migration') + const [owner] = await executor + .select({ id: user.id, email: user.email, verified: user.emailVerified }) + .from(user) + .innerJoin( + member, + and(eq(member.userId, user.id), eq(member.organizationId, options.organizationId)) + ) + .where(eq(user.id, current.createdBy)) + .for('share') + .limit(1) + if (!owner?.verified) + throw new Error(`Credential ${credentialId} requires a verified current organization member`) + const [source] = await executor + .select({ + userId: credentialGroupEnrollment.userId, + status: credentialGroupEnrollment.status, + revokedAt: credentialGroupEnrollment.revokedAt, + workspaceId: credentialGroup.workspaceId, + organizationId: credentialGroup.organizationId, + groupStatus: credentialGroup.status, + }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where(eq(credentialGroupEnrollment.id, current.credentialGroupEnrollmentId!)) + .for('share') + .limit(1) + if ( + !source || + source.userId !== current.createdBy || + source.revokedAt || + source.status === 'revoked' || + source.groupStatus !== 'active' || + !( + source.workspaceId === current.workspaceId || + (source.organizationId === options.organizationId && !source.workspaceId) + ) + ) { + throw new Error(`Credential ${credentialId} has an inactive or mismatched source enrollment`) + } + const [group] = await executor + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.organizationId, options.organizationId), + isNull(credentialGroup.workspaceId), + eq(credentialGroup.status, 'active') + ) + ) + .for('share') + .limit(1) + if (!group) throw new Error('Configure organization Connected accounts before migrating tokens') + await requireOrganizationAccountsSetup(options.organizationId, group.id, executor) + + const targets = await executor + .select() + .from(credentialGroupEnrollment) + .where( + and( + eq(credentialGroupEnrollment.credentialGroupId, group.id), + or( + eq(credentialGroupEnrollment.userId, owner.id), + eq(credentialGroupEnrollment.email, normalizeEmail(owner.email)) + ) + ) + ) + .for('update') + .limit(2) + if (targets.length > 1) + throw new Error(`Credential ${credentialId} has conflicting organization enrollment identities`) + const [target] = targets + if ( + target && + (target.revokedAt || + target.status === 'revoked' || + (target.userId && target.userId !== owner.id)) + ) { + throw new Error( + `Credential ${credentialId} has a revoked or conflicting organization enrollment` + ) + } + const [duplicate] = await executor + .select({ id: credential.id }) + .from(credential) + .leftJoin(workspace, eq(workspace.id, credential.workspaceId)) + .where( + and( + organizationTokens(options.organizationId), + ne(credential.id, current.id), + eq(credential.createdBy, owner.id), + eq(credential.providerId, 'gitlab'), + eq(credential.providerTenantId, current.providerTenantId), + eq(credential.providerSubjectId, current.providerSubjectId) + ) + ) + .limit(1) + if (duplicate) + throw new Error( + 'Duplicate GitLab identity appeared during migration; resolve it before retrying' + ) + const identity = { + providerId: 'gitlab' as const, + ownerUserId: owner.id, + subjectId: current.providerSubjectId, + instanceUrl: current.providerTenantId, + } + const accessToken = await decryptPersonalToken(current.encryptedPersonalToken, { + ...identity, + workspaceId: current.workspaceId, + }) + if (!options.apply) return true + const encryptedPersonalToken = await encryptPersonalToken({ + ...identity, + organizationId: options.organizationId, + accessToken, + }) + const now = new Date() + const enrollmentId = target?.id ?? generateId() + if (!target) { + await executor.insert(credentialGroupEnrollment).values({ + id: enrollmentId, + credentialGroupId: group.id, + userId: owner.id, + email: normalizeEmail(owner.email), + status: 'in_progress', + invitationTokenHash: sha256Hex(generateId()), + invitationExpiresAt: now, + invitedAt: now, + }) + } else if (!target.userId) { + await executor + .update(credentialGroupEnrollment) + .set({ userId: owner.id, status: 'in_progress', updatedAt: now }) + .where(eq(credentialGroupEnrollment.id, enrollmentId)) + } + await executor + .update(credential) + .set({ + organizationId: options.organizationId, + workspaceId: null, + credentialGroupEnrollmentId: enrollmentId, + encryptedPersonalToken, + updatedAt: now, + }) + .where(and(eq(credential.id, current.id), eq(credential.workspaceId, current.workspaceId))) + return true +} + +/** Keyset-paged and resumable; every token and any new enrollment commit together. */ +export async function migrateGitLabPersonalTokens(options: MigrationOptions, database = db) { + if (!options.organizationId.trim()) throw new Error('An organization ID is required') + await assertUniqueIdentities(database, options.organizationId) + let afterId = '' + let processed = 0 + while (true) { + const candidates = await database + .select({ id: credential.id }) + .from(credential) + .innerJoin(workspace, eq(workspace.id, credential.workspaceId)) + .where( + and( + eq(credential.type, 'personal_token'), + eq(workspace.organizationId, options.organizationId), + gt(credential.id, afterId) + ) + ) + .orderBy(asc(credential.id)) + .limit(BATCH_SIZE) + if (!candidates.length) break + for (const candidate of candidates) { + const changed = await database.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '5s'`) + await tx.execute(sql`SET LOCAL statement_timeout = '30s'`) + return migrateToken(tx, candidate.id, options) + }) + if (changed) processed++ + afterId = candidate.id + } + } + return { mode: options.apply ? 'applied' : 'dry-run', processed } +} + +if (import.meta.main) { + const args = process.argv.slice(2) + const organizationArgs = args.filter((arg) => arg.startsWith('--organization-id=')) + if ( + organizationArgs.length !== 1 || + args.some((arg) => arg !== '--apply' && !arg.startsWith('--organization-id=')) + ) { + throw new Error('Usage: migrate-gitlab-personal-tokens.ts --organization-id= [--apply]') + } + try { + logger.info( + 'GitLab token migration complete', + await migrateGitLabPersonalTokens({ + organizationId: organizationArgs[0]!.slice('--organization-id='.length), + apply: args.includes('--apply'), + }) + ) + process.exit(0) + } catch (error) { + logger.error('GitLab token migration failed', { error: getErrorMessage(error) }) + process.exit(1) + } +} diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d5f37658ffd..b02699b1eda 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4606,6 +4606,7 @@ export interface ManagedMcpToolSnapshot { inputSchema: Record } +/** contract-pending(after all GitLab tokens migrate and workspace-token writers are retired): drop credential_personal_token_identity_unique; only the index is retired. */ export const credential = pgTable( 'credential', {