chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clearCredentialInValue } from '@/lib/credentials/deletion'
|
||||
|
||||
const TARGET = 'cred_target123'
|
||||
const OTHER = 'cred_other999'
|
||||
|
||||
describe('clearCredentialInValue', () => {
|
||||
it('clears matching subBlock value with id="credential"', () => {
|
||||
const input = { id: 'credential', type: 'oauth-input', value: TARGET }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.value).toEqual({ id: 'credential', type: 'oauth-input', value: '' })
|
||||
})
|
||||
|
||||
it('clears matching subBlock value with id="manualCredential"', () => {
|
||||
const input = { id: 'manualCredential', type: 'short-input', value: TARGET }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.value).toEqual({ id: 'manualCredential', type: 'short-input', value: '' })
|
||||
})
|
||||
|
||||
it('clears matching subBlock value with id="triggerCredentials"', () => {
|
||||
const input = { id: 'triggerCredentials', value: TARGET }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
expect((result.value as { value: string }).value).toBe('')
|
||||
})
|
||||
|
||||
it('leaves unrelated subBlock value untouched', () => {
|
||||
const input = { id: 'someOtherField', value: TARGET }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.value).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves matching subBlock with non-matching value untouched', () => {
|
||||
const input = { id: 'credential', value: OTHER }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.value).toBe(input)
|
||||
})
|
||||
|
||||
it('clears nested tools[].params.credential', () => {
|
||||
const input = {
|
||||
id: 'tools',
|
||||
value: [
|
||||
{ type: 'gmail_send', params: { credential: TARGET, to: 'a@b.com' } },
|
||||
{ type: 'slack_message', params: { credential: OTHER, channel: '#x' } },
|
||||
],
|
||||
}
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
const value = result.value as { value: Array<{ params: { credential: string } }> }
|
||||
expect(value.value[0].params.credential).toBe('')
|
||||
expect(value.value[1].params.credential).toBe(OTHER)
|
||||
})
|
||||
|
||||
it('walks workflow_blocks-style keyed subBlocks structure', () => {
|
||||
const input = {
|
||||
credential: { id: 'credential', value: TARGET },
|
||||
messages: { id: 'messages', value: 'hello' },
|
||||
tools: {
|
||||
id: 'tools',
|
||||
value: [{ type: 'x', params: { credential: TARGET } }],
|
||||
},
|
||||
}
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
const value = result.value as typeof input
|
||||
expect(value.credential.value).toBe('')
|
||||
expect(value.messages.value).toBe('hello')
|
||||
expect(value.tools.value[0].params.credential).toBe('')
|
||||
})
|
||||
|
||||
it('walks deployment-style nested blocks structure', () => {
|
||||
const input = {
|
||||
blocks: {
|
||||
block1: {
|
||||
subBlocks: {
|
||||
credential: { id: 'credential', value: TARGET },
|
||||
},
|
||||
},
|
||||
block2: {
|
||||
subBlocks: {
|
||||
other: { id: 'other', value: 'unrelated' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
const value = result.value as typeof input
|
||||
expect(value.blocks.block1.subBlocks.credential.value).toBe('')
|
||||
expect(value.blocks.block2.subBlocks.other.value).toBe('unrelated')
|
||||
})
|
||||
|
||||
it('returns same reference when no changes are made', () => {
|
||||
const input = {
|
||||
blocks: {
|
||||
block1: {
|
||||
subBlocks: { credential: { id: 'credential', value: OTHER } },
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.value).toBe(input)
|
||||
})
|
||||
|
||||
it('does not match outer "credential" key whose value is an object wrapper', () => {
|
||||
const input = { credential: { id: 'credential', value: TARGET } }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
const value = result.value as { credential: { id: string; value: string } }
|
||||
expect(value.credential).toEqual({ id: 'credential', value: '' })
|
||||
})
|
||||
|
||||
it('clears params.credential string directly even when not nested in tools', () => {
|
||||
const input = { params: { credential: TARGET, channel: '#x' } }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
expect((result.value as typeof input).params).toEqual({ credential: '', channel: '#x' })
|
||||
})
|
||||
|
||||
it('does not match "credential" key when value is a different string', () => {
|
||||
const input = { credential: 'cred_unrelated' }
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.value).toBe(input)
|
||||
})
|
||||
|
||||
it('handles primitives and null', () => {
|
||||
expect(clearCredentialInValue(null, TARGET)).toEqual({ value: null, changed: false })
|
||||
expect(clearCredentialInValue('string', TARGET)).toEqual({ value: 'string', changed: false })
|
||||
expect(clearCredentialInValue(42, TARGET)).toEqual({ value: 42, changed: false })
|
||||
})
|
||||
|
||||
it('clears multiple references in a single pass', () => {
|
||||
const input = {
|
||||
blocks: {
|
||||
a: { subBlocks: { credential: { id: 'credential', value: TARGET } } },
|
||||
b: { subBlocks: { triggerCredentials: { id: 'triggerCredentials', value: TARGET } } },
|
||||
c: {
|
||||
subBlocks: {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
value: [{ params: { credential: TARGET } }, { params: { credential: TARGET } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = clearCredentialInValue(input, TARGET)
|
||||
expect(result.changed).toBe(true)
|
||||
const value = result.value as typeof input
|
||||
expect(value.blocks.a.subBlocks.credential.value).toBe('')
|
||||
expect(value.blocks.b.subBlocks.triggerCredentials.value).toBe('')
|
||||
expect(value.blocks.c.subBlocks.tools.value[0].params.credential).toBe('')
|
||||
expect(value.blocks.c.subBlocks.tools.value[1].params.credential).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckWorkspaceAccess, dbState } = vi.hoisted(() => ({
|
||||
mockCheckWorkspaceAccess: vi.fn(),
|
||||
dbState: { results: [] as any[][] },
|
||||
}))
|
||||
|
||||
function makeChain() {
|
||||
const chain: any = {}
|
||||
chain.from = vi.fn(() => chain)
|
||||
chain.where = vi.fn(() => chain)
|
||||
chain.limit = vi.fn(() => Promise.resolve(dbState.results.shift() ?? []))
|
||||
return chain
|
||||
}
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { select: vi.fn(() => makeChain()) },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
credentialTypeEnum: {
|
||||
enumValues: ['oauth', 'env_workspace', 'env_personal', 'service_account'],
|
||||
},
|
||||
credential: {
|
||||
id: 'credential.id',
|
||||
workspaceId: 'credential.workspaceId',
|
||||
type: 'credential.type',
|
||||
},
|
||||
credentialMember: {
|
||||
credentialId: 'credentialMember.credentialId',
|
||||
userId: 'credentialMember.userId',
|
||||
status: 'credentialMember.status',
|
||||
role: 'credentialMember.role',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...args: unknown[]) => ({ and: args })),
|
||||
eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })),
|
||||
inArray: vi.fn((a: unknown, b: unknown) => ({ inArray: [a, b] })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
checkWorkspaceAccess: mockCheckWorkspaceAccess,
|
||||
}))
|
||||
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
|
||||
const workspaceAdminAccess = { hasAccess: true, canWrite: true, canAdmin: true }
|
||||
const noWorkspaceAccess = { hasAccess: false, canWrite: false, canAdmin: false }
|
||||
|
||||
describe('getCredentialActorContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
dbState.results = []
|
||||
})
|
||||
|
||||
it('treats an explicit credential admin membership as admin', async () => {
|
||||
dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], [{ role: 'admin' }]]
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
|
||||
|
||||
const ctx = await getCredentialActorContext('c1', 'user1')
|
||||
|
||||
expect(ctx.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('derives credential admin from workspace admin for shared credentials', async () => {
|
||||
dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []]
|
||||
mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess)
|
||||
|
||||
const ctx = await getCredentialActorContext('c1', 'admin-user')
|
||||
|
||||
expect(ctx.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('does not derive credential admin on personal env credentials', async () => {
|
||||
dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'env_personal' }], []]
|
||||
mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess)
|
||||
|
||||
const ctx = await getCredentialActorContext('c1', 'admin-user')
|
||||
|
||||
expect(ctx.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('is not admin for a non-admin without membership', async () => {
|
||||
dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []]
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
canWrite: false,
|
||||
canAdmin: false,
|
||||
})
|
||||
|
||||
const ctx = await getCredentialActorContext('c1', 'reader-user')
|
||||
|
||||
expect(ctx.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty context when the credential does not exist', async () => {
|
||||
dbState.results = [[]]
|
||||
|
||||
const ctx = await getCredentialActorContext('missing', 'user1')
|
||||
|
||||
expect(ctx.credential).toBeNull()
|
||||
expect(ctx.isAdmin).toBe(false)
|
||||
expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes workspace access flags from checkWorkspaceAccess', async () => {
|
||||
dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []]
|
||||
mockCheckWorkspaceAccess.mockResolvedValue(noWorkspaceAccess)
|
||||
|
||||
const ctx = await getCredentialActorContext('c1', 'outsider')
|
||||
|
||||
expect(ctx.hasWorkspaceAccess).toBe(false)
|
||||
expect(ctx.canWriteWorkspace).toBe(false)
|
||||
expect(ctx.isAdmin).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credential, credentialMember, credentialTypeEnum } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
type ActiveCredentialMember = typeof credentialMember.$inferSelect
|
||||
type CredentialRecord = typeof credential.$inferSelect
|
||||
|
||||
export type CredentialType = (typeof credentialTypeEnum.enumValues)[number]
|
||||
|
||||
/**
|
||||
* Credential types shared at the workspace level — every type except a user's
|
||||
* personal env vars. Derived from the enum so a newly added credential type is
|
||||
* treated as shared by default, keeping visibility, role, and admin derivation
|
||||
* consistent instead of drifting against a hand-maintained inclusion list.
|
||||
*/
|
||||
export const SHARED_CREDENTIAL_TYPES = credentialTypeEnum.enumValues.filter(
|
||||
(type) => type !== 'env_personal'
|
||||
)
|
||||
|
||||
/** Whether a credential is shared at the workspace level (i.e. not a personal env var). */
|
||||
export function isSharedCredentialType(type: CredentialType): boolean {
|
||||
return type !== 'env_personal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user is an admin of a credential: an explicit credential-member admin,
|
||||
* or — for shared credentials only — a workspace admin (workspace admins are
|
||||
* derived credential admins, but never for personal env vars).
|
||||
*/
|
||||
export function deriveCredentialAdmin(params: {
|
||||
credentialType: CredentialType
|
||||
memberRole: ActiveCredentialMember['role'] | null | undefined
|
||||
workspaceCanAdmin: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
params.memberRole === 'admin' ||
|
||||
(isSharedCredentialType(params.credentialType) && params.workspaceCanAdmin)
|
||||
)
|
||||
}
|
||||
|
||||
export interface CredentialActorContext {
|
||||
credential: CredentialRecord | null
|
||||
member: ActiveCredentialMember | null
|
||||
hasWorkspaceAccess: boolean
|
||||
canWriteWorkspace: boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves user access context for a credential. Pass `workspaceAccess` when the
|
||||
* caller has already resolved access for the credential's workspace to skip a
|
||||
* redundant lookup; it is reused only when it matches the credential's workspace.
|
||||
*/
|
||||
export async function getCredentialActorContext(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
options?: { workspaceAccess?: WorkspaceAccess }
|
||||
): Promise<CredentialActorContext> {
|
||||
const [credentialRow] = await db
|
||||
.select()
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentialRow) {
|
||||
return {
|
||||
credential: null,
|
||||
member: null,
|
||||
hasWorkspaceAccess: false,
|
||||
canWriteWorkspace: false,
|
||||
isAdmin: false,
|
||||
}
|
||||
}
|
||||
|
||||
const providedAccess = options?.workspaceAccess
|
||||
const workspaceAccess =
|
||||
providedAccess && providedAccess.workspace?.id === credentialRow.workspaceId
|
||||
? providedAccess
|
||||
: await checkWorkspaceAccess(credentialRow.workspaceId, userId)
|
||||
const [memberRow] = await db
|
||||
.select()
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, credentialId),
|
||||
eq(credentialMember.userId, userId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const isAdmin = deriveCredentialAdmin({
|
||||
credentialType: credentialRow.type,
|
||||
memberRole: memberRow?.role,
|
||||
workspaceCanAdmin: workspaceAccess.canAdmin,
|
||||
})
|
||||
|
||||
return {
|
||||
credential: credentialRow,
|
||||
member: memberRow ?? null,
|
||||
hasWorkspaceAccess: workspaceAccess.hasAccess,
|
||||
canWriteWorkspace: workspaceAccess.canWrite,
|
||||
isAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all credential memberships for a user across one or more workspaces.
|
||||
* Workspace owners and admins are derived credential admins, so no per-credential
|
||||
* owner promotion is needed to avoid orphaning a credential. Returns the number
|
||||
* of memberships revoked.
|
||||
*/
|
||||
export async function revokeWorkspaceCredentialMembershipsTx(
|
||||
tx: DbOrTx,
|
||||
workspaceId: string | string[],
|
||||
userId: string
|
||||
): Promise<number> {
|
||||
const workspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
|
||||
if (workspaceIds.length === 0) return 0
|
||||
|
||||
const workspaceCredentialIds = await tx
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(inArray(credential.workspaceId, workspaceIds))
|
||||
|
||||
if (workspaceCredentialIds.length === 0) return 0
|
||||
|
||||
const credIds = workspaceCredentialIds.map((c) => c.id)
|
||||
|
||||
const revoked = await tx
|
||||
.update(credentialMember)
|
||||
.set({ status: 'revoked', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.userId, userId),
|
||||
eq(credentialMember.status, 'active'),
|
||||
inArray(credentialMember.credentialId, credIds)
|
||||
)
|
||||
)
|
||||
.returning({ id: credentialMember.id })
|
||||
|
||||
return revoked.length
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
|
||||
|
||||
/**
|
||||
* Discrete validation failure codes returned to the client. The UI maps each
|
||||
* code to a human message; raw Atlassian response bodies stay in server logs.
|
||||
*/
|
||||
export type AtlassianValidationCode =
|
||||
| 'invalid_credentials'
|
||||
| 'site_not_found'
|
||||
| 'atlassian_unavailable'
|
||||
|
||||
export class AtlassianValidationError extends Error {
|
||||
constructor(
|
||||
public readonly code: AtlassianValidationCode,
|
||||
public readonly status: number,
|
||||
public readonly logDetail?: Record<string, unknown>
|
||||
) {
|
||||
super(code)
|
||||
this.name = 'AtlassianValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atlassian Cloud sites are always served from `*.atlassian.net` (production)
|
||||
* or `*.jira-dev.com` (Atlassian's developer sandbox). Anything else is either
|
||||
* a typo (`atlassian.com`, `jira.com`), a Data Center hostname (which our
|
||||
* gateway URL doesn't support), or — worse — an attempt to point this
|
||||
* server-side fetch at internal infrastructure (`localhost`, `169.254.169.254`,
|
||||
* `*.corp`). Restricting to the public Atlassian Cloud suffixes blocks SSRF
|
||||
* at the boundary before any outbound request.
|
||||
*/
|
||||
const ATLASSIAN_CLOUD_HOST_REGEX =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:atlassian\.net|jira-dev\.com)$/i
|
||||
|
||||
export function normalizeAtlassianDomain(rawDomain: string): string {
|
||||
return rawDomain.replace(/^https?:\/\//i, '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function assertAtlassianCloudHost(domain: string): void {
|
||||
if (!ATLASSIAN_CLOUD_HOST_REGEX.test(domain)) {
|
||||
throw new AtlassianValidationError('site_not_found', 400, {
|
||||
step: 'host_validation',
|
||||
domain,
|
||||
reason: 'host is not an Atlassian Cloud site (expected *.atlassian.net)',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an `AtlassianValidationError` with `unauthorizedCode` for 401/403 responses
|
||||
* (which mean the token itself was rejected) and `atlassian_unavailable` for any
|
||||
* other non-2xx.
|
||||
*/
|
||||
async function assertAtlassianResponseOk(
|
||||
res: Response,
|
||||
step: string,
|
||||
unauthorizedCode: AtlassianValidationCode,
|
||||
context: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
if (res.ok) return
|
||||
const body = parseAtlassianErrorMessage(res.status, res.statusText, await res.text())
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new AtlassianValidationError(unauthorizedCode, res.status, { step, body, ...context })
|
||||
}
|
||||
throw new AtlassianValidationError('atlassian_unavailable', res.status, {
|
||||
step,
|
||||
body,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an Atlassian service account scoped API token.
|
||||
*
|
||||
* Scoped service-account tokens cannot call `api.atlassian.com/oauth/token/accessible-resources`
|
||||
* (that endpoint is for OAuth-3LO tokens). Instead we use the public, unauthenticated
|
||||
* `tenant_info` discovery endpoint to resolve cloudId from the site domain, then verify
|
||||
* the token works by hitting `/myself` through the gateway.
|
||||
*/
|
||||
export async function validateAtlassianServiceAccount(
|
||||
apiToken: string,
|
||||
domain: string
|
||||
): Promise<{ accountId: string; displayName: string; cloudId: string }> {
|
||||
assertAtlassianCloudHost(domain)
|
||||
|
||||
const tenantInfoRes = await fetch(`https://${domain}/_edge/tenant_info`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (tenantInfoRes.status === 404) {
|
||||
throw new AtlassianValidationError('site_not_found', 404, { step: 'tenant_info', domain })
|
||||
}
|
||||
// tenant_info is unauthenticated, so there is no "invalid credentials" branch here —
|
||||
// any non-OK that isn't a 404 means Atlassian is unavailable, not the token's fault.
|
||||
await assertAtlassianResponseOk(tenantInfoRes, 'tenant_info', 'atlassian_unavailable', { domain })
|
||||
const tenantInfo = (await tenantInfoRes.json()) as { cloudId?: string }
|
||||
if (!tenantInfo.cloudId) {
|
||||
throw new AtlassianValidationError('atlassian_unavailable', 502, {
|
||||
step: 'tenant_info',
|
||||
reason: 'missing cloudId in response',
|
||||
domain,
|
||||
})
|
||||
}
|
||||
const cloudId = tenantInfo.cloudId
|
||||
|
||||
const myselfRes = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/myself`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' },
|
||||
})
|
||||
await assertAtlassianResponseOk(myselfRes, 'myself', 'invalid_credentials', { cloudId })
|
||||
|
||||
const myself = (await myselfRes.json()) as {
|
||||
accountId?: string
|
||||
displayName?: string
|
||||
emailAddress?: string
|
||||
}
|
||||
if (!myself.accountId) {
|
||||
throw new AtlassianValidationError('atlassian_unavailable', 502, {
|
||||
step: 'myself',
|
||||
reason: 'missing accountId in response',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: myself.accountId,
|
||||
displayName: myself.displayName || myself.emailAddress || domain,
|
||||
cloudId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
export const PENDING_OAUTH_CREDENTIAL_DRAFT_KEY = 'sim.pending-oauth-credential-draft'
|
||||
export const PENDING_CREDENTIAL_CREATE_REQUEST_KEY = 'sim.pending-credential-create-request'
|
||||
export const PENDING_CREDENTIAL_CREATE_REQUEST_EVENT = 'sim:pending-credential-create-request'
|
||||
|
||||
interface PendingOAuthCredentialDraft {
|
||||
workspaceId: string
|
||||
providerId: string
|
||||
displayName: string
|
||||
existingCredentialIds: string[]
|
||||
existingAccountIds: string[]
|
||||
requestedAt: number
|
||||
}
|
||||
|
||||
export interface PendingCredentialCreateRequest {
|
||||
workspaceId: string
|
||||
type: 'env_personal' | 'env_workspace'
|
||||
envKey?: string
|
||||
requestedAt: number
|
||||
}
|
||||
|
||||
function parseJson<T>(raw: string | null): T | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readPendingOAuthCredentialDraft(): PendingOAuthCredentialDraft | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return parseJson<PendingOAuthCredentialDraft>(
|
||||
window.sessionStorage.getItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY)
|
||||
)
|
||||
}
|
||||
|
||||
export function writePendingOAuthCredentialDraft(payload: PendingOAuthCredentialDraft) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.setItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export function clearPendingOAuthCredentialDraft() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.removeItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY)
|
||||
}
|
||||
|
||||
export function readPendingCredentialCreateRequest(): PendingCredentialCreateRequest | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return parseJson<PendingCredentialCreateRequest>(
|
||||
window.sessionStorage.getItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
|
||||
)
|
||||
}
|
||||
|
||||
export function writePendingCredentialCreateRequest(payload: PendingCredentialCreateRequest) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.setItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY, JSON.stringify(payload))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PendingCredentialCreateRequest>(PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, {
|
||||
detail: payload,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function clearPendingCredentialCreateRequest() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
|
||||
}
|
||||
|
||||
export const ADD_CONNECTOR_SEARCH_PARAM = 'addConnector' as const
|
||||
|
||||
const OAUTH_RETURN_CONTEXT_KEY = 'sim.oauth-return-context'
|
||||
|
||||
export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors'
|
||||
|
||||
interface OAuthReturnBase {
|
||||
displayName: string
|
||||
providerId: string
|
||||
preCount: number
|
||||
workspaceId: string
|
||||
reconnect?: boolean
|
||||
requestedAt: number
|
||||
}
|
||||
|
||||
interface OAuthReturnWorkflow extends OAuthReturnBase {
|
||||
origin: 'workflow'
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
interface OAuthReturnIntegrations extends OAuthReturnBase {
|
||||
origin: 'integrations'
|
||||
}
|
||||
|
||||
interface OAuthReturnKBConnectors extends OAuthReturnBase {
|
||||
origin: 'kb-connectors'
|
||||
knowledgeBaseId: string
|
||||
connectorType?: string
|
||||
}
|
||||
|
||||
export type OAuthReturnContext =
|
||||
| OAuthReturnWorkflow
|
||||
| OAuthReturnIntegrations
|
||||
| OAuthReturnKBConnectors
|
||||
|
||||
export function writeOAuthReturnContext(ctx: OAuthReturnContext) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.setItem(OAUTH_RETURN_CONTEXT_KEY, JSON.stringify(ctx))
|
||||
}
|
||||
|
||||
export function readOAuthReturnContext(): OAuthReturnContext | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return parseJson<OAuthReturnContext>(window.sessionStorage.getItem(OAUTH_RETURN_CONTEXT_KEY))
|
||||
}
|
||||
|
||||
export function consumeOAuthReturnContext(): OAuthReturnContext | null {
|
||||
const ctx = readOAuthReturnContext()
|
||||
if (ctx) {
|
||||
window.sessionStorage.removeItem(OAUTH_RETURN_CONTEXT_KEY)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils'
|
||||
|
||||
const logger = createLogger('CredentialDeletion')
|
||||
|
||||
export type CredentialDeleteReason =
|
||||
| 'oauth_disconnect'
|
||||
| 'user_delete'
|
||||
| 'copilot_delete'
|
||||
| 'env_prune'
|
||||
|
||||
interface DeleteCredentialParams {
|
||||
credentialId: string
|
||||
actorId: string
|
||||
actorName?: string | null
|
||||
actorEmail?: string | null
|
||||
reason: CredentialDeleteReason
|
||||
request?: NextRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all stored references to the credential, deletes the row, and
|
||||
* records an audit entry. Idempotent when the row no longer exists.
|
||||
*/
|
||||
export async function deleteCredential(params: DeleteCredentialParams): Promise<void> {
|
||||
const { credentialId, actorId, actorName, actorEmail, reason, request } = params
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: schema.credential.id,
|
||||
workspaceId: schema.credential.workspaceId,
|
||||
type: schema.credential.type,
|
||||
displayName: schema.credential.displayName,
|
||||
providerId: schema.credential.providerId,
|
||||
accountId: schema.credential.accountId,
|
||||
})
|
||||
.from(schema.credential)
|
||||
.where(eq(schema.credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return
|
||||
|
||||
await clearCredentialRefs(credentialId, row.workspaceId)
|
||||
|
||||
await db.delete(schema.credential).where(eq(schema.credential.id, credentialId))
|
||||
|
||||
recordAudit({
|
||||
workspaceId: row.workspaceId,
|
||||
actorId,
|
||||
actorName: actorName ?? undefined,
|
||||
actorEmail: actorEmail ?? undefined,
|
||||
action: AuditAction.CREDENTIAL_DELETED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: credentialId,
|
||||
resourceName: row.displayName,
|
||||
description: `Deleted ${row.type} credential "${row.displayName}" (${reason})`,
|
||||
metadata: {
|
||||
reason,
|
||||
credentialType: row.type,
|
||||
providerId: row.providerId,
|
||||
accountId: row.accountId,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears stored references to a credential across mutable workspace state
|
||||
* (editor blocks, copilot checkpoints, knowledge connectors) and frozen
|
||||
* snapshots (deployed versions, paused executions). Frozen snapshots have
|
||||
* the reference replaced with an empty string so resumed/redeployed runs
|
||||
* fail fast at the affected block instead of with "credential not found".
|
||||
*/
|
||||
export async function clearCredentialRefs(
|
||||
credentialId: string,
|
||||
workspaceId: string
|
||||
): Promise<void> {
|
||||
const needle = `%${credentialId}%`
|
||||
|
||||
await Promise.all([
|
||||
clearInWorkflowBlocks(credentialId, workspaceId, needle),
|
||||
clearInDeploymentVersions(credentialId, workspaceId, needle),
|
||||
clearInPausedExecutions(credentialId, workspaceId, needle),
|
||||
clearInWorkflowCheckpoints(credentialId, workspaceId, needle),
|
||||
clearInKnowledgeConnectors(credentialId),
|
||||
deactivateSlackAppWebhooks(credentialId),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates Slack trigger webhooks bound to this credential so inbound
|
||||
* events stop routing once the account is disconnected: native (`slack_app`)
|
||||
* rows reference it via `providerConfig.credentialId`, custom-bot (`slack`)
|
||||
* rows via `routingKey` = the bot credential id. Neither is a foreign key, so
|
||||
* neither is covered by CASCADE.
|
||||
*/
|
||||
async function deactivateSlackAppWebhooks(credentialId: string): Promise<void> {
|
||||
await db
|
||||
.update(schema.webhook)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.webhook.isActive, true),
|
||||
or(
|
||||
and(
|
||||
eq(schema.webhook.provider, 'slack_app'),
|
||||
sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}`
|
||||
),
|
||||
and(eq(schema.webhook.provider, 'slack'), eq(schema.webhook.routingKey, credentialId))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function clearInWorkflowBlocks(
|
||||
credentialId: string,
|
||||
workspaceId: string,
|
||||
needle: string
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.workflowBlocks.id,
|
||||
subBlocks: schema.workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(schema.workflowBlocks)
|
||||
.innerJoin(schema.workflow, eq(schema.workflow.id, schema.workflowBlocks.workflowId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.workflow.workspaceId, workspaceId),
|
||||
sql`${schema.workflowBlocks.subBlocks}::text LIKE ${needle}`
|
||||
)
|
||||
)
|
||||
|
||||
let updated = 0
|
||||
for (const row of rows) {
|
||||
const next = clearCredentialInValue(row.subBlocks, credentialId)
|
||||
if (next.changed) {
|
||||
await db
|
||||
.update(schema.workflowBlocks)
|
||||
.set({ subBlocks: next.value, updatedAt: new Date() })
|
||||
.where(eq(schema.workflowBlocks.id, row.id))
|
||||
updated += 1
|
||||
}
|
||||
}
|
||||
if (updated > 0) {
|
||||
logger.info('Cleared credential refs in workflow_blocks', {
|
||||
credentialId,
|
||||
workspaceId,
|
||||
updated,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function clearInDeploymentVersions(
|
||||
credentialId: string,
|
||||
workspaceId: string,
|
||||
needle: string
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.workflowDeploymentVersion.id,
|
||||
state: schema.workflowDeploymentVersion.state,
|
||||
})
|
||||
.from(schema.workflowDeploymentVersion)
|
||||
.innerJoin(schema.workflow, eq(schema.workflow.id, schema.workflowDeploymentVersion.workflowId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.workflow.workspaceId, workspaceId),
|
||||
sql`${schema.workflowDeploymentVersion.state}::text LIKE ${needle}`
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const next = clearCredentialInValue(row.state, credentialId)
|
||||
if (next.changed) {
|
||||
await db
|
||||
.update(schema.workflowDeploymentVersion)
|
||||
.set({ state: next.value })
|
||||
.where(eq(schema.workflowDeploymentVersion.id, row.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearInPausedExecutions(
|
||||
credentialId: string,
|
||||
workspaceId: string,
|
||||
needle: string
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.pausedExecutions.id,
|
||||
executionSnapshot: schema.pausedExecutions.executionSnapshot,
|
||||
})
|
||||
.from(schema.pausedExecutions)
|
||||
.innerJoin(schema.workflow, eq(schema.workflow.id, schema.pausedExecutions.workflowId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.workflow.workspaceId, workspaceId),
|
||||
sql`${schema.pausedExecutions.executionSnapshot}::text LIKE ${needle}`
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const next = clearCredentialInValue(row.executionSnapshot, credentialId)
|
||||
if (next.changed) {
|
||||
await db
|
||||
.update(schema.pausedExecutions)
|
||||
.set({ executionSnapshot: next.value, updatedAt: new Date() })
|
||||
.where(eq(schema.pausedExecutions.id, row.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearInWorkflowCheckpoints(
|
||||
credentialId: string,
|
||||
workspaceId: string,
|
||||
needle: string
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.workflowCheckpoints.id,
|
||||
workflowState: schema.workflowCheckpoints.workflowState,
|
||||
})
|
||||
.from(schema.workflowCheckpoints)
|
||||
.innerJoin(schema.workflow, eq(schema.workflow.id, schema.workflowCheckpoints.workflowId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.workflow.workspaceId, workspaceId),
|
||||
sql`${schema.workflowCheckpoints.workflowState}::text LIKE ${needle}`
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const next = clearCredentialInValue(row.workflowState, credentialId)
|
||||
if (next.changed) {
|
||||
await db
|
||||
.update(schema.workflowCheckpoints)
|
||||
.set({ workflowState: next.value, updatedAt: new Date() })
|
||||
.where(eq(schema.workflowCheckpoints.id, row.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearInKnowledgeConnectors(credentialId: string): Promise<void> {
|
||||
await db
|
||||
.update(schema.knowledgeConnector)
|
||||
.set({ credentialId: null, updatedAt: new Date() })
|
||||
.where(eq(schema.knowledgeConnector.credentialId, credentialId))
|
||||
}
|
||||
|
||||
interface ClearResult {
|
||||
value: unknown
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a JSON value and clears credential references matching
|
||||
* `credentialId`. Recognizes the two reference shapes used in workflow state:
|
||||
* subBlock entries (`{id: 'credential'|'manualCredential'|'triggerCredentials', value}`)
|
||||
* and tool params (`{credential: <id>, ...}` inside a tool's `params` object).
|
||||
* Returns the original reference when nothing matched so callers can skip writes.
|
||||
*/
|
||||
export function clearCredentialInValue(input: unknown, credentialId: string): ClearResult {
|
||||
if (Array.isArray(input)) {
|
||||
let changed = false
|
||||
const next = input.map((item) => {
|
||||
const result = clearCredentialInValue(item, credentialId)
|
||||
if (result.changed) changed = true
|
||||
return result.value
|
||||
})
|
||||
return changed ? { value: next, changed: true } : { value: input, changed: false }
|
||||
}
|
||||
|
||||
if (input !== null && typeof input === 'object') {
|
||||
const obj = input as Record<string, unknown>
|
||||
const id = typeof obj.id === 'string' ? obj.id : null
|
||||
const isCredentialSubBlock = id !== null && CREDENTIAL_SUBBLOCK_IDS.has(id)
|
||||
let changed = false
|
||||
const next: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (isCredentialSubBlock && key === 'value' && value === credentialId) {
|
||||
next[key] = ''
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (key === 'credential' && value === credentialId) {
|
||||
next[key] = ''
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
const result = clearCredentialInValue(value, credentialId)
|
||||
next[key] = result.value
|
||||
if (result.changed) changed = true
|
||||
}
|
||||
|
||||
return changed ? { value: next, changed: true } : { value: input, changed: false }
|
||||
}
|
||||
|
||||
return { value: input, changed: false }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { clearDeadFlag } from '@/lib/oauth/terminal-errors'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('CredentialDraftHooks')
|
||||
|
||||
/**
|
||||
* Creates a new credential from a pending draft (normal OAuth connect flow).
|
||||
*/
|
||||
export async function handleCreateCredentialFromDraft(params: {
|
||||
draft: { workspaceId: string; displayName: string; description: string | null }
|
||||
accountId: string
|
||||
providerId: string
|
||||
userId: string
|
||||
now: Date
|
||||
}) {
|
||||
const { draft, accountId, providerId, userId, now } = params
|
||||
const credentialId = generateId()
|
||||
|
||||
try {
|
||||
await db.insert(schema.credential).values({
|
||||
id: credentialId,
|
||||
workspaceId: draft.workspaceId,
|
||||
type: 'oauth',
|
||||
displayName: draft.displayName,
|
||||
description: draft.description ?? null,
|
||||
providerId,
|
||||
accountId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await db.insert(schema.credentialMember).values({
|
||||
id: generateId(),
|
||||
credentialId,
|
||||
userId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
logger.info('Created credential from draft', {
|
||||
credentialId,
|
||||
displayName: draft.displayName,
|
||||
providerId,
|
||||
accountId,
|
||||
})
|
||||
|
||||
await clearDeadFlag(accountId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: draft.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.CREDENTIAL_CREATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: credentialId,
|
||||
resourceName: draft.displayName,
|
||||
description: `Created OAuth credential "${draft.displayName}"`,
|
||||
metadata: { providerId, accountId },
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'credential_connected',
|
||||
{
|
||||
credential_type: 'oauth',
|
||||
provider_id: providerId,
|
||||
workspace_id: draft.workspaceId,
|
||||
},
|
||||
{
|
||||
groups: { workspace: draft.workspaceId },
|
||||
setOnce: { first_credential_connected_at: now.toISOString() },
|
||||
}
|
||||
)
|
||||
} catch (insertError: unknown) {
|
||||
const code =
|
||||
insertError && typeof insertError === 'object' && 'code' in insertError
|
||||
? (insertError as { code: string }).code
|
||||
: undefined
|
||||
if (code !== '23505') {
|
||||
throw insertError
|
||||
}
|
||||
logger.info('Credential already exists, skipping draft', {
|
||||
providerId,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnects an existing credential to a new OAuth account.
|
||||
* Handles unique constraint checks and orphaned account cleanup.
|
||||
*/
|
||||
export async function handleReconnectCredential(params: {
|
||||
draft: { credentialId: string | null; workspaceId: string; displayName: string }
|
||||
newAccountId: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
now: Date
|
||||
}) {
|
||||
const { draft, newAccountId, workspaceId, userId, now } = params
|
||||
if (!draft.credentialId) return
|
||||
|
||||
const [existingCredential] = await db
|
||||
.select({ id: schema.credential.id, accountId: schema.credential.accountId })
|
||||
.from(schema.credential)
|
||||
.where(eq(schema.credential.id, draft.credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingCredential) {
|
||||
logger.warn('Credential not found for reconnect, skipping', {
|
||||
credentialId: draft.credentialId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const oldAccountId = existingCredential.accountId
|
||||
|
||||
if (oldAccountId === newAccountId) {
|
||||
logger.info('Account unchanged during reconnect, skipping update', {
|
||||
credentialId: draft.credentialId,
|
||||
accountId: newAccountId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const [conflicting] = await db
|
||||
.select({ id: schema.credential.id })
|
||||
.from(schema.credential)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.credential.workspaceId, workspaceId),
|
||||
eq(schema.credential.accountId, newAccountId),
|
||||
sql`${schema.credential.id} != ${draft.credentialId}`
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (conflicting) {
|
||||
logger.warn('New account already used by another credential, skipping reconnect', {
|
||||
credentialId: draft.credentialId,
|
||||
newAccountId,
|
||||
conflictingCredentialId: conflicting.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.credential)
|
||||
.set({ accountId: newAccountId, updatedAt: now })
|
||||
.where(eq(schema.credential.id, draft.credentialId))
|
||||
|
||||
logger.info('Reconnected credential to new account', {
|
||||
credentialId: draft.credentialId,
|
||||
oldAccountId,
|
||||
newAccountId,
|
||||
})
|
||||
|
||||
await clearDeadFlag(newAccountId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.CREDENTIAL_RECONNECTED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: draft.credentialId,
|
||||
resourceName: draft.displayName,
|
||||
description: `Reconnected OAuth credential "${draft.displayName}" to a new account`,
|
||||
metadata: { oldAccountId, newAccountId },
|
||||
})
|
||||
|
||||
if (oldAccountId) {
|
||||
const [stillReferenced] = await db
|
||||
.select({ id: schema.credential.id })
|
||||
.from(schema.credential)
|
||||
.where(eq(schema.credential.accountId, oldAccountId))
|
||||
.limit(1)
|
||||
|
||||
if (!stillReferenced) {
|
||||
await db.delete(schema.account).where(eq(schema.account.id, oldAccountId))
|
||||
logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import {
|
||||
handleCreateCredentialFromDraft,
|
||||
handleReconnectCredential,
|
||||
} from '@/lib/credentials/draft-hooks'
|
||||
|
||||
const logger = createLogger('CredentialDraftProcessor')
|
||||
|
||||
interface ProcessCredentialDraftParams {
|
||||
userId: string
|
||||
providerId: string
|
||||
accountId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a pending credential draft for the given user/provider and processes it.
|
||||
* Creates a new credential or reconnects an existing one depending on the draft state.
|
||||
* Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello).
|
||||
*/
|
||||
export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise<void> {
|
||||
const { userId, providerId, accountId } = params
|
||||
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(schema.pendingCredentialDraft)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.pendingCredentialDraft.userId, userId),
|
||||
eq(schema.pendingCredentialDraft.providerId, providerId),
|
||||
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!draft) return
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (draft.credentialId) {
|
||||
await handleReconnectCredential({
|
||||
draft,
|
||||
newAccountId: accountId,
|
||||
workspaceId: draft.workspaceId,
|
||||
userId,
|
||||
now,
|
||||
})
|
||||
} else {
|
||||
await handleCreateCredentialFromDraft({
|
||||
draft,
|
||||
accountId,
|
||||
providerId,
|
||||
userId,
|
||||
now,
|
||||
})
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(schema.pendingCredentialDraft)
|
||||
.where(eq(schema.pendingCredentialDraft.id, draft.id))
|
||||
|
||||
logger.info('Processed credential draft', {
|
||||
draftId: draft.id,
|
||||
userId,
|
||||
providerId,
|
||||
isReconnect: Boolean(draft.credentialId),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment'
|
||||
|
||||
describe('getWorkspaceEnvKeyAdminAccess', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('returns empty sets without querying when no keys are provided', async () => {
|
||||
const result = await getWorkspaceEnvKeyAdminAccess({
|
||||
workspaceId: 'ws-1',
|
||||
envKeys: [],
|
||||
userId: 'u-1',
|
||||
})
|
||||
|
||||
expect(result.adminKeys.size).toBe(0)
|
||||
expect(result.knownKeys.size).toBe(0)
|
||||
expect(dbChainMockFns.where).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks a key admin only for an active admin membership, known for any credential', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([
|
||||
{ envKey: 'OWNED', role: 'admin', status: 'active' },
|
||||
{ envKey: 'MEMBER_ONLY', role: 'member', status: 'active' },
|
||||
{ envKey: 'PENDING_ADMIN', role: 'admin', status: 'pending' },
|
||||
{ envKey: 'NO_MEMBERSHIP', role: null, status: null },
|
||||
])
|
||||
|
||||
const result = await getWorkspaceEnvKeyAdminAccess({
|
||||
workspaceId: 'ws-1',
|
||||
envKeys: ['OWNED', 'MEMBER_ONLY', 'PENDING_ADMIN', 'NO_MEMBERSHIP', 'ABSENT'],
|
||||
userId: 'u-1',
|
||||
})
|
||||
|
||||
expect([...result.adminKeys]).toEqual(['OWNED'])
|
||||
expect([...result.knownKeys].sort()).toEqual([
|
||||
'MEMBER_ONLY',
|
||||
'NO_MEMBERSHIP',
|
||||
'OWNED',
|
||||
'PENDING_ADMIN',
|
||||
])
|
||||
expect(result.knownKeys.has('ABSENT')).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupes and drops empty keys before issuing a single query', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([])
|
||||
|
||||
await getWorkspaceEnvKeyAdminAccess({
|
||||
workspaceId: 'ws-1',
|
||||
envKeys: ['A', 'A', '', 'B'],
|
||||
userId: 'u-1',
|
||||
})
|
||||
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credential, credentialMember, permissions, workspace } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNotNull, isNull, notInArray, or, sql } from 'drizzle-orm'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export interface WorkspaceMembership {
|
||||
ownerId: string | null
|
||||
/** All workspace members: the owner plus everyone with a workspace permission. */
|
||||
memberUserIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a workspace's membership in one owner lookup + one permissions scan.
|
||||
* Credential-admin status is derived from workspace role at access time, so
|
||||
* members are seeded only for use access (the owner plus permission holders).
|
||||
*/
|
||||
export async function getWorkspaceMembership(workspaceId: string): Promise<WorkspaceMembership> {
|
||||
const [workspaceRows, permissionRows] = await Promise.all([
|
||||
db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))),
|
||||
])
|
||||
|
||||
const ownerId = workspaceRows[0]?.ownerId ?? null
|
||||
const memberUserIds = new Set<string>(permissionRows.map((row) => row.userId))
|
||||
if (ownerId) {
|
||||
memberUserIds.add(ownerId)
|
||||
}
|
||||
|
||||
return { ownerId, memberUserIds: Array.from(memberUserIds) }
|
||||
}
|
||||
|
||||
export interface WorkspaceEnvKeyAdminAccess {
|
||||
/** Keys for which the caller is an active credential admin. */
|
||||
adminKeys: Set<string>
|
||||
/** Keys that already have an `env_workspace` credential (regardless of role). */
|
||||
knownKeys: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* For a set of workspace env keys, resolves which the caller may administer
|
||||
* (active `credential_member` with role `admin`) and which already have an
|
||||
* `env_workspace` credential at all. Keys absent from `knownKeys` have no ACL
|
||||
* yet (new or legacy), letting routes fall back to a workspace-permission gate.
|
||||
*/
|
||||
export async function getWorkspaceEnvKeyAdminAccess(params: {
|
||||
workspaceId: string
|
||||
envKeys: string[]
|
||||
userId: string
|
||||
}): Promise<WorkspaceEnvKeyAdminAccess> {
|
||||
const { workspaceId, envKeys, userId } = params
|
||||
const keys = Array.from(new Set(envKeys.filter(Boolean)))
|
||||
if (keys.length === 0) return { adminKeys: new Set(), knownKeys: new Set() }
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
envKey: credential.envKey,
|
||||
role: credentialMember.role,
|
||||
status: credentialMember.status,
|
||||
})
|
||||
.from(credential)
|
||||
.leftJoin(
|
||||
credentialMember,
|
||||
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_workspace'),
|
||||
inArray(credential.envKey, keys)
|
||||
)
|
||||
)
|
||||
|
||||
const knownKeys = new Set<string>()
|
||||
const adminKeys = new Set<string>()
|
||||
for (const row of rows) {
|
||||
if (!row.envKey) continue
|
||||
knownKeys.add(row.envKey)
|
||||
if (row.role === 'admin' && row.status === 'active') adminKeys.add(row.envKey)
|
||||
}
|
||||
return { adminKeys, knownKeys }
|
||||
}
|
||||
|
||||
interface AccessibleEnvCredential {
|
||||
type: 'env_workspace' | 'env_personal'
|
||||
envKey: string
|
||||
envOwnerUserId: string | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export async function getUserWorkspaceIds(userId: string): Promise<string[]> {
|
||||
const [permissionRows, ownedWorkspaceRows] = await Promise.all([
|
||||
db
|
||||
.select({ workspaceId: workspace.id })
|
||||
.from(permissions)
|
||||
.innerJoin(
|
||||
workspace,
|
||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspace.id))
|
||||
)
|
||||
.where(and(eq(permissions.userId, userId), isNull(workspace.archivedAt))),
|
||||
db
|
||||
.select({ workspaceId: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt))),
|
||||
])
|
||||
|
||||
const workspaceIds = new Set<string>(permissionRows.map((row) => row.workspaceId))
|
||||
for (const row of ownedWorkspaceRows) {
|
||||
workspaceIds.add(row.workspaceId)
|
||||
}
|
||||
|
||||
return Array.from(workspaceIds)
|
||||
}
|
||||
|
||||
async function ensureWorkspaceCredentialMemberships(
|
||||
credentialId: string,
|
||||
memberUserIds: string[],
|
||||
invitedBy: string
|
||||
) {
|
||||
if (!memberUserIds.length) return
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({
|
||||
userId: credentialMember.userId,
|
||||
status: credentialMember.status,
|
||||
})
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, credentialId),
|
||||
inArray(credentialMember.userId, memberUserIds)
|
||||
)
|
||||
)
|
||||
|
||||
// Revoked memberships are filtered out so ON CONFLICT cannot resurrect them.
|
||||
const revokedUserIds = new Set<string>(
|
||||
existingMemberships.filter((row) => row.status === 'revoked').map((row) => row.userId)
|
||||
)
|
||||
const targetUserIds = memberUserIds.filter((id) => !revokedUserIds.has(id))
|
||||
if (targetUserIds.length === 0) return
|
||||
|
||||
const now = new Date()
|
||||
const values = targetUserIds.map((memberUserId) => ({
|
||||
id: generateId(),
|
||||
credentialId,
|
||||
userId: memberUserId,
|
||||
role: 'member' as const,
|
||||
status: 'active' as const,
|
||||
joinedAt: now,
|
||||
invitedBy,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
// Existing roles (including manual per-secret overrides) are preserved on
|
||||
// conflict; only membership activeness and a missing joinedAt are reconciled.
|
||||
await db
|
||||
.insert(credentialMember)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [credentialMember.credentialId, credentialMember.userId],
|
||||
set: {
|
||||
status: 'active',
|
||||
joinedAt: sql`COALESCE(${credentialMember.joinedAt}, excluded.joined_at)`,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function syncWorkspaceEnvCredentials(params: {
|
||||
workspaceId: string
|
||||
envKeys: string[]
|
||||
actingUserId: string
|
||||
}) {
|
||||
const { workspaceId, envKeys, actingUserId } = params
|
||||
const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId)
|
||||
|
||||
if (!ownerId) return
|
||||
|
||||
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
|
||||
const existingCredentials = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
envKey: credential.envKey,
|
||||
})
|
||||
.from(credential)
|
||||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
||||
|
||||
const existingByKey = new Map(
|
||||
existingCredentials
|
||||
.filter((row): row is { id: string; envKey: string } => Boolean(row.envKey))
|
||||
.map((row) => [row.envKey, row.id])
|
||||
)
|
||||
|
||||
const credentialIdsToEnsureMembership = new Set<string>()
|
||||
const now = new Date()
|
||||
|
||||
for (const envKey of normalizedKeys) {
|
||||
const existingId = existingByKey.get(envKey)
|
||||
if (existingId) credentialIdsToEnsureMembership.add(existingId)
|
||||
}
|
||||
|
||||
const keysToCreate = normalizedKeys.filter((key) => !existingByKey.has(key))
|
||||
if (keysToCreate.length > 0) {
|
||||
const inserted = await db
|
||||
.insert(credential)
|
||||
.values(
|
||||
keysToCreate.map((envKey) => ({
|
||||
id: generateId(),
|
||||
workspaceId,
|
||||
type: 'env_workspace' as const,
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
createdBy: actingUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: credential.id })
|
||||
for (const row of inserted) {
|
||||
credentialIdsToEnsureMembership.add(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const credentialId of credentialIdsToEnsureMembership) {
|
||||
await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, ownerId)
|
||||
}
|
||||
|
||||
if (normalizedKeys.length > 0) {
|
||||
await db
|
||||
.delete(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_workspace'),
|
||||
notInArray(credential.envKey, normalizedKeys)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(credential)
|
||||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates credential records and bulk-inserts memberships for newly added workspace env keys.
|
||||
* Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys are new.
|
||||
*/
|
||||
export async function createWorkspaceEnvCredentials(params: {
|
||||
workspaceId: string
|
||||
newKeys: string[]
|
||||
actingUserId: string
|
||||
}): Promise<void> {
|
||||
const { workspaceId, newKeys, actingUserId } = params
|
||||
const keys = Array.from(new Set(newKeys.filter(Boolean)))
|
||||
if (keys.length === 0) return
|
||||
|
||||
const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId)
|
||||
|
||||
if (!ownerId) return
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const inserted = await db
|
||||
.insert(credential)
|
||||
.values(
|
||||
keys.map((envKey) => ({
|
||||
id: generateId(),
|
||||
workspaceId,
|
||||
type: 'env_workspace' as const,
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
createdBy: actingUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: credential.id })
|
||||
const createdIds = inserted.map((row) => row.id)
|
||||
|
||||
if (createdIds.length === 0 || memberUserIds.length === 0) return
|
||||
|
||||
// Bulk-insert memberships for all new credentials × all workspace members in one query
|
||||
const membershipValues = createdIds.flatMap((credentialId) =>
|
||||
memberUserIds.map((memberUserId) => ({
|
||||
id: generateId(),
|
||||
credentialId,
|
||||
userId: memberUserId,
|
||||
role: (memberUserId === actingUserId ? 'admin' : 'member') as 'admin' | 'member',
|
||||
status: 'active' as const,
|
||||
joinedAt: now,
|
||||
invitedBy: actingUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
|
||||
await db.insert(credentialMember).values(membershipValues).onConflictDoNothing()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes credential records (and their memberships via cascade) for removed workspace env keys.
|
||||
* Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys were deleted.
|
||||
*/
|
||||
export async function deleteWorkspaceEnvCredentials(params: {
|
||||
workspaceId: string
|
||||
removedKeys: string[]
|
||||
}): Promise<void> {
|
||||
const { workspaceId, removedKeys } = params
|
||||
const keys = removedKeys.filter(Boolean)
|
||||
if (keys.length === 0) return
|
||||
|
||||
await db
|
||||
.delete(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_workspace'),
|
||||
inArray(credential.envKey, keys)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function syncPersonalEnvCredentialsForUser(params: {
|
||||
userId: string
|
||||
envKeys: string[]
|
||||
}): Promise<void> {
|
||||
const { userId, envKeys } = params
|
||||
const workspaceIds = await getUserWorkspaceIds(userId)
|
||||
if (!workspaceIds.length) return
|
||||
|
||||
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
|
||||
const now = new Date()
|
||||
|
||||
await Promise.all(
|
||||
workspaceIds.map(async (workspaceId) => {
|
||||
if (normalizedKeys.length > 0) {
|
||||
await db
|
||||
.insert(credential)
|
||||
.values(
|
||||
normalizedKeys.map((envKey) => ({
|
||||
id: generateId(),
|
||||
workspaceId,
|
||||
type: 'env_personal' as const,
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
envOwnerUserId: userId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
}
|
||||
|
||||
const currentCredentials =
|
||||
normalizedKeys.length > 0
|
||||
? await db
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_personal'),
|
||||
eq(credential.envOwnerUserId, userId),
|
||||
inArray(credential.envKey, normalizedKeys)
|
||||
)
|
||||
)
|
||||
: []
|
||||
|
||||
if (currentCredentials.length > 0) {
|
||||
await db
|
||||
.insert(credentialMember)
|
||||
.values(
|
||||
currentCredentials.map(({ id: credentialId }) => ({
|
||||
id: generateId(),
|
||||
credentialId,
|
||||
userId,
|
||||
role: 'admin' as const,
|
||||
status: 'active' as const,
|
||||
joinedAt: now,
|
||||
invitedBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [credentialMember.credentialId, credentialMember.userId],
|
||||
set: { role: 'admin', status: 'active', updatedAt: now },
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedKeys.length > 0) {
|
||||
await db
|
||||
.delete(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_personal'),
|
||||
eq(credential.envOwnerUserId, userId),
|
||||
notInArray(credential.envKey, normalizedKeys)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
await db
|
||||
.delete(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_personal'),
|
||||
eq(credential.envOwnerUserId, userId)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function getAccessibleEnvCredentials(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
options?: { isWorkspaceAdmin?: boolean }
|
||||
): Promise<AccessibleEnvCredential[]> {
|
||||
const isWorkspaceAdmin =
|
||||
options?.isWorkspaceAdmin ?? (await hasWorkspaceAdminAccess(userId, workspaceId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
type: credential.type,
|
||||
envKey: credential.envKey,
|
||||
envOwnerUserId: credential.envOwnerUserId,
|
||||
updatedAt: credential.updatedAt,
|
||||
})
|
||||
.from(credential)
|
||||
.leftJoin(
|
||||
credentialMember,
|
||||
and(
|
||||
eq(credentialMember.credentialId, credential.id),
|
||||
eq(credentialMember.userId, userId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['env_workspace', 'env_personal']),
|
||||
or(
|
||||
isNotNull(credentialMember.id),
|
||||
eq(credential.envOwnerUserId, userId),
|
||||
isWorkspaceAdmin ? eq(credential.type, 'env_workspace') : undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
(row): row is typeof row & { type: 'env_workspace' | 'env_personal'; envKey: string } =>
|
||||
row.envKey !== null && (row.type === 'env_workspace' || row.type === 'env_personal')
|
||||
)
|
||||
.map((row) => ({
|
||||
type: row.type,
|
||||
envKey: row.envKey,
|
||||
envOwnerUserId: row.envOwnerUserId,
|
||||
updatedAt: row.updatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface AccessibleOAuthCredential {
|
||||
id: string
|
||||
providerId: string
|
||||
displayName: string
|
||||
role: 'admin' | 'member'
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export async function getAccessibleOAuthCredentials(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
options?: { isWorkspaceAdmin?: boolean }
|
||||
): Promise<AccessibleOAuthCredential[]> {
|
||||
const isWorkspaceAdmin =
|
||||
options?.isWorkspaceAdmin ?? (await hasWorkspaceAdminAccess(userId, workspaceId))
|
||||
|
||||
if (isWorkspaceAdmin) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
providerId: credential.providerId,
|
||||
displayName: credential.displayName,
|
||||
updatedAt: credential.updatedAt,
|
||||
})
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account'])
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
.filter((row): row is typeof row & { providerId: string } => Boolean(row.providerId))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
role: 'admin' as const,
|
||||
updatedAt: row.updatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
providerId: credential.providerId,
|
||||
displayName: credential.displayName,
|
||||
role: credentialMember.role,
|
||||
updatedAt: credential.updatedAt,
|
||||
})
|
||||
.from(credential)
|
||||
.innerJoin(
|
||||
credentialMember,
|
||||
and(
|
||||
eq(credentialMember.credentialId, credential.id),
|
||||
eq(credentialMember.userId, userId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account'])
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
.filter((row): row is AccessibleOAuthCredential => Boolean(row.providerId))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
providerId: row.providerId!,
|
||||
displayName: row.displayName,
|
||||
role: row.role,
|
||||
updatedAt: row.updatedAt,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential, credentialMember } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, notInArray } from 'drizzle-orm'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
|
||||
/** Provider IDs that are not real OAuth integrations (login-only social providers and password) */
|
||||
const NON_OAUTH_PROVIDER_IDS = ['credential', 'google', 'github'] as const
|
||||
|
||||
interface SyncWorkspaceOAuthCredentialsForUserParams {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface SyncWorkspaceOAuthCredentialsForUserResult {
|
||||
updatedMemberships: number
|
||||
}
|
||||
|
||||
function getPostgresErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined
|
||||
const err = error as { code?: string; cause?: { code?: string } }
|
||||
return err.code || err.cause?.code
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes display names and ensures credential memberships for existing
|
||||
* workspace-scoped OAuth credentials. Does not create new credentials —
|
||||
* credential creation is handled by the draft-based OAuth connect flow.
|
||||
*/
|
||||
export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
params: SyncWorkspaceOAuthCredentialsForUserParams
|
||||
): Promise<SyncWorkspaceOAuthCredentialsForUserResult> {
|
||||
const { workspaceId, userId } = params
|
||||
|
||||
const userAccounts = await db
|
||||
.select({
|
||||
id: account.id,
|
||||
providerId: account.providerId,
|
||||
accountId: account.accountId,
|
||||
})
|
||||
.from(account)
|
||||
.where(
|
||||
and(eq(account.userId, userId), notInArray(account.providerId, [...NON_OAUTH_PROVIDER_IDS]))
|
||||
)
|
||||
|
||||
if (userAccounts.length === 0) {
|
||||
return { updatedMemberships: 0 }
|
||||
}
|
||||
|
||||
const accountIds = userAccounts.map((row) => row.id)
|
||||
const existingCredentials = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
accountId: credential.accountId,
|
||||
})
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'oauth'),
|
||||
inArray(credential.accountId, accountIds)
|
||||
)
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const userAccountById = new Map(userAccounts.map((row) => [row.id, row]))
|
||||
for (const existingCredential of existingCredentials) {
|
||||
if (!existingCredential.accountId) continue
|
||||
const linkedAccount = userAccountById.get(existingCredential.accountId)
|
||||
if (!linkedAccount) continue
|
||||
|
||||
const normalizedLabel =
|
||||
getServiceConfigByProviderId(linkedAccount.providerId)?.name || linkedAccount.providerId
|
||||
const shouldNormalizeDisplayName =
|
||||
existingCredential.displayName === linkedAccount.accountId ||
|
||||
existingCredential.displayName === linkedAccount.providerId
|
||||
|
||||
if (!shouldNormalizeDisplayName || existingCredential.displayName === normalizedLabel) {
|
||||
continue
|
||||
}
|
||||
|
||||
await db
|
||||
.update(credential)
|
||||
.set({
|
||||
displayName: normalizedLabel,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(credential.id, existingCredential.id))
|
||||
}
|
||||
|
||||
const credentialRows = await db
|
||||
.select({ id: credential.id, accountId: credential.accountId })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'oauth'),
|
||||
inArray(credential.accountId, accountIds)
|
||||
)
|
||||
)
|
||||
|
||||
const credentialIdByAccountId = new Map(
|
||||
credentialRows.filter((row) => Boolean(row.accountId)).map((row) => [row.accountId!, row.id])
|
||||
)
|
||||
const allCredentialIds = Array.from(credentialIdByAccountId.values())
|
||||
if (allCredentialIds.length === 0) {
|
||||
return { updatedMemberships: 0 }
|
||||
}
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({
|
||||
id: credentialMember.id,
|
||||
credentialId: credentialMember.credentialId,
|
||||
joinedAt: credentialMember.joinedAt,
|
||||
})
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
inArray(credentialMember.credentialId, allCredentialIds),
|
||||
eq(credentialMember.userId, userId)
|
||||
)
|
||||
)
|
||||
|
||||
const membershipByCredentialId = new Map(
|
||||
existingMemberships.map((row) => [row.credentialId, row])
|
||||
)
|
||||
let updatedMemberships = 0
|
||||
|
||||
for (const credentialId of allCredentialIds) {
|
||||
const existingMembership = membershipByCredentialId.get(credentialId)
|
||||
if (existingMembership) {
|
||||
await db
|
||||
.update(credentialMember)
|
||||
.set({
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: existingMembership.joinedAt ?? now,
|
||||
invitedBy: userId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(credentialMember.id, existingMembership.id))
|
||||
updatedMemberships += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(credentialMember).values({
|
||||
id: generateId(),
|
||||
credentialId,
|
||||
userId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
updatedMemberships += 1
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) !== '23505') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedMemberships }
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { credential, environment, webhook, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
|
||||
import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion'
|
||||
import {
|
||||
deleteWorkspaceEnvCredentials,
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
} from '@/lib/credentials/environment'
|
||||
import {
|
||||
ServiceAccountSecretError,
|
||||
verifyAndBuildServiceAccountSecret,
|
||||
} from '@/lib/credentials/service-account-secret'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('CredentialOrchestration')
|
||||
|
||||
export type CredentialOrchestrationErrorCode =
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
| 'validation'
|
||||
| 'conflict'
|
||||
| 'internal'
|
||||
|
||||
interface CredentialActorParams {
|
||||
credentialId: string
|
||||
userId: string
|
||||
actorName?: string | null
|
||||
actorEmail?: string | null
|
||||
allowedTypes?: Array<typeof credential.$inferSelect.type>
|
||||
reason?: CredentialDeleteReason
|
||||
request?: NextRequest
|
||||
}
|
||||
|
||||
export interface PerformUpdateCredentialParams extends CredentialActorParams {
|
||||
displayName?: string
|
||||
description?: string | null
|
||||
serviceAccountJson?: string
|
||||
/** Slack custom-bot secret rotation (reconnect). */
|
||||
signingSecret?: string
|
||||
botToken?: string
|
||||
/** Atlassian service-account secret rotation (reconnect). */
|
||||
apiToken?: string
|
||||
domain?: string
|
||||
}
|
||||
|
||||
export interface PerformCredentialResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
errorCode?: CredentialOrchestrationErrorCode
|
||||
/** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */
|
||||
providerErrorCode?: string
|
||||
workspaceId?: string
|
||||
updatedFields?: string[]
|
||||
}
|
||||
|
||||
export async function performUpdateCredential(
|
||||
params: PerformUpdateCredentialParams
|
||||
): Promise<PerformCredentialResult> {
|
||||
try {
|
||||
const access = await getCredentialActorContext(params.credentialId, params.userId)
|
||||
if (!access.credential) {
|
||||
return { success: false, error: 'Credential not found', errorCode: 'not_found' }
|
||||
}
|
||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Credential admin permission required',
|
||||
errorCode: 'forbidden',
|
||||
}
|
||||
}
|
||||
if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (params.description !== undefined) {
|
||||
updates.description = params.description ?? null
|
||||
}
|
||||
if (
|
||||
params.displayName !== undefined &&
|
||||
(access.credential.type === 'oauth' || access.credential.type === 'service_account')
|
||||
) {
|
||||
updates.displayName = params.displayName
|
||||
}
|
||||
if (params.serviceAccountJson !== undefined && access.credential.type === 'service_account') {
|
||||
let parsedJson: Record<string, unknown>
|
||||
try {
|
||||
parsedJson = JSON.parse(params.serviceAccountJson)
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid JSON format', errorCode: 'validation' }
|
||||
}
|
||||
if (
|
||||
parsedJson.type !== 'service_account' ||
|
||||
typeof parsedJson.client_email !== 'string' ||
|
||||
typeof parsedJson.private_key !== 'string' ||
|
||||
typeof parsedJson.project_id !== 'string'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid service account JSON key',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
const { encrypted } = await encryptSecret(params.serviceAccountJson)
|
||||
updates.encryptedServiceAccountKey = encrypted
|
||||
}
|
||||
|
||||
// Reconnect: rotate a Slack/Atlassian service-account secret in place. The
|
||||
// secret is re-verified against the provider and re-encrypted; the display
|
||||
// name is preserved (the user may have renamed it).
|
||||
const hasRotationSecret =
|
||||
params.signingSecret !== undefined ||
|
||||
params.botToken !== undefined ||
|
||||
params.apiToken !== undefined ||
|
||||
params.domain !== undefined
|
||||
let rotatedSlackBotUserId: string | undefined
|
||||
if (hasRotationSecret && access.credential.type === 'service_account') {
|
||||
try {
|
||||
const secret = await verifyAndBuildServiceAccountSecret(
|
||||
access.credential.providerId ?? '',
|
||||
{
|
||||
signingSecret: params.signingSecret,
|
||||
botToken: params.botToken,
|
||||
apiToken: params.apiToken,
|
||||
domain: params.domain,
|
||||
}
|
||||
)
|
||||
updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey
|
||||
rotatedSlackBotUserId = secret.botUserId
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountSecretError) {
|
||||
return { success: false, error: error.message, errorCode: 'validation' }
|
||||
}
|
||||
if (error instanceof AtlassianValidationError) {
|
||||
// Surface the provider code so the client maps it to the specific
|
||||
// token/domain message (create returns it too).
|
||||
return {
|
||||
success: false,
|
||||
error: error.code,
|
||||
errorCode: 'validation',
|
||||
providerErrorCode: error.code,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
if (access.credential.type === 'oauth' || access.credential.type === 'service_account') {
|
||||
return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date()
|
||||
await db.update(credential).set(updates).where(eq(credential.id, params.credentialId))
|
||||
|
||||
// Reconnecting to a recreated Slack app changes the bot user id, but each
|
||||
// deployed webhook cached the old one at deploy for reaction self-drop.
|
||||
// Propagate the rotated id to the credential's live custom-bot webhooks so
|
||||
// the bot's own reactions keep being dropped (a stale id lets them re-enter).
|
||||
if (rotatedSlackBotUserId) {
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: sql`jsonb_set((${webhook.providerConfig})::jsonb, '{bot_user_id}', to_jsonb(${rotatedSlackBotUserId}::text))::json`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(webhook.provider, 'slack'), eq(webhook.routingKey, params.credentialId)))
|
||||
}
|
||||
|
||||
const updatedFields = Object.keys(updates).filter((key) => key !== 'updatedAt')
|
||||
recordAudit({
|
||||
workspaceId: access.credential.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.CREDENTIAL_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: params.credentialId,
|
||||
resourceName: access.credential.displayName,
|
||||
description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`,
|
||||
metadata: {
|
||||
credentialType: access.credential.type,
|
||||
updatedFields,
|
||||
},
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true, workspaceId: access.credential.workspaceId, updatedFields }
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('unique')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'A service account credential with this name already exists in the workspace',
|
||||
errorCode: 'conflict',
|
||||
}
|
||||
}
|
||||
logger.error('Failed to update credential', { error })
|
||||
return { success: false, error: 'Internal server error', errorCode: 'internal' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function performDeleteCredential(
|
||||
params: CredentialActorParams
|
||||
): Promise<PerformCredentialResult> {
|
||||
try {
|
||||
const access = await getCredentialActorContext(params.credentialId, params.userId)
|
||||
if (!access.credential) {
|
||||
return { success: false, error: 'Credential not found', errorCode: 'not_found' }
|
||||
}
|
||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Credential admin permission required',
|
||||
errorCode: 'forbidden',
|
||||
}
|
||||
}
|
||||
if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
if (access.credential.type === 'env_personal' && access.credential.envKey) {
|
||||
const ownerUserId = access.credential.envOwnerUserId
|
||||
if (!ownerUserId) {
|
||||
return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' }
|
||||
}
|
||||
|
||||
const [personalRow] = await db
|
||||
.select({ variables: environment.variables })
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, ownerUserId))
|
||||
.limit(1)
|
||||
|
||||
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
if (access.credential.envKey in current) delete current[access.credential.envKey]
|
||||
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: [environment.userId],
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncPersonalEnvCredentialsForUser({
|
||||
userId: ownerUserId,
|
||||
envKeys: Object.keys(current),
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: 'env_personal',
|
||||
provider_id: access.credential.envKey,
|
||||
workspace_id: access.credential.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: access.credential.workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: access.credential.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.CREDENTIAL_DELETED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: params.credentialId,
|
||||
resourceName: access.credential.displayName,
|
||||
description: `Deleted personal env credential "${access.credential.envKey}"`,
|
||||
metadata: { credentialType: 'env_personal', envKey: access.credential.envKey },
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true, workspaceId: access.credential.workspaceId }
|
||||
}
|
||||
|
||||
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
|
||||
const [workspaceRow] = await db
|
||||
.select({
|
||||
id: workspaceEnvironment.id,
|
||||
createdAt: workspaceEnvironment.createdAt,
|
||||
variables: workspaceEnvironment.variables,
|
||||
})
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
if (access.credential.envKey in current) delete current[access.credential.envKey]
|
||||
|
||||
await db
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: workspaceRow?.id || generateId(),
|
||||
workspaceId: access.credential.workspaceId,
|
||||
variables: current,
|
||||
createdAt: workspaceRow?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await deleteWorkspaceEnvCredentials({
|
||||
workspaceId: access.credential.workspaceId,
|
||||
removedKeys: [access.credential.envKey],
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: 'env_workspace',
|
||||
provider_id: access.credential.envKey,
|
||||
workspace_id: access.credential.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: access.credential.workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: access.credential.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.CREDENTIAL_DELETED,
|
||||
resourceType: AuditResourceType.CREDENTIAL,
|
||||
resourceId: params.credentialId,
|
||||
resourceName: access.credential.displayName,
|
||||
description: `Deleted workspace env credential "${access.credential.envKey}"`,
|
||||
metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey },
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
return { success: true, workspaceId: access.credential.workspaceId }
|
||||
}
|
||||
|
||||
await deleteCredential({
|
||||
credentialId: params.credentialId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName,
|
||||
actorEmail: params.actorEmail,
|
||||
reason: params.reason ?? 'user_delete',
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
params.userId,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: access.credential.type as 'oauth' | 'service_account',
|
||||
provider_id: access.credential.providerId ?? params.credentialId,
|
||||
workspace_id: access.credential.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: access.credential.workspaceId } }
|
||||
)
|
||||
|
||||
return { success: true, workspaceId: access.credential.workspaceId }
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete credential', { error })
|
||||
return { success: false, error: 'Internal server error', errorCode: 'internal' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockEncryptSecret, mockFetchSlackTeamId, mockValidateAtlassian, mockNormalizeDomain } =
|
||||
vi.hoisted(() => ({
|
||||
// Identity encryption so tests can read back the JSON blob.
|
||||
mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })),
|
||||
mockFetchSlackTeamId: vi.fn(),
|
||||
mockValidateAtlassian: vi.fn(),
|
||||
mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret }))
|
||||
vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId }))
|
||||
vi.mock('@/lib/credentials/atlassian-service-account', () => ({
|
||||
validateAtlassianServiceAccount: mockValidateAtlassian,
|
||||
normalizeAtlassianDomain: mockNormalizeDomain,
|
||||
}))
|
||||
vi.mock('@/lib/api/contracts/credentials', () => ({
|
||||
serviceAccountJsonSchema: {
|
||||
safeParse: (value: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return { success: true, data: parsed }
|
||||
} catch {
|
||||
return { success: false, error: { issues: [{ message: 'bad json' }] } }
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/api/server', () => ({
|
||||
getValidationErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
}))
|
||||
|
||||
import {
|
||||
ServiceAccountSecretError,
|
||||
verifyAndBuildServiceAccountSecret,
|
||||
} from '@/lib/credentials/service-account-secret'
|
||||
import {
|
||||
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
SLACK_CUSTOM_BOT_PROVIDER_ID,
|
||||
} from '@/lib/oauth/types'
|
||||
|
||||
describe('verifyAndBuildServiceAccountSecret', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value }))
|
||||
mockNormalizeDomain.mockImplementation((raw: string) => raw.trim().toLowerCase())
|
||||
})
|
||||
|
||||
it('verifies a Slack bot token and encrypts the derived blob', async () => {
|
||||
mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T1', userId: 'U_BOT', teamName: 'Acme' })
|
||||
const result = await verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, {
|
||||
signingSecret: 'sec',
|
||||
botToken: 'xoxb-1',
|
||||
})
|
||||
expect(result.providerId).toBe(SLACK_CUSTOM_BOT_PROVIDER_ID)
|
||||
expect(result.displayName).toBe('Acme')
|
||||
expect(result.auditMetadata.slackTeamId).toBe('T1')
|
||||
expect(result.botUserId).toBe('U_BOT')
|
||||
const blob = JSON.parse(result.encryptedServiceAccountKey)
|
||||
expect(blob).toMatchObject({
|
||||
signingSecret: 'sec',
|
||||
botToken: 'xoxb-1',
|
||||
teamId: 'T1',
|
||||
botUserId: 'U_BOT',
|
||||
teamName: 'Acme',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when Slack required fields are missing', async () => {
|
||||
await expect(
|
||||
verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, { signingSecret: 'sec' })
|
||||
).rejects.toBeInstanceOf(ServiceAccountSecretError)
|
||||
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wraps a failed Slack token verification as a ServiceAccountSecretError', async () => {
|
||||
mockFetchSlackTeamId.mockRejectedValue(new Error('invalid_auth'))
|
||||
await expect(
|
||||
verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, {
|
||||
signingSecret: 'sec',
|
||||
botToken: 'xoxb-bad',
|
||||
})
|
||||
).rejects.toThrow(/Could not verify the Slack bot token/)
|
||||
})
|
||||
|
||||
it('verifies an Atlassian token and encrypts the blob', async () => {
|
||||
mockValidateAtlassian.mockResolvedValue({
|
||||
accountId: 'acc-1',
|
||||
displayName: 'Jira Bot',
|
||||
cloudId: 'cloud-1',
|
||||
})
|
||||
const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, {
|
||||
apiToken: 'tok',
|
||||
domain: 'Acme.atlassian.net',
|
||||
})
|
||||
expect(result.providerId).toBe(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID)
|
||||
expect(result.displayName).toBe('Jira Bot')
|
||||
expect(result.auditMetadata.atlassianCloudId).toBe('cloud-1')
|
||||
const blob = JSON.parse(result.encryptedServiceAccountKey)
|
||||
expect(blob).toMatchObject({
|
||||
apiToken: 'tok',
|
||||
domain: 'acme.atlassian.net',
|
||||
cloudId: 'cloud-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when Atlassian required fields are missing', async () => {
|
||||
await expect(
|
||||
verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { apiToken: 'tok' })
|
||||
).rejects.toBeInstanceOf(ServiceAccountSecretError)
|
||||
})
|
||||
|
||||
it('validates and encrypts a Google service-account JSON key', async () => {
|
||||
const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' })
|
||||
const result = await verifyAndBuildServiceAccountSecret('google-service-account', {
|
||||
serviceAccountJson: json,
|
||||
})
|
||||
expect(result.providerId).toBe('google-service-account')
|
||||
expect(result.displayName).toBe('svc@proj.iam')
|
||||
expect(result.encryptedServiceAccountKey).toBe(json)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import {
|
||||
normalizeAtlassianDomain,
|
||||
validateAtlassianServiceAccount,
|
||||
} from '@/lib/credentials/atlassian-service-account'
|
||||
import {
|
||||
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
|
||||
SLACK_CUSTOM_BOT_PROVIDER_ID,
|
||||
SLACK_CUSTOM_BOT_SECRET_TYPE,
|
||||
} from '@/lib/oauth/types'
|
||||
import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack'
|
||||
|
||||
/** Provider-specific secret inputs a service-account credential can carry. */
|
||||
export interface ServiceAccountSecretFields {
|
||||
signingSecret?: string
|
||||
botToken?: string
|
||||
apiToken?: string
|
||||
domain?: string
|
||||
serviceAccountJson?: string
|
||||
}
|
||||
|
||||
export interface ServiceAccountSecretResult {
|
||||
/** Canonical provider id for the resolved secret. */
|
||||
providerId: string
|
||||
encryptedServiceAccountKey: string
|
||||
displayName: string
|
||||
auditMetadata: Record<string, string>
|
||||
/** Slack custom bot: the derived bot user id (for reaction self-drop). */
|
||||
botUserId?: string
|
||||
}
|
||||
|
||||
/** Thrown when a service-account secret is missing or fails provider verification. */
|
||||
export class ServiceAccountSecretError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ServiceAccountSecretError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a service-account secret against its provider, derives the display
|
||||
* name, and returns the encrypted blob ready to persist. Shared by credential
|
||||
* create (POST) and in-place reconnect (PUT) so both paths verify + encrypt
|
||||
* identically. Throws {@link ServiceAccountSecretError} on missing fields or a
|
||||
* failed provider verification (callers map it to a 400).
|
||||
*/
|
||||
export async function verifyAndBuildServiceAccountSecret(
|
||||
providerId: string,
|
||||
fields: ServiceAccountSecretFields
|
||||
): Promise<ServiceAccountSecretResult> {
|
||||
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
|
||||
const { apiToken, domain } = fields
|
||||
if (!apiToken || !domain) {
|
||||
throw new ServiceAccountSecretError(
|
||||
'apiToken and domain are required for Atlassian service account credentials'
|
||||
)
|
||||
}
|
||||
const normalizedDomain = normalizeAtlassianDomain(domain)
|
||||
const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
|
||||
const blob = JSON.stringify({
|
||||
type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
|
||||
apiToken,
|
||||
domain: normalizedDomain,
|
||||
cloudId: validation.cloudId,
|
||||
atlassianAccountId: validation.accountId,
|
||||
})
|
||||
const { encrypted } = await encryptSecret(blob)
|
||||
return {
|
||||
providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
encryptedServiceAccountKey: encrypted,
|
||||
displayName: validation.displayName,
|
||||
auditMetadata: {
|
||||
atlassianDomain: normalizedDomain,
|
||||
atlassianCloudId: validation.cloudId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
|
||||
const { signingSecret, botToken } = fields
|
||||
if (!signingSecret || !botToken) {
|
||||
throw new ServiceAccountSecretError(
|
||||
'signingSecret and botToken are required for a custom Slack bot credential'
|
||||
)
|
||||
}
|
||||
// Verify the token and derive the workspace/team identity (never trusted
|
||||
// from the client) via auth.test.
|
||||
let teamId: string
|
||||
let botUserId: string | undefined
|
||||
let teamName: string | undefined
|
||||
try {
|
||||
const auth = await fetchSlackTeamId(botToken)
|
||||
teamId = auth.teamId
|
||||
botUserId = auth.userId
|
||||
teamName = auth.teamName
|
||||
} catch (error) {
|
||||
throw new ServiceAccountSecretError(
|
||||
`Could not verify the Slack bot token: ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
const blob = JSON.stringify({
|
||||
type: SLACK_CUSTOM_BOT_SECRET_TYPE,
|
||||
signingSecret,
|
||||
botToken,
|
||||
teamId,
|
||||
botUserId,
|
||||
teamName,
|
||||
})
|
||||
const { encrypted } = await encryptSecret(blob)
|
||||
return {
|
||||
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
|
||||
encryptedServiceAccountKey: encrypted,
|
||||
displayName: teamName || 'Slack bot',
|
||||
auditMetadata: { slackTeamId: teamId },
|
||||
botUserId,
|
||||
}
|
||||
}
|
||||
|
||||
const { serviceAccountJson } = fields
|
||||
if (!serviceAccountJson) {
|
||||
throw new ServiceAccountSecretError(
|
||||
'serviceAccountJson is required for service account credentials'
|
||||
)
|
||||
}
|
||||
const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
|
||||
if (!jsonParseResult.success) {
|
||||
throw new ServiceAccountSecretError(
|
||||
getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON')
|
||||
)
|
||||
}
|
||||
const { encrypted } = await encryptSecret(serviceAccountJson)
|
||||
return {
|
||||
providerId: 'google-service-account',
|
||||
encryptedServiceAccountKey: encrypted,
|
||||
displayName: jsonParseResult.data.client_email,
|
||||
auditMetadata: {},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user