From 69a73bc2343216b428b33e39dcec65e1201489cd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 8 Sep 2026 12:37:12 -0700 Subject: [PATCH] fix(credentials): scope GitLab personal tokens to organizations --- apps/sim/app/api/credentials/[id]/route.ts | 16 +- .../hooks/use-credential-detail-form.ts | 6 +- .../connect-personal-token-modal.tsx | 4 +- .../connected-credential-detail.tsx | 9 +- apps/sim/hooks/queries/credentials.ts | 29 +- apps/sim/hooks/queries/scoped-credentials.ts | 6 +- .../hooks/queries/utils/credential-keys.ts | 4 + apps/sim/lib/api/contracts/credentials.ts | 8 +- ...rganization-personal-tokens.integration.ts | 365 ++++++++++++++++++ apps/sim/lib/credentials/access.ts | 33 +- .../authorized-credential-use-case.ts | 11 +- .../application/credential-context.ts | 22 +- .../application/credential-crud.ts | 14 +- .../credentials/application/presentation.ts | 3 +- .../application/resolve-personal-token.ts | 5 +- .../application/service-account.test.ts | 4 +- .../credentials/gitlab-personal-token.test.ts | 17 + .../lib/credentials/gitlab-personal-token.ts | 33 +- .../lib/credentials/personal-tokens.test.ts | 20 +- apps/sim/lib/credentials/personal-tokens.ts | 69 +++- apps/sim/lib/credentials/queries.ts | 39 +- .../scripts/migrate-gitlab-personal-tokens.md | 30 ++ .../scripts/migrate-gitlab-personal-tokens.ts | 310 +++++++++++++++ packages/db/schema.ts | 1 + 24 files changed, 970 insertions(+), 88 deletions(-) create mode 100644 apps/sim/lib/credentials/__integration__/organization-personal-tokens.integration.ts create mode 100644 apps/sim/scripts/migrate-gitlab-personal-tokens.md create mode 100644 apps/sim/scripts/migrate-gitlab-personal-tokens.ts 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..3f485ed3d18 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='Use a token with the api scope. This connection is private to you and available across your organization.' > ( () => 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/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index d83fd5527b5..ad3fc93ea98 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -26,6 +26,7 @@ import { } from '@/lib/api/contracts/organization-credentials' import { environmentKeys } from '@/hooks/queries/environment' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' +import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' @@ -60,13 +61,18 @@ export function useWorkspaceCredentials(params: { }) } -export function useWorkspaceCredential(credentialId?: string, enabled = true) { +export function useWorkspaceCredential( + credentialId?: string, + enabled = true, + workspaceId?: string +) { return useQuery({ - 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/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.ts b/apps/sim/lib/credentials/application/credential-crud.ts index 3746181c51e..dcc6aa02074 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -239,7 +239,9 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ : 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') } @@ -289,6 +291,7 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ export interface GetWorkspaceCredentialInput { credentialId: string + assertedWorkspaceId?: string } export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ @@ -305,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 } @@ -344,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/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/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 bc78e501dab..e2ccb46b477 100644 --- a/apps/sim/lib/credentials/personal-tokens.test.ts +++ b/apps/sim/lib/credentials/personal-tokens.test.ts @@ -33,7 +33,6 @@ import { import type { CredentialRow } from '@/lib/credentials/queries' const input = { - workspaceId: 'workspace', userId: 'owner', accounts: { organizationId: 'organization', credentialGroupId: 'group' }, providerId: 'gitlab', @@ -50,7 +49,8 @@ const verified = { } const current = { id: 'token', - workspaceId: 'workspace', + workspaceId: null, + organizationId: 'organization', createdBy: 'owner', type: 'personal_token', providerId: 'gitlab', @@ -69,15 +69,10 @@ function binding(organizationId: string | null = '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.id, schemaMock.credentialGroupEnrollment.userId) - expect(eq).toHaveBeenCalledWith( - schemaMock.credentialGroup.organizationId, - schemaMock.workspace.organizationId - ) expect(inArray).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.status, [ 'invited', 'in_progress', @@ -114,11 +109,12 @@ describe('personal GitLab tokens in Connected accounts', () => { 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: 'workspace', + workspaceId: null, + organizationId: 'organization', createdBy: 'owner', providerSubjectId: '42', providerTenantId: verified.instanceUrl, @@ -139,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, @@ -247,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, }) @@ -258,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 8c1b742dde5..41cbd2b2ba3 100644 --- a/apps/sim/lib/credentials/personal-tokens.ts +++ b/apps/sim/lib/credentials/personal-tokens.ts @@ -3,12 +3,19 @@ import { credential, credentialGroup, credentialGroupEnrollment, + 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 { + 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' @@ -57,7 +64,13 @@ export async function getPersonalTokenCredentials( .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), @@ -93,6 +106,10 @@ function liveEnrollmentConditions(workspaceId: string, userId: string) { 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), ] @@ -100,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', @@ -119,15 +137,30 @@ export async function requirePersonalTokenEnrollment( .from(credentialGroupEnrollment) .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) .innerJoin(user, eq(user.id, credentialGroupEnrollment.userId)) - .innerJoin(workspace, eq(workspace.id, input.workspaceId)) + .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', @@ -143,7 +176,6 @@ export async function requirePersonalTokenEnrollment( } export interface CreatePersonalTokenParams { - workspaceId: string userId: string accounts: { organizationId: string; credentialGroupId: string } providerId?: string @@ -161,7 +193,7 @@ export async function createPersonalTokenCredential(input: CreatePersonalTokenPa 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, @@ -173,7 +205,8 @@ export async function createPersonalTokenCredential(input: CreatePersonalTokenPa }) 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, @@ -189,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 ) @@ -207,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, @@ -238,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'), @@ -269,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 || @@ -277,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, } @@ -319,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, @@ -340,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/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', {