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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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('')
})
})
+118
View File
@@ -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)
})
})
+145
View File
@@ -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,
}
}
+122
View File
@@ -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
}
+308
View File
@@ -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 }
}
+193
View File
@@ -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)
})
})
+555
View File
@@ -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,
}))
}
+169
View File
@@ -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: {},
}
}