diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index e173e2ffee0..2e56d942198 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -37,8 +37,17 @@ vi.mock('@/lib/credential-groups/oauth-state', () => ({ })) vi.mock('@/lib/credential-groups/providers', () => ({ - CREDENTIAL_GROUP_PROVIDER_IDS: ['gmail', 'google-calendar', 'confluence', 'jira', 'slack'], + CREDENTIAL_GROUP_PROVIDER_IDS: [ + 'gmail', + 'google-calendar', + 'confluence', + 'jira', + 'slack', + 'fireflies', + ], CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['gmail', 'google-calendar', 'confluence', 'jira'], + CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS: ['fireflies'], + isCredentialGroupApiKeyProvider: (provider: string) => provider === 'fireflies', getCredentialGroupStandardOAuthProviderFromProviderId: (providerId: string) => { const providers: Record = { 'google-email': 'gmail', diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/api-key/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/api-key/[optionId]/route.ts new file mode 100644 index 00000000000..198397b1688 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/api-key/[optionId]/route.ts @@ -0,0 +1,78 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { submitCredentialGroupApiKeyContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CredentialGroupApiKeyVerificationError } from '@/lib/credential-groups/api-key-providers/types' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { submitPublicCredentialGroupApiKey } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { + enforceCredentialGroupEnrollmentOAuthRateLimit, + enforcePublicCredentialGroupIpRateLimit, +} from '@/lib/credential-groups/rate-limit' +import { ManagedApiKeyFormatError } from '@/lib/credentials/managed-api-key' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const UNAVAILABLE = 'This invitation is invalid, expired, or has been revoked.' + +/** + * Accepts one API key from an invited person. + * + * A JSON route rather than the redirect-based flow its OAuth sibling uses: the submitting + * form is a client component that renders the rejection inline against the field, so the + * answer has to come back in the response instead of a query parameter. + */ +export const POST = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ token: string; optionId: string }> } + ) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'api-key-submit') + if (limited) return limited + + const parsed = await parseRequest(submitCredentialGroupApiKeyContract, request, context) + if (!parsed.success) return parsed.response + const { token, optionId } = parsed.data.params + + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) return NextResponse.json({ error: UNAVAILABLE }, { status: 404 }) + + const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit( + principal.enrollmentId + ) + if (enrollmentLimited) return enrollmentLimited + + try { + const result = await submitPublicCredentialGroupApiKey.execute({ + principal, + input: { invitationToken: token, optionId, fields: parsed.data.body.fields }, + request, + }) + return NextResponse.json(result) + } catch (error) { + // The verifier's message names what the provider said and is written for the person + // holding the invitation, so it is surfaced verbatim rather than flattened to a 500. + if (error instanceof CredentialGroupApiKeyVerificationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error instanceof ManagedApiKeyFormatError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error instanceof CredentialGroupOAuthError) { + return NextResponse.json({ error: error.message }, { status: error.statusCode }) + } + const orchestration = asOrchestrationError(error) + if (orchestration?.code === 'not_found') { + return NextResponse.json({ error: UNAVAILABLE }, { status: 404 }) + } + if (orchestration?.code === 'validation') { + return NextResponse.json({ error: orchestration.message }, { status: 400 }) + } + throw error + } + } +) diff --git a/apps/sim/app/credential-groups/enroll/[token]/api-key-connect-modal.tsx b/apps/sim/app/credential-groups/enroll/[token]/api-key-connect-modal.tsx new file mode 100644 index 00000000000..3780718d20f --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/api-key-connect-modal.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { + type CredentialGroupApiKeyProvider, + getCredentialGroupApiKeyFields, + getCredentialGroupApiKeyLocation, + getCredentialGroupProviderPresentation, +} from '@/lib/credential-groups/providers' +import { useSubmitCredentialGroupApiKey } from '@/hooks/queries/credential-group-enrollment' + +interface ApiKeyConnectModalProps { + token: string + optionId: string + /** + * The provider id rather than its resolved presentation: this renders from a server + * component, and an icon is a function, which cannot cross that boundary. Everything the + * modal needs is derived here on the client from this one serializable value. + */ + provider: CredentialGroupApiKeyProvider + connected: boolean +} + +/** + * Collects the values one API-key option needs. + * + * The row itself offers a plain Connect action so an API-key account reads the same as an + * OAuth one; the difference — that this service hands you a key instead of a sign-in — belongs + * inside the modal, next to the link explaining where to find it. + */ +export function ApiKeyConnectModal({ + token, + optionId, + provider, + connected, +}: ApiKeyConnectModalProps) { + const { name: serviceName, icon: Icon } = getCredentialGroupProviderPresentation(provider) + const fields = getCredentialGroupApiKeyFields(provider) + const keyLocation = getCredentialGroupApiKeyLocation(provider) + const router = useRouter() + const submit = useSubmitCredentialGroupApiKey(token, optionId) + const [open, setOpen] = useState(false) + const [values, setValues] = useState>({}) + const [error, setError] = useState(null) + + const handleOpenChange = (next: boolean) => { + if (submit.isPending) return + setOpen(next) + if (!next) { + setValues({}) + setError(null) + } + } + + const handleSubmit = async () => { + const missing = fields.find((field) => !(values[field.id] ?? '').trim()) + if (missing) { + setError(`${missing.label} is required.`) + return + } + setError(null) + try { + await submit.mutateAsync({ + fields: Object.fromEntries(fields.map((field) => [field.id, values[field.id].trim()])), + }) + handleOpenChange(false) + router.refresh() + } catch (err) { + setError(getErrorMessage(err, 'Those credentials could not be verified. Please try again.')) + } + } + + return ( + <> + setOpen(true)}>{connected ? 'Reconnect' : 'Connect'} + + handleOpenChange(false)} + closeDisabled={submit.isPending} + > + Connect {serviceName} + + +

+ {keyLocation.steps} + {keyLocation.url && ( + <> + {' '} + + Open {serviceName} + + + )} +

+ {fields.map((field) => ( + + setValues((current) => ({ ...current, [field.id]: value })) + } + placeholder={field.placeholder} + autoComplete='off' + disabled={submit.isPending} + required + /> + ))} + {error} +
+ handleOpenChange(false)} + cancelDisabled={submit.isPending} + primaryAction={{ + label: submit.isPending ? 'Checking…' : 'Connect', + onClick: () => void handleSubmit(), + disabled: submit.isPending, + }} + /> +
+ + ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 3336b84ad1b..f27eff68a85 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -5,10 +5,14 @@ import { headers } from 'next/headers' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { + getCredentialGroupProviderPresentation, + isCredentialGroupApiKeyProvider, +} from '@/lib/credential-groups/providers' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { SupportFooter } from '@/app/(auth)/components' import { LogoShell } from '@/app/(landing)/components' +import { ApiKeyConnectModal } from '@/app/credential-groups/enroll/[token]/api-key-connect-modal' import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' import { @@ -116,7 +120,7 @@ export default async function CredentialGroupEnrollmentPage({ : undefined const notification = connectedOptionId ? { - message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + message: `${connectedOption ? getCredentialGroupProviderPresentation(connectedOption.provider).name : 'Account'} connected successfully.`, variant: 'success' as const, } : oauthMessage @@ -151,8 +155,31 @@ export default async function CredentialGroupEnrollmentPage({
{activeOptions.map((option) => { - const ProviderIcon = getCredentialGroupProviderService(option.provider).icon + const ProviderIcon = getCredentialGroupProviderPresentation(option.provider).icon const connection = option.connections[0] + if (isCredentialGroupApiKeyProvider(option.provider)) { + const provider = option.provider + return ( + } + title={option.label} + description={ + connection + ? `Connected${connection.email ? ` as ${connection.email}` : ''}` + : 'Not connected' + } + trailing={ + + } + /> + ) + } return ( + credentialId: string credentials: Array<{ credentialId: string email: string @@ -92,7 +94,12 @@ interface CredentialGroupBlockOutput { } const INVITE_OPERATIONS = ['send_invite', 'get_invite_link'] as const -const GROUP_OPERATIONS = ['list_credentials', ...INVITE_OPERATIONS, 'list_people'] as const +const GROUP_OPERATIONS = [ + 'list_credentials', + 'get_api_key', + ...INVITE_OPERATIONS, + 'list_people', +] as const const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const export const CredentialGroupBlock: BlockConfig = { @@ -109,6 +116,7 @@ export const CredentialGroupBlock: BlockConfig = { - "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded. - Use "List People" to inspect invitation and connection progress without exposing credential secrets. - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. + - "Get API Key" returns one enrolled person's credential values under "fields" — reference them as , or and for Gong. Pass the credentialId from "List Credentials". - "Get Invite Link" issues a fresh seven-day bearer link without sending email. It invalidates the previous link for that email, so treat the output as a secret. `, docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group', @@ -153,6 +161,14 @@ export const CredentialGroupBlock: BlockConfig = { { text: ', matching', field: 'email' }, { text: ', with status', field: 'peopleStatuses' }, ], + get_api_key: [ + { text: 'Get API key for', field: 'credentialId', core: true }, + { + text: 'in', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + ], list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }], }, }, @@ -165,6 +181,7 @@ export const CredentialGroupBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'List Credentials', id: 'list_credentials' }, + { label: 'Get API Key', id: 'get_api_key' }, { label: 'Send Invite', id: 'send_invite' }, { label: 'Get Invite Link', id: 'get_invite_link' }, { label: 'List People', id: 'list_people' }, @@ -200,6 +217,14 @@ export const CredentialGroupBlock: BlockConfig = { placeholder: 'person@example.com', condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, }, + { + id: 'credentialId', + title: 'Credential ID', + type: 'short-input', + required: { field: 'operation', value: 'get_api_key' }, + placeholder: 'credentialId from List Credentials', + condition: { field: 'operation', value: 'get_api_key' }, + }, { id: 'providerFilter', title: 'Provider', @@ -262,9 +287,13 @@ export const CredentialGroupBlock: BlockConfig = { operation: { type: 'string', description: - "'list_credentials', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'", + "'list_credentials', 'get_api_key', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'", }, credentialGroupId: { type: 'string', description: 'Credential Group ID' }, + credentialId: { + type: 'string', + description: 'Credential ID to read an API key for, from List Credentials', + }, email: { type: 'string', description: 'Recipient email for invites or an optional credential/people-list filter', @@ -287,6 +316,27 @@ export const CredentialGroupBlock: BlockConfig = { 'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)', condition: { field: 'operation', value: 'list_credentials' }, }, + fields: { + type: 'json', + description: + 'Credential values the invited person provided, keyed by field id — `apiKey` for most services, `accessKey` and `accessKeySecret` for Gong. Secret values are redacted from logs and model-visible content.', + condition: { field: 'operation', value: 'get_api_key' }, + }, + credentialId: { + type: 'string', + description: 'Credential the API key belongs to', + condition: { field: 'operation', value: 'get_api_key' }, + }, + displayName: { + type: 'string', + description: 'Account name reported by the provider', + condition: { field: 'operation', value: 'get_api_key' }, + }, + providerId: { + type: 'string', + description: 'Provider the credential belongs to', + condition: { field: 'operation', value: 'get_api_key' }, + }, credentialGroups: { type: 'json', description: diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index a6a76bf9ab4..601e3ebd75a 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -11,7 +11,7 @@ import type { CredentialGroupEnrollmentConnection, } from '@/lib/api/contracts/credential-groups' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { getCredentialGroupProviderPresentation } from '@/lib/credential-groups/providers' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { @@ -69,7 +69,7 @@ interface CredentialProviderIconProps { } function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { - const ProviderIcon = getCredentialGroupProviderService(provider).icon + const ProviderIcon = getCredentialGroupProviderPresentation(provider).icon return } @@ -270,16 +270,6 @@ export function CredentialGroupDetail({ title={credentialGroup?.name ?? 'Credential group'} description={credentialGroup?.description ?? undefined} actions={actions} - search={ - activeTab === 'details' - ? { - value: providerSearch, - onChange: setProviderSearch, - placeholder: 'Search account types...', - disabled: detail.isPending, - } - : undefined - } > {detail.error ? ( @@ -299,6 +289,7 @@ export function CredentialGroupDetail({ workspaceId={workspaceId} credentialGroup={credentialGroup} providerSearch={providerSearch} + onProviderSearchChange={setProviderSearch} name={name} onNameChange={setDraftName} description={description} diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index ecff6823dd9..30e995a1387 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' +import { Search } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import type { WorkspaceCredential } from '@/lib/api/contracts' import type { @@ -11,10 +12,12 @@ import type { } from '@/lib/api/contracts/credential-groups' import { CREDENTIAL_GROUP_PROVIDER_IDS, + type CredentialGroupApiKeyProvider, type CredentialGroupProvider, type CredentialGroupStandardOAuthProvider, - getCredentialGroupProviderService, + getCredentialGroupProviderPresentation, getCredentialGroupProviderSupport, + isCredentialGroupApiKeyProvider, isCredentialGroupStandardOAuthProvider, } from '@/lib/credential-groups/providers' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' @@ -38,6 +41,7 @@ interface CredentialGroupDetailsProps { workspaceId: string /** Filters the account types offered below; owned by the panel header's search field. */ providerSearch: string + onProviderSearchChange: (value: string) => void /** Edited name; committed by the panel header's Save action, which owns the dirty state. */ name: string onNameChange: (name: string) => void @@ -50,7 +54,7 @@ function toOptionUpdateInput( ): NonNullable[number] { const common = { id: option.id, - label: getCredentialGroupProviderService(option.provider).name, + label: getCredentialGroupProviderPresentation(option.provider).name, required: false, } if (option.provider !== 'slack') return { ...common, provider: option.provider } @@ -65,6 +69,7 @@ export function CredentialGroupDetails({ credentialGroup, workspaceId, providerSearch, + onProviderSearchChange, name, onNameChange, description, @@ -105,8 +110,10 @@ export function CredentialGroupDetails({ } } - const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { - const service = getCredentialGroupProviderService(provider) + const addProvider = async ( + provider: CredentialGroupStandardOAuthProvider | CredentialGroupApiKeyProvider + ) => { + const service = getCredentialGroupProviderPresentation(provider) const existing = credentialGroup.options.map(toOptionUpdateInput) const nextOption: NonNullable[number] = { provider, @@ -122,7 +129,10 @@ export function CredentialGroupDetails({ const handleProviderAction = (provider: CredentialGroupProvider) => { const support = getCredentialGroupProviderSupport(provider) - if (isCredentialGroupStandardOAuthProvider(provider)) { + if ( + isCredentialGroupStandardOAuthProvider(provider) || + isCredentialGroupApiKeyProvider(provider) + ) { void addProvider(provider) return } @@ -135,7 +145,7 @@ export function CredentialGroupDetails({ const handleRemoveProvider = async () => { if (!removingProvider) return - const service = getCredentialGroupProviderService(removingProvider) + const service = getCredentialGroupProviderPresentation(removingProvider) const options = credentialGroup.options .filter((option) => option.provider !== removingProvider) .map(toOptionUpdateInput) @@ -159,7 +169,9 @@ export function CredentialGroupDetails({ return false } if (!providerQuery) return true - return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) + return getCredentialGroupProviderPresentation(provider) + .name.toLowerCase() + .includes(providerQuery) }) return ( @@ -191,6 +203,14 @@ export function CredentialGroupDetails({ + onProviderSearchChange(event.target.value)} + /> {shownProviders.length === 0 ? ( {providerSearch.trim() @@ -200,7 +220,7 @@ export function CredentialGroupDetails({ ) : null}
{shownProviders.map((provider) => { - const service = getCredentialGroupProviderService(provider) + const service = getCredentialGroupProviderPresentation(provider) const support = getCredentialGroupProviderSupport(provider) const option = credentialGroup.options.find( (candidate) => candidate.provider === provider @@ -273,7 +293,10 @@ export function CredentialGroupDetails({ onClick={() => handleProviderAction(provider)} disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} > - {support.configuration === 'oauth' ? 'Add' : 'Set up'} + {/* Only Slack configures anything here — it collects a custom bot's + credentials before anyone can enroll. Every other kind, OAuth and + API key alike, is just added to the group. */} + {support.configuration === 'slack_custom_bot' ? 'Set up' : 'Add'} ) } @@ -301,7 +324,9 @@ export function CredentialGroupDetails({ onOpenChange={(open) => !open && !isUpdating && setRemovingProvider(null)} srTitle='Remove account type' title={`Remove ${ - removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account' + removingProvider + ? getCredentialGroupProviderPresentation(removingProvider).name + : 'account' }`} defaultAction='confirm' text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.' diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts index f1bdeaa44d5..ef70bbab7bf 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -15,6 +15,11 @@ const mocks = vi.hoisted(() => ({ listGroups: vi.fn(), listPeople: vi.fn(), sendInvite: vi.fn(), + resolveApiKey: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/resolve-managed-api-key', () => ({ + resolveManagedApiKeyCredential: { execute: mocks.resolveApiKey }, })) vi.mock('@/lib/credential-groups/application/create-invite-link', () => ({ @@ -311,3 +316,101 @@ describe('CredentialGroupBlockHandler', () => { expect(mocks.createPrincipal).not.toHaveBeenCalled() }) }) + +describe('get_api_key', () => { + const resolved = { + fields: { accessKey: 'gong-access-key', accessKeySecret: 'gong-access-secret' }, + provenanceEntries: [ + { name: 'accessKey', encryptedValue: 'enc(gong-access-key)' }, + { name: 'accessKeySecret', encryptedValue: 'enc(gong-access-secret)' }, + ], + credentialId: 'credential-1', + providerId: 'fireflies', + displayName: 'Ada', + email: 'ada@example.com', + } + + function contextWithRegistry(registry: unknown) { + return { ...context, resolvedSecretTraceRegistry: registry } as ExecutionContext + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.createPrincipal.mockResolvedValue(principal) + mocks.resolveApiKey.mockResolvedValue(resolved) + }) + + it('registers one catalog entry per secret field before returning them', async () => { + const importProvenance = vi.fn().mockResolvedValue(true) + const registry = { + importProvenance, + exportProvenance: () => ({ scope: { userId: 'user-1', workspaceId: 'workspace-1' } }), + } + + const result = await new CredentialGroupBlockHandler().execute( + contextWithRegistry(registry), + block, + { operation: 'get_api_key', credentialGroupId: 'group-1', credentialId: 'credential-1' } + ) + + expect(importProvenance).toHaveBeenCalledWith( + expect.objectContaining({ + version: 1, + complete: true, + entries: [ + { encryptedValue: 'enc(gong-access-key)', name: 'credential-1:accessKey' }, + { encryptedValue: 'enc(gong-access-secret)', name: 'credential-1:accessKeySecret' }, + ], + }), + expect.objectContaining({ trusted: true }) + ) + expect(result).toMatchObject({ + fields: { accessKey: 'gong-access-key', accessKeySecret: 'gong-access-secret' }, + credentialId: 'credential-1', + }) + }) + + /** + * The key is only safe to hand a workflow because the run can redact it. A run with no + * registry cannot, so the block must fail rather than emit an unredactable secret. + */ + it('fails instead of returning a key when the run has no trace registry', async () => { + await expect( + new CredentialGroupBlockHandler().execute(context, block, { + operation: 'get_api_key', + credentialGroupId: 'group-1', + credentialId: 'credential-1', + }) + ).rejects.toThrow(/resolved-secret provenance/) + }) + + it('fails when the registry refuses the entry', async () => { + const registry = { + importProvenance: vi.fn().mockResolvedValue(false), + exportProvenance: () => ({ scope: undefined }), + } + + await expect( + new CredentialGroupBlockHandler().execute(contextWithRegistry(registry), block, { + operation: 'get_api_key', + credentialGroupId: 'group-1', + credentialId: 'credential-1', + }) + ).rejects.toThrow(/registered for redaction/) + }) + + it('requires a credential id', async () => { + const registry = { + importProvenance: vi.fn().mockResolvedValue(true), + exportProvenance: () => ({ scope: undefined }), + } + + await expect( + new CredentialGroupBlockHandler().execute(contextWithRegistry(registry), block, { + operation: 'get_api_key', + credentialGroupId: 'group-1', + }) + ).rejects.toThrow() + expect(mocks.resolveApiKey).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts index 0bc0c24169a..2c2e8971913 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -11,6 +11,7 @@ import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/s import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials' import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments' import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit' +import { resolveManagedApiKeyCredential } from '@/lib/credentials/application/resolve-managed-api-key' import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/constants' @@ -21,6 +22,7 @@ const logger = createLogger('CredentialGroupBlockHandler') const CREDENTIAL_GROUP_OPERATION_IDS = [ 'list_credentials', + 'get_api_key', 'send_invite', 'get_invite_link', 'list_people', @@ -175,6 +177,58 @@ export class CredentialGroupBlockHandler implements BlockHandler { invitationLink: result.invitationLink, } } + case 'get_api_key': { + const credentialId = requireString(inputs.credentialId, 'Credential') + const resolved = await resolveManagedApiKeyCredential.execute({ + principal, + input: { credentialId, credentialGroupId: credentialGroupId! }, + }) + + /** + * Register the key with the run's redaction catalog before returning it. + * + * This is the whole reason the value may be handed to a workflow at all: the trace + * registry substitutes known secret literals out of logs, model-visible content, and + * stored snapshots. A run without a registry cannot do that, so the block fails + * rather than emitting a secret nothing can redact. + */ + if (!ctx.resolvedSecretTraceRegistry) { + throw new Error( + 'Credential Group API keys cannot be read without resolved-secret provenance for this run' + ) + } + const scope = ctx.resolvedSecretTraceRegistry.exportProvenance().scope + const imported = await ctx.resolvedSecretTraceRegistry.importProvenance( + { + version: 1, + complete: true, + // One entry per secret field: the matcher substitutes exact literals, so a + // credential carrying two secrets needs both catalogued or one goes out in clear. + entries: resolved.provenanceEntries.map((entry) => ({ + encryptedValue: entry.encryptedValue, + name: `${credentialId}:${entry.name}`, + })), + ...(scope ? { scope } : {}), + }, + { trusted: true, origin: 'credentialGroup.getApiKey' } + ) + if (!imported) { + throw new Error('Credential Group API key could not be registered for redaction') + } + + logger.info('Resolved Credential Group API key', { + credentialGroupId, + credentialId, + providerId: resolved.providerId, + }) + return { + fields: resolved.fields, + credentialId: resolved.credentialId, + providerId: resolved.providerId, + displayName: resolved.displayName, + email: resolved.email, + } + } case 'list_people': { const statuses = parseStringList(inputs.peopleStatuses, 'People statuses') const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) diff --git a/apps/sim/hooks/queries/credential-group-enrollment.ts b/apps/sim/hooks/queries/credential-group-enrollment.ts new file mode 100644 index 00000000000..124764d837e --- /dev/null +++ b/apps/sim/hooks/queries/credential-group-enrollment.ts @@ -0,0 +1,21 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { submitCredentialGroupApiKeyContract } from '@/lib/api/contracts/credential-groups' + +/** + * Submits one API key from the public enrollment page. + * + * No query keys or invalidation: the page is a server component holding no client cache, so + * the caller re-renders it with `router.refresh()` after a successful submit. + */ +export function useSubmitCredentialGroupApiKey(token: string, optionId: string) { + return useMutation<{ connectedOptionId: string }, Error, { fields: Record }>({ + mutationFn: ({ fields }) => + requestJson(submitCredentialGroupApiKeyContract, { + params: { token, optionId }, + body: { fields }, + }), + }) +} diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts index 5f7edf584e3..56a09b70e99 100644 --- a/apps/sim/lib/api/contracts/credential-groups.test.ts +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -8,6 +8,7 @@ import { credentialGroupSchema, inviteCredentialGroupEnrollmentsBodySchema, sharedCredentialGroupOAuthCallbackContract, + submitCredentialGroupApiKeyContract, updateCredentialGroupAccessBodySchema, updateCredentialGroupBodySchema, } from '@/lib/api/contracts/credential-groups' @@ -15,6 +16,10 @@ import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT, CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, } from '@/lib/credential-groups/workflow-access-limits' +import { + MAX_MANAGED_API_KEY_LENGTH, + MIN_MANAGED_API_KEY_LENGTH, +} from '@/lib/credentials/managed-api-key' describe('credential group contracts', () => { it('describes the shared managed OAuth callback as a redirect', () => { @@ -289,3 +294,74 @@ describe('credential group contracts', () => { ).toBe(false) }) }) + +describe('API-key credential group options', () => { + it('accepts an API-key option at create time, unlike Slack', () => { + expect( + createCredentialGroupBodySchema.safeParse({ + name: 'Recorders', + options: [{ provider: 'fireflies', label: 'Fireflies', required: false }], + }).success + ).toBe(true) + }) + + it('rejects OAuth configuration smuggled onto an API-key option', () => { + expect( + createCredentialGroupBodySchema.safeParse({ + name: 'Recorders', + options: [ + { + provider: 'fireflies', + label: 'Fireflies', + required: false, + slackBotCredentialId: '00000000-0000-4000-8000-000000000000', + }, + ], + }).success + ).toBe(false) + }) + + it('still rejects two options for the same API-key provider', () => { + expect( + createCredentialGroupBodySchema.safeParse({ + name: 'Recorders', + options: [ + { provider: 'fireflies', label: 'Mine', required: false }, + { provider: 'fireflies', label: 'Theirs', required: false }, + ], + }).success + ).toBe(false) + }) + + it('rejects an unregistered provider', () => { + expect( + createCredentialGroupBodySchema.safeParse({ + name: 'Recorders', + options: [{ provider: 'zoho-desk', label: 'Zoho Desk', required: false }], + }).success + ).toBe(false) + }) + + it('accepts a multi-field credential and bounds the envelope', () => { + const body = submitCredentialGroupApiKeyContract.body + expect( + body?.safeParse({ fields: { accessKey: 'key-value', accessKeySecret: 'secret-value' } }) + .success + ).toBe(true) + expect(body?.safeParse({ fields: {} }).success).toBe(false) + expect( + body?.safeParse({ fields: { apiKey: 'a'.repeat(MAX_MANAGED_API_KEY_LENGTH + 1) } }).success + ).toBe(false) + }) + + /** + * Per-field length rules live in the provider registry, not the wire: a non-secret field has + * no floor, so the boundary must not impose one it cannot know about. + */ + it('leaves the per-secret length floor to the application layer', () => { + const body = submitCredentialGroupApiKeyContract.body + expect( + body?.safeParse({ fields: { apiKey: 'a'.repeat(MIN_MANAGED_API_KEY_LENGTH - 1) } }).success + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 94fba1f1e92..5026ed760ef 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { + CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS, CREDENTIAL_GROUP_PROVIDER_IDS, CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, } from '@/lib/credential-groups/providers' @@ -10,6 +11,7 @@ import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH, } from '@/lib/credential-groups/workflow-access-limits' +import { MAX_MANAGED_API_KEY_LENGTH } from '@/lib/credentials/managed-api-key' export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) @@ -46,9 +48,22 @@ const slackCredentialGroupOptionInputSchema = z }) .strict() +/** + * An API-key option carries no authorization configuration of its own: there is no OAuth app + * to register, no scopes to request, and nothing for an admin to set up before inviting + * people. It is the base option fields and a provider, nothing more. + */ +const apiKeyCredentialGroupOptionInputSchema = z + .object({ + provider: z.enum(CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS), + ...credentialGroupOptionFields, + }) + .strict() + export const credentialGroupOptionInputSchema = z.discriminatedUnion('provider', [ standardOAuthCredentialGroupOptionInputSchema, slackCredentialGroupOptionInputSchema, + apiKeyCredentialGroupOptionInputSchema, ]) export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [ @@ -62,6 +77,11 @@ export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [ status: z.enum(['active', 'disabled']), configurationStatus: credentialGroupOptionConfigurationStatusSchema, }), + apiKeyCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), ]) export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('provider', [ @@ -69,6 +89,7 @@ export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('prov id: z.string().min(1).max(128).optional(), }), slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), + apiKeyCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), ]) export const credentialGroupSchema = z.object({ @@ -508,6 +529,30 @@ export const completeCredentialGroupEnrollmentContract = defineRouteContract({ response: { mode: 'empty' }, }) +export const submitCredentialGroupApiKeyContract = defineRouteContract({ + method: 'POST', + path: '/api/credential-groups/enroll/[token]/api-key/[optionId]', + params: startCredentialGroupOAuthParamsSchema, + /** + * Field ids and per-field length rules belong to the provider registry, not the wire: a + * non-secret field (a subdomain) has no length floor, and the set of fields differs by + * provider. The boundary bounds the envelope; the application layer validates it against the + * provider's declared fields. + */ + body: z + .object({ + fields: z + .record( + z.string().min(1).max(64), + z.string().min(1).max(MAX_MANAGED_API_KEY_LENGTH, 'Value is too long') + ) + .refine((value) => Object.keys(value).length > 0, 'At least one field is required') + .refine((value) => Object.keys(value).length <= 8, 'Too many fields'), + }) + .strict(), + response: { mode: 'json', schema: z.object({ connectedOptionId: z.string() }) }, +}) + export const credentialGroupOAuthCallbackContract = defineRouteContract({ method: 'GET', path: '/api/credential-groups/oauth/[provider]/callback', diff --git a/apps/sim/lib/credential-groups/api-key-providers/aws.ts b/apps/sim/lib/credential-groups/api-key-providers/aws.ts new file mode 100644 index 00000000000..55137808991 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/aws.ts @@ -0,0 +1,123 @@ +import { createHash, createHmac } from 'node:crypto' +import { + type CredentialGroupApiKeyVerification, + CredentialGroupApiKeyVerificationError, + type CredentialGroupApiKeyVerifier, + unprovenApiKeySubjectId, +} from '@/lib/credential-groups/api-key-providers/types' + +/** + * `sts:GetCallerIdentity` requires no IAM permission at all, so it validates a key pair + * however narrowly its policy is scoped — any other call would reject a perfectly good + * credential that simply lacks that one permission. + * + * STS is global and always reachable at this endpoint, so the region the person supplied is + * not used for signing here. It is stored because the tools that consume the credential need + * it, not because verification does. + */ +const STS_HOST = 'sts.amazonaws.com' +const STS_REGION = 'us-east-1' +const STS_SERVICE = 'sts' +const STS_BODY = 'Action=GetCallerIdentity&Version=2011-06-15' + +const AWS_REGION_PATTERN = /^[a-z0-9-]{1,32}$/ + +function hmac(key: Buffer | string, value: string): Buffer { + return createHmac('sha256', key).update(value, 'utf8').digest() +} + +/** + * The SigV4 four-step key derivation. Exported because it is the one part of this file with a + * published AWS test vector, and pinning it is what keeps a reordered HMAC chain from failing + * silently — a wrong signature is still 64 hex characters, so only a known-good value catches it. + */ +export function deriveSigningKey( + secretAccessKey: string, + dateStamp: string, + region: string, + service: string +): Buffer { + return hmac( + hmac(hmac(hmac(`AWS4${secretAccessKey}`, dateStamp), region), service), + 'aws4_request' + ) +} + +/** + * Minimal SigV4 for one fixed request. + * + * Written out rather than shared with `tools/s3`, which hand-rolls the same algorithm: `lib/` + * cannot import the tool registry (the realtime prune graph forbids it), and this covers only + * the simplest possible case — one POST, no query string, three headers. + */ +export function signStsRequest( + accessKeyId: string, + secretAccessKey: string, + amzDate: string +): string { + const dateStamp = amzDate.slice(0, 8) + const payloadHash = createHash('sha256').update(STS_BODY, 'utf8').digest('hex') + const canonicalHeaders = + `content-type:application/x-www-form-urlencoded; charset=utf-8\n` + + `host:${STS_HOST}\n` + + `x-amz-date:${amzDate}\n` + const signedHeaders = 'content-type;host;x-amz-date' + const canonicalRequest = `POST\n/\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}` + const credentialScope = `${dateStamp}/${STS_REGION}/${STS_SERVICE}/aws4_request` + const stringToSign = + `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n` + + createHash('sha256').update(canonicalRequest, 'utf8').digest('hex') + + const signingKey = deriveSigningKey(secretAccessKey, dateStamp, STS_REGION, STS_SERVICE) + const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex') + return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` +} + +export const awsApiKeyVerifier: CredentialGroupApiKeyVerifier = { + provider: 'aws', + async verify(fields: Record): Promise { + if (!AWS_REGION_PATTERN.test(fields.region)) { + throw new CredentialGroupApiKeyVerificationError('Region must look like us-east-1.') + } + + const amzDate = `${new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')}` + let response: Response + try { + response = await fetch(`https://${STS_HOST}/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'X-Amz-Date': amzDate, + Authorization: signStsRequest(fields.accessKeyId, fields.secretAccessKey, amzDate), + }, + body: STS_BODY, + }) + } catch { + throw new CredentialGroupApiKeyVerificationError( + 'Could not reach AWS to check these credentials. Please try again.' + ) + } + + if (response.status === 401 || response.status === 403) { + throw new CredentialGroupApiKeyVerificationError( + 'AWS rejected this access key ID and secret.' + ) + } + if (!response.ok) { + throw new CredentialGroupApiKeyVerificationError( + 'AWS could not verify these credentials right now. Please try again.' + ) + } + + /** + * GetCallerIdentity does return a real caller ARN, but it carries no address to match + * against the invitation, so the binding is recorded as unproven and keyed to the + * credential itself like every other provider that cannot name its owner. + */ + return { + identity: 'unproven', + subjectId: unprovenApiKeySubjectId(fields), + displayName: 'AWS account', + } + }, +} diff --git a/apps/sim/lib/credential-groups/api-key-providers/fireflies.ts b/apps/sim/lib/credential-groups/api-key-providers/fireflies.ts new file mode 100644 index 00000000000..ef09bce0575 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/fireflies.ts @@ -0,0 +1,64 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { + type CredentialGroupApiKeyVerification, + CredentialGroupApiKeyVerificationError, + type CredentialGroupApiKeyVerifier, +} from '@/lib/credential-groups/api-key-providers/types' + +const FIREFLIES_GRAPHQL_URL = 'https://api.fireflies.ai/graphql' + +/** Resolves the key's own owner: `user(id:)` defaults to the caller when no id is given. */ +const VIEWER_QUERY = `query User { user { user_id name email } }` + +interface FirefliesViewerResponse { + data?: { user?: { user_id?: string; name?: string; email?: string } | null } + errors?: Array<{ message?: string }> +} + +export const firefliesApiKeyVerifier: CredentialGroupApiKeyVerifier = { + provider: 'fireflies', + async verify(fields: Record): Promise { + const apiKey = fields.apiKey + let response: Response + try { + response = await fetch(FIREFLIES_GRAPHQL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ query: VIEWER_QUERY }), + }) + } catch { + throw new CredentialGroupApiKeyVerificationError( + 'Could not reach Fireflies to check this key. Please try again.' + ) + } + + if (response.status === 401 || response.status === 403) { + throw new CredentialGroupApiKeyVerificationError('Fireflies rejected this API key.') + } + if (!response.ok) { + throw new CredentialGroupApiKeyVerificationError( + 'Fireflies could not verify this key right now. Please try again.' + ) + } + + const payload = (await response.json().catch(() => null)) as FirefliesViewerResponse | null + if (payload?.errors?.length) { + throw new CredentialGroupApiKeyVerificationError('Fireflies rejected this API key.') + } + + const user = payload?.data?.user + const email = user?.email ? normalizeEmail(user.email) : undefined + if (!user?.user_id || !email || !isValidEmailSyntax(email)) { + throw new CredentialGroupApiKeyVerificationError( + 'Fireflies did not identify the owner of this key.' + ) + } + + return { + identity: 'verified', + subjectId: user.user_id, + displayName: user.name?.trim() || email, + email, + } + }, +} diff --git a/apps/sim/lib/credential-groups/api-key-providers/grain.ts b/apps/sim/lib/credential-groups/api-key-providers/grain.ts new file mode 100644 index 00000000000..9f0e7fb7427 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/grain.ts @@ -0,0 +1,45 @@ +import { + type CredentialGroupApiKeyVerification, + CredentialGroupApiKeyVerificationError, + type CredentialGroupApiKeyVerifier, + unprovenApiKeySubjectId, +} from '@/lib/credential-groups/api-key-providers/types' + +/** + * Cheapest authenticated read in Grain's public API, used purely as a liveness probe. + * Grain exposes no endpoint naming the key's owner, so identity stays `unproven`. + */ +const GRAIN_TEAMS_URL = 'https://api.grain.com/_/public-api/v2/teams' + +export const grainApiKeyVerifier: CredentialGroupApiKeyVerifier = { + provider: 'grain', + async verify(fields: Record): Promise { + const apiKey = fields.apiKey + let response: Response + try { + response = await fetch(GRAIN_TEAMS_URL, { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}` }, + }) + } catch { + throw new CredentialGroupApiKeyVerificationError( + 'Could not reach Grain to check this key. Please try again.' + ) + } + + if (response.status === 401 || response.status === 403) { + throw new CredentialGroupApiKeyVerificationError('Grain rejected this API key.') + } + if (!response.ok) { + throw new CredentialGroupApiKeyVerificationError( + 'Grain could not verify this key right now. Please try again.' + ) + } + + return { + identity: 'unproven', + subjectId: unprovenApiKeySubjectId(fields), + displayName: 'Grain account', + } + }, +} diff --git a/apps/sim/lib/credential-groups/api-key-providers/granola.ts b/apps/sim/lib/credential-groups/api-key-providers/granola.ts new file mode 100644 index 00000000000..a27bd3ebd58 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/granola.ts @@ -0,0 +1,48 @@ +import { + type CredentialGroupApiKeyVerification, + CredentialGroupApiKeyVerificationError, + type CredentialGroupApiKeyVerifier, + unprovenApiKeySubjectId, +} from '@/lib/credential-groups/api-key-providers/types' + +/** + * Smallest authenticated read in Granola's public API, used purely as a liveness probe. + * Granola exposes no endpoint naming the key's owner, so identity stays `unproven`. + * + * The base URL is repeated rather than imported from `@/tools/granola/utils`: nothing under + * `lib/` may depend on the tool registry, which the realtime prune graph enforces. + */ +const GRANOLA_PROBE_URL = 'https://public-api.granola.ai/v1/folders?page_size=1' + +export const granolaApiKeyVerifier: CredentialGroupApiKeyVerifier = { + provider: 'granola', + async verify(fields: Record): Promise { + const apiKey = fields.apiKey + let response: Response + try { + response = await fetch(GRANOLA_PROBE_URL, { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + }) + } catch { + throw new CredentialGroupApiKeyVerificationError( + 'Could not reach Granola to check this key. Please try again.' + ) + } + + if (response.status === 401 || response.status === 403) { + throw new CredentialGroupApiKeyVerificationError('Granola rejected this API key.') + } + if (!response.ok) { + throw new CredentialGroupApiKeyVerificationError( + 'Granola could not verify this key right now. Please try again.' + ) + } + + return { + identity: 'unproven', + subjectId: unprovenApiKeySubjectId(fields), + displayName: 'Granola account', + } + }, +} diff --git a/apps/sim/lib/credential-groups/api-key-providers/registry.ts b/apps/sim/lib/credential-groups/api-key-providers/registry.ts new file mode 100644 index 00000000000..1fe9837e537 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/registry.ts @@ -0,0 +1,26 @@ +import { awsApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/aws' +import { firefliesApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/fireflies' +import { grainApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/grain' +import { granolaApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/granola' +import type { CredentialGroupApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/types' +import type { CredentialGroupApiKeyProvider } from '@/lib/credential-groups/providers' + +/** + * Keyed by the full API-key provider union, so adding a provider id without a verifier is a + * compile error rather than a runtime one at enrollment time. + */ +const CREDENTIAL_GROUP_API_KEY_VERIFIERS: Record< + CredentialGroupApiKeyProvider, + CredentialGroupApiKeyVerifier +> = { + aws: awsApiKeyVerifier, + fireflies: firefliesApiKeyVerifier, + grain: grainApiKeyVerifier, + granola: granolaApiKeyVerifier, +} + +export function getCredentialGroupApiKeyVerifier( + provider: CredentialGroupApiKeyProvider +): CredentialGroupApiKeyVerifier { + return CREDENTIAL_GROUP_API_KEY_VERIFIERS[provider] +} diff --git a/apps/sim/lib/credential-groups/api-key-providers/types.ts b/apps/sim/lib/credential-groups/api-key-providers/types.ts new file mode 100644 index 00000000000..f7d8ee82943 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/types.ts @@ -0,0 +1,57 @@ +import { createHash } from 'node:crypto' +import type { CredentialGroupApiKeyProvider } from '@/lib/credential-groups/providers' + +/** + * What a service could establish about a key its owner pasted. + * + * The OAuth enrollment path always proves identity: the consenting account's address is + * matched against the invitation, and a mismatch is refused. An API key can only be matched + * that way when the service exposes an endpoint naming the key's owner. Where it does not, + * the binding rests on possession of the invitation link alone. + * + * That difference is modelled rather than smoothed over: a missing address is `unproven`, + * not an absent field, so every new verifier has to answer the question and every surface + * can show which bindings are proven. + */ +export type CredentialGroupApiKeyVerification = + | { identity: 'verified'; subjectId: string; displayName: string; email: string } + | { identity: 'unproven'; subjectId: string; displayName: string } + +/** + * Stand-in subject for a service that cannot name a key's owner. + * + * `credential.providerSubjectId` is required for every managed credential, and for an + * unproven binding the only thing that distinguishes one grant from another is the credential + * itself. A digest over every field, key-sorted so field order cannot change it, gives a + * stable non-reversible identifier that changes when any part is rotated — which is correct, + * since a replacement credential is a new grant. The prefix keeps it from being mistaken for + * something the provider issued. + */ +export function unprovenApiKeySubjectId(fields: Record): string { + const canonical = Object.keys(fields) + .sort() + .map((key) => `${key}\u0000${fields[key]}`) + .join('\u0001') + return `unproven:${createHash('sha256').update(canonical).digest('hex')}` +} + +export class CredentialGroupApiKeyVerificationError extends Error { + constructor(message: string) { + super(message) + this.name = 'CredentialGroupApiKeyVerificationError' + } +} + +export interface CredentialGroupApiKeyVerifier { + provider: CredentialGroupApiKeyProvider + /** + * Proves the credential works, and names its owner where the service can. + * + * Receives every field the provider declares, keyed by field id, already trimmed and + * length-checked. Throws {@link CredentialGroupApiKeyVerificationError} with a message safe + * to show the invited person when the credential is rejected. This runs while they are still + * on the page, so a typo is caught at collection instead of surfacing later as a failing + * workflow. + */ + verify(fields: Record): Promise +} diff --git a/apps/sim/lib/credential-groups/api-key-providers/verifiers.test.ts b/apps/sim/lib/credential-groups/api-key-providers/verifiers.test.ts new file mode 100644 index 00000000000..a952e23a5b8 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key-providers/verifiers.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + awsApiKeyVerifier, + deriveSigningKey, + signStsRequest, +} from '@/lib/credential-groups/api-key-providers/aws' +import { firefliesApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/fireflies' +import { grainApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/grain' +import { granolaApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/granola' +import { getCredentialGroupApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/registry' +import { + CredentialGroupApiKeyVerificationError, + unprovenApiKeySubjectId, +} from '@/lib/credential-groups/api-key-providers/types' +import { CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS } from '@/lib/credential-groups/providers' + +const originalFetch = global.fetch + +function mockFetch(response: Partial & { json?: () => Promise }) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + ...response, + }) as typeof global.fetch +} + +describe('credential group API key verifiers', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => { + global.fetch = originalFetch + }) + + it('resolves a verifier for every registered API-key provider', () => { + for (const provider of CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS) { + expect(getCredentialGroupApiKeyVerifier(provider).provider).toBe(provider) + } + }) + + describe('fireflies', () => { + it('returns a verified identity from the viewer query', async () => { + mockFetch({ + json: async () => ({ + data: { user: { user_id: 'u1', name: 'Ada', email: 'Ada@Example.com ' } }, + }), + }) + + await expect(firefliesApiKeyVerifier.verify({ apiKey: 'key' })).resolves.toEqual({ + identity: 'verified', + subjectId: 'u1', + displayName: 'Ada', + email: 'ada@example.com', + }) + }) + + it('rejects an unauthorized key', async () => { + mockFetch({ ok: false, status: 401 }) + await expect(firefliesApiKeyVerifier.verify({ apiKey: 'key' })).rejects.toThrow( + CredentialGroupApiKeyVerificationError + ) + }) + + /** GraphQL reports auth failures in a 200 body, so status alone is not enough. */ + it('rejects a 200 response carrying GraphQL errors', async () => { + mockFetch({ json: async () => ({ errors: [{ message: 'Unauthorized' }] }) }) + await expect(firefliesApiKeyVerifier.verify({ apiKey: 'key' })).rejects.toThrow( + CredentialGroupApiKeyVerificationError + ) + }) + + it('rejects a viewer without an email rather than downgrading to unproven', async () => { + mockFetch({ json: async () => ({ data: { user: { user_id: 'u1', name: 'Ada' } } }) }) + await expect(firefliesApiKeyVerifier.verify({ apiKey: 'key' })).rejects.toThrow( + CredentialGroupApiKeyVerificationError + ) + }) + }) + + describe.each([ + ['grain', grainApiKeyVerifier], + ['granola', granolaApiKeyVerifier], + ])('%s', (_name, verifier) => { + it('reports an unproven identity keyed to the credential itself', async () => { + mockFetch({ json: async () => ({ data: [] }) }) + + const result = await verifier.verify({ apiKey: 'some-api-key' }) + + expect(result.identity).toBe('unproven') + expect(result.subjectId).toBe(unprovenApiKeySubjectId({ apiKey: 'some-api-key' })) + expect(result).not.toHaveProperty('email') + }) + + it('gives a different subject when the key rotates', async () => { + mockFetch({ json: async () => ({}) }) + const first = await verifier.verify({ apiKey: 'key-one' }) + const second = await verifier.verify({ apiKey: 'key-two' }) + expect(first.subjectId).not.toBe(second.subjectId) + }) + + it('rejects a forbidden key', async () => { + mockFetch({ ok: false, status: 403 }) + await expect(verifier.verify({ apiKey: 'key' })).rejects.toThrow( + CredentialGroupApiKeyVerificationError + ) + }) + + it('rejects a transport failure without leaking the cause', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) as typeof global.fetch + await expect(verifier.verify({ apiKey: 'key' })).rejects.toThrow( + CredentialGroupApiKeyVerificationError + ) + }) + }) + + it('never puts a credential value in the subject identifier', () => { + const subject = unprovenApiKeySubjectId({ apiKey: 'sk-secret', other: 'v-secret' }) + expect(subject).not.toContain('sk-secret') + expect(subject).not.toContain('v-secret') + expect(subject).toMatch(/^unproven:[0-9a-f]{64}$/) + }) + + it('is insensitive to field insertion order', () => { + expect(unprovenApiKeySubjectId({ a: '1', b: '2' })).toBe( + unprovenApiKeySubjectId({ b: '2', a: '1' }) + ) + }) + + describe('aws', () => { + const credentials = { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + region: 'us-east-1', + } + + /** + * AWS's own published derivation vector. This is the only independently-known-correct + * value available for this file, and it is what makes the golden signature below + * meaningful rather than merely self-consistent. + */ + it('derives the signing key AWS documents for its worked example', () => { + expect( + deriveSigningKey( + 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + '20150830', + 'us-east-1', + 'iam' + ).toString('hex') + ).toBe('c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9') + }) + + /** + * Pins the whole header for fixed inputs. A shape assertion cannot do this job: a wrong + * signature is still 64 hex characters, so reordering the HMAC chain, changing the + * credential scope, or altering the canonical request would all pass unnoticed and surface + * only as AWS rejecting a valid key in production. + */ + it('produces a stable signature for fixed credentials and timestamp', () => { + expect( + signStsRequest( + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + '20150830T123600Z' + ) + ).toBe( + 'AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20150830/us-east-1/sts/aws4_request, ' + + 'SignedHeaders=content-type;host;x-amz-date, ' + + 'Signature=6fb20d31f734d876c5682fdd2678d194cf68b862755f83b7ba1373c0874be25c' + ) + }) + + it('sends the signed GetCallerIdentity request and reports an unproven identity', async () => { + mockFetch({ text: async () => '' }) + + const result = await awsApiKeyVerifier.verify(credentials) + + const [url, init] = (global.fetch as ReturnType).mock.calls[0] + expect(url).toBe('https://sts.amazonaws.com/') + expect(init.body).toBe('Action=GetCallerIdentity&Version=2011-06-15') + expect(init.headers.Authorization).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\/\d{8}\/us-east-1\/sts\/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=[0-9a-f]{64}$/ + ) + expect(init.headers['X-Amz-Date']).toMatch(/^\d{8}T\d{6}Z$/) + expect(result.identity).toBe('unproven') + expect(result.subjectId).toBe(unprovenApiKeySubjectId(credentials)) + }) + + it('rejects a malformed region before making a request', async () => { + mockFetch({}) + await expect( + awsApiKeyVerifier.verify({ ...credentials, region: 'US East (N. Virginia)' }) + ).rejects.toThrow(/Region must look like/) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('rejects credentials AWS refuses', async () => { + mockFetch({ ok: false, status: 403 }) + await expect(awsApiKeyVerifier.verify(credentials)).rejects.toThrow( + CredentialGroupApiKeyVerificationError + ) + }) + + /** The region is stored for the tools that consume it, not folded into signing. */ + it('changes subject when only the region changes', async () => { + mockFetch({}) + const base = await awsApiKeyVerifier.verify(credentials) + const other = await awsApiKeyVerifier.verify({ ...credentials, region: 'eu-west-1' }) + expect(other.subjectId).not.toBe(base.subjectId) + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/api-key.ts b/apps/sim/lib/credential-groups/api-key.ts new file mode 100644 index 00000000000..eb2f9b7aa24 --- /dev/null +++ b/apps/sim/lib/credential-groups/api-key.ts @@ -0,0 +1,171 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { normalizeEmail } from '@sim/utils/string' +import { and, eq, ne, sql } from 'drizzle-orm' +import { + type CredentialGroupApiKeyVerification, + CredentialGroupApiKeyVerificationError, +} from '@/lib/credential-groups/api-key-providers/types' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { + CredentialGroupInvitationUnavailableError, + CredentialGroupOAuthError, +} from '@/lib/credential-groups/provider-adapter' +import { + type CredentialGroupApiKeyProvider, + getCredentialGroupProviderId, + getCredentialGroupProviderPresentation, +} from '@/lib/credential-groups/providers' +import { sealManagedApiKey } from '@/lib/credentials/managed-api-key' + +/** + * Stores a verified API key against one enrollment option. + * + * Mirrors the OAuth grant path's locking exactly — the same lifecycle lock, an option-scoped + * advisory lock, and a `FOR UPDATE` re-read of the group — because the races are the same: + * a revocation landing mid-submit, and two submissions for one option colliding. + */ +export async function persistCredentialGroupApiKey(params: { + context: CredentialGroupOAuthContext + provider: CredentialGroupApiKeyProvider + fields: Record + verification: CredentialGroupApiKeyVerification +}): Promise { + const { context, provider, fields, verification } = params + + /** + * Where the service can name the key's owner, hold it to the same rule the OAuth path + * enforces: the credential must belong to the person the invitation was sent to. Where it + * cannot, the binding rests on possession of the invitation link, and the option is + * recorded without an address rather than with an unverified one. + */ + if (verification.identity === 'verified') { + const grantedEmail = normalizeEmail(verification.email) + if (grantedEmail !== context.email) { + throw new CredentialGroupOAuthError( + `This key belongs to ${grantedEmail}. Use the key for ${context.email}.`, + 403 + ) + } + } + + const encryptedApiKey = await sealManagedApiKey(fields) + const providerId = getCredentialGroupProviderId(provider) + const providerName = getCredentialGroupProviderPresentation(provider).name + + await db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, context.enrollmentId) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-api-key:${context.enrollmentId}:${context.option.id}`}, 0))` + ) + + const [enrollment] = await tx + .select({ status: credentialGroupEnrollment.status }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, context.enrollmentId)) + .limit(1) + if (!enrollment || enrollment.status === 'revoked') { + throw new CredentialGroupInvitationUnavailableError() + } + + const [group] = await tx + .select({ status: credentialGroup.status, options: credentialGroup.options }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, context.credentialGroupId), + eq(credentialGroup.workspaceId, context.workspaceId) + ) + ) + .limit(1) + .for('update') + const currentOption = group?.options.find((option) => option.id === context.option.id) + if ( + !group || + group.status !== 'active' || + !currentOption || + currentOption.status !== 'active' || + currentOption.provider !== provider + ) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + + const [existing] = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_api_key'), + eq(credential.credentialGroupEnrollmentId, context.enrollmentId), + eq(credential.credentialGroupOptionId, context.option.id) + ) + ) + .limit(1) + + const now = new Date() + const values = { + workspaceId: context.workspaceId, + type: 'managed_api_key' as const, + displayName: verification.displayName, + description: `Managed ${providerName} key for ${context.workspaceName}`, + providerId, + accountId: null, + credentialGroupEnrollmentId: context.enrollmentId, + credentialGroupOptionId: context.option.id, + providerSubjectId: verification.subjectId, + providerTenantId: null, + managedOauthStatus: 'active' as const, + providerMetadata: + verification.identity === 'verified' + ? { email: verification.email, displayName: verification.displayName } + : { displayName: verification.displayName }, + encryptedApiKey, + grantedAt: now, + revokedAt: null, + updatedAt: now, + } + + if (existing) { + const [updated] = await tx + .update(credential) + .set(values) + .where(eq(credential.id, existing.id)) + .returning({ id: credential.id }) + if (!updated) throw new Error('Managed API key credential update returned no row') + } else { + const [inserted] = await tx + .insert(credential) + .values({ + id: generateId(), + ...values, + createdBy: context.workspaceOwnerId, + createdAt: now, + }) + .returning({ id: credential.id }) + if (!inserted) throw new Error('Managed API key credential insert returned no row') + } + + const [updatedEnrollment] = await tx + .update(credentialGroupEnrollment) + .set({ + status: enrollment.status === 'completed' ? 'completed' : 'in_progress', + ...(enrollment.status === 'completed' ? {} : { completedAt: null }), + updatedAt: now, + }) + .where( + and( + eq(credentialGroupEnrollment.id, context.enrollmentId), + ne(credentialGroupEnrollment.status, 'revoked') + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!updatedEnrollment) throw new CredentialGroupInvitationUnavailableError() + }) +} + +export { CredentialGroupApiKeyVerificationError } diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts index b53788bda45..20ea758c440 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-operations.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -25,6 +25,10 @@ export const credentialGroupEnrollmentOperations = { id: 'credential_groups.enrollment.oauth.complete', principalKind: 'credential_group_enrollment', }), + submitApiKey: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.api_key.submit', + principalKind: 'credential_group_enrollment', + }), complete: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.complete', principalKind: 'credential_group_enrollment', diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index ccda73d5984..9b232cf21fd 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -3,6 +3,8 @@ import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' import type { OperationUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { persistCredentialGroupApiKey } from '@/lib/credential-groups/api-key' +import { getCredentialGroupApiKeyVerifier } from '@/lib/credential-groups/api-key-providers/registry' import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations' import { completeAuthorizedCredentialGroupEnrollment, @@ -15,6 +17,12 @@ import { startCredentialGroupOAuth, } from '@/lib/credential-groups/oauth' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { + getCredentialGroupApiKeyFields, + isCredentialGroupApiKeyProvider, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import { requireStorableManagedApiKeyFields } from '@/lib/credentials/managed-api-key' interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition { operation: O @@ -186,3 +194,51 @@ export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGrou return { connectedOptionId: context.oauth.option.id } }, }) + +interface SubmitPublicCredentialGroupApiKeyInput { + invitationToken: string + optionId: string + /** Every value the provider declares, keyed by field id. */ + fields: Record +} + +/** + * Accepts one API key for one enrollment option. + * + * Reuses the OAuth context resolver because the shape it loads — the enrollment, the group, + * and the single option being satisfied — is identical; only the way that option is + * satisfied differs. The verifier runs before anything is written, so an unusable key is + * refused while the person is still on the page rather than surfacing later as a workflow + * that cannot authenticate. + */ +export const submitPublicCredentialGroupApiKey = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.submitApiKey, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: SubmitPublicCredentialGroupApiKeyInput + }) => resolvePublicOAuthContext(principal, input.optionId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.invitationToken) + + const provider = context.oauth.option.provider + if (!isCredentialGroupProvider(provider) || !isCredentialGroupApiKeyProvider(provider)) { + throw new OrchestrationError('validation', 'This account is not connected with an API key') + } + + const fields = requireStorableManagedApiKeyFields( + getCredentialGroupApiKeyFields(provider), + input.fields + ) + const verification = await getCredentialGroupApiKeyVerifier(provider).verify(fields) + await persistCredentialGroupApiKey({ + context: context.oauth, + provider, + fields, + verification, + }) + return { connectedOptionId: context.oauth.option.id } + }, +}) diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts index 1ffe32740af..23eadd8279b 100644 --- a/apps/sim/lib/credential-groups/credentials.ts +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -12,6 +12,7 @@ import { getCredentialGroupProviderId, isCredentialGroupProvider, } from '@/lib/credential-groups/providers' +import { MANAGED_CREDENTIAL_TYPES } from '@/lib/credentials/access' export const MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE = 100 @@ -99,7 +100,7 @@ export async function loadCredentialGroupEnrollmentAccessForSubject( and( eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), - eq(credential.type, 'managed_oauth'), + inArray(credential.type, MANAGED_CREDENTIAL_TYPES), eq(credential.managedOauthStatus, 'active'), eq(credential.providerId, providerId), eq(credential.providerTenantId, subject.tenantId), @@ -162,7 +163,7 @@ export async function listCredentialGroupCredentialReferences({ and( eq(credential.id, cursor), eq(credential.workspaceId, workspaceId), - eq(credential.type, 'managed_oauth'), + inArray(credential.type, MANAGED_CREDENTIAL_TYPES), eq(credential.managedOauthStatus, 'active'), eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), email ? eq(credentialGroupEnrollment.email, email) : undefined, @@ -196,7 +197,7 @@ export async function listCredentialGroupCredentialReferences({ .where( and( eq(credential.workspaceId, workspaceId), - eq(credential.type, 'managed_oauth'), + inArray(credential.type, MANAGED_CREDENTIAL_TYPES), eq(credential.managedOauthStatus, 'active'), eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), email ? eq(credentialGroupEnrollment.email, email) : undefined, diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 3f99519ab2f..bc47baf75cf 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -4,6 +4,7 @@ import { credential, credentialGroup, credentialGroupEnrollment, + isCredentialGroupApiKeyOptionConfig, user, workspace, } from '@sim/db/schema' @@ -17,6 +18,7 @@ import { getCredentialGroupInvitationSubject } from '@/components/emails/subject import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { getBaseUrl } from '@/lib/core/utils/urls' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { requireCredentialGroupOAuthOptionConfig } from '@/lib/credential-groups/option-config' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { @@ -30,6 +32,7 @@ import type { CredentialGroupEnrollmentRecord, InviteCredentialGroupEnrollmentsInput, } from '@/lib/credential-groups/types' +import { MANAGED_CREDENTIAL_TYPES } from '@/lib/credentials/access' import type { DbOrTx } from '@/lib/db/types' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -565,7 +568,7 @@ export async function listCredentialGroupEnrollments( .from(credential) .where( and( - eq(credential.type, 'managed_oauth'), + inArray(credential.type, MANAGED_CREDENTIAL_TYPES), inArray(credential.credentialGroupEnrollmentId, enrollmentIds), inArray(credential.credentialGroupOptionId, activeOptionIds) ) @@ -788,7 +791,7 @@ async function buildPublicCredentialGroupEnrollment( .from(credential) .where( and( - eq(credential.type, 'managed_oauth'), + inArray(credential.type, MANAGED_CREDENTIAL_TYPES), eq(credential.credentialGroupEnrollmentId, row.enrollment.id) ) ) @@ -802,14 +805,27 @@ async function buildPublicCredentialGroupEnrollment( if (!isCredentialGroupProvider(option.provider)) { throw new Error(`Unsupported Credential Group provider: ${option.provider}`) } - const adapter = getCredentialGroupProviderAdapter(option.provider) - const policy = await adapter.getPolicy(option, { - workspaceId: row.workspaceId, - credentialGroupId: row.groupId, - }) + const provider = option.provider + /** + * An API-key option has no scope policy, so a stored key is either usable or + * revoked. The drift checks below exist to catch an OAuth grant whose authorization + * app or scopes changed underneath it, and there is no equivalent to drift against. + */ + const oauthPolicy = isCredentialGroupApiKeyOptionConfig(option) + ? null + : await (async () => { + const adapter = getCredentialGroupProviderAdapter(provider) + return { + adapter, + policy: await adapter.getPolicy(requireCredentialGroupOAuthOptionConfig(option), { + workspaceId: row.workspaceId, + credentialGroupId: row.groupId, + }), + } + })() return { id: option.id, - provider: option.provider, + provider, label: option.label, required: option.required, status: option.status, @@ -820,15 +836,17 @@ async function buildPublicCredentialGroupEnrollment( const status = connection.status === 'revoked' ? ('revoked' as const) - : connection.status !== 'active' || - connection.authorizationAppId !== policy.authorizationAppId || - connection.scopeVersion !== policy.scopeVersion || - !adapter.hasRequiredScopes( - connection.grantedScopes ?? [], - policy.requiredScopes - ) + : connection.status !== 'active' ? ('needs_reauth' as const) - : ('connected' as const) + : oauthPolicy && + (connection.authorizationAppId !== oauthPolicy.policy.authorizationAppId || + connection.scopeVersion !== oauthPolicy.policy.scopeVersion || + !oauthPolicy.adapter.hasRequiredScopes( + connection.grantedScopes ?? [], + oauthPolicy.policy.requiredScopes + )) + ? ('needs_reauth' as const) + : ('connected' as const) return { email, displayName: diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 0aea1addc2a..79e4a1c22f4 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -10,6 +10,7 @@ import { type CredentialGroupOAuthAttempt, createCredentialGroupOAuthAttempt, } from '@/lib/credential-groups/oauth-state' +import { requireCredentialGroupOAuthOptionConfig } from '@/lib/credential-groups/option-config' import type { CredentialGroupProviderAdapter, CredentialGroupProviderPolicy, @@ -63,7 +64,7 @@ async function assertCurrentPolicy( adapter: CredentialGroupProviderAdapter, attempt?: CredentialGroupOAuthAttempt ): Promise { - const policy = await adapter.getPolicy(context.option, { + const policy = await adapter.getPolicy(requireCredentialGroupOAuthOptionConfig(context.option), { workspaceId: context.workspaceId, credentialGroupId: context.credentialGroupId, }) @@ -154,11 +155,14 @@ async function persistGrant( 409 ) } - const currentPolicy = await adapter.getPolicy(currentOption, { - workspaceId: context.workspaceId, - credentialGroupId: context.credentialGroupId, - executor: tx, - }) + const currentPolicy = await adapter.getPolicy( + requireCredentialGroupOAuthOptionConfig(currentOption), + { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + executor: tx, + } + ) if (!policiesEqual(currentPolicy, policy)) { throw new CredentialGroupOAuthError( 'This credential option changed. Reload the invitation and try again.', diff --git a/apps/sim/lib/credential-groups/option-config.ts b/apps/sim/lib/credential-groups/option-config.ts new file mode 100644 index 00000000000..f562b738a37 --- /dev/null +++ b/apps/sim/lib/credential-groups/option-config.ts @@ -0,0 +1,25 @@ +import { + type CredentialGroupOAuthOptionConfig, + type CredentialGroupOptionConfig, + isCredentialGroupApiKeyOptionConfig, +} from '@sim/db/schema' + +/** + * Narrows a stored option to the OAuth member. + * + * The OAuth enrollment path — authorization URLs, token exchange, scope policy comparison, + * reconnect detection — is meaningless for an option enrolled by pasting a key, so reaching + * it with one is a routing bug. Throwing surfaces that at the boundary instead of letting + * `undefined` scopes flow into a policy comparison that would silently mark every credential + * as needing reauthorization. + */ +export function requireCredentialGroupOAuthOptionConfig( + option: CredentialGroupOptionConfig +): CredentialGroupOAuthOptionConfig { + if (isCredentialGroupApiKeyOptionConfig(option)) { + throw new Error( + `Credential Group option ${option.id} enrolls with an API key and has no OAuth policy` + ) + } + return option +} diff --git a/apps/sim/lib/credential-groups/provider-adapter.ts b/apps/sim/lib/credential-groups/provider-adapter.ts index 48387833eae..d1af6a35e5e 100644 --- a/apps/sim/lib/credential-groups/provider-adapter.ts +++ b/apps/sim/lib/credential-groups/provider-adapter.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto' -import type { CredentialGroupOptionConfig, ManagedOAuthProviderMetadata } from '@sim/db/schema' +import type { + CredentialGroupOAuthOptionConfig, + ManagedCredentialProviderMetadata, +} from '@sim/db/schema' import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' @@ -27,7 +30,7 @@ export interface VerifiedCredentialGroupGrant { providerSubjectId: string providerTenantId: string | null displayName: string - metadata: ManagedOAuthProviderMetadata + metadata: ManagedCredentialProviderMetadata accessToken: string refreshToken?: string grantedScopes: string[] @@ -45,7 +48,7 @@ export interface CredentialGroupProviderAdapter { provider: CredentialGroupProvider requiresRefreshToken: boolean getPolicy( - option: Pick | undefined, + option: Pick | undefined, context: { workspaceId: string credentialGroupId?: string diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts index 08642b7e598..99239b73556 100644 --- a/apps/sim/lib/credential-groups/provider-registry.ts +++ b/apps/sim/lib/credential-groups/provider-registry.ts @@ -1,13 +1,20 @@ import type { CredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-adapter' import { + type CredentialGroupOAuthProvider, type CredentialGroupProvider, getCredentialGroupProviderFromProviderId, + isCredentialGroupApiKeyProvider, } from '@/lib/credential-groups/providers' import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider' import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' +/** + * Keyed by the OAuth-only union. API-key providers enroll through a verifier + * (`api-key-providers/registry.ts`) and have no authorization URL, token exchange, or + * refresh to implement, so they are deliberately absent rather than stubbed. + */ const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< - CredentialGroupProvider, + CredentialGroupOAuthProvider, CredentialGroupProviderAdapter > = { gmail: createStandardOAuthCredentialGroupProviderAdapter('gmail'), @@ -37,6 +44,9 @@ const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< export function getCredentialGroupProviderAdapter( provider: CredentialGroupProvider ): CredentialGroupProviderAdapter { + if (isCredentialGroupApiKeyProvider(provider)) { + throw new Error(`Credential Group provider ${provider} enrolls with an API key, not OAuth`) + } return CREDENTIAL_GROUP_PROVIDER_ADAPTERS[provider] } diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts index 07cce8fe04d..3efc7f10410 100644 --- a/apps/sim/lib/credential-groups/providers.ts +++ b/apps/sim/lib/credential-groups/providers.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' +import { FirefliesIcon, GrainIcon, GranolaIcon, S3Icon } from '@/components/icons' import type { OAuthServiceConfig } from '@/lib/oauth' import { getServiceConfigByServiceId } from '@/lib/oauth' @@ -28,19 +30,89 @@ export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = [ export type CredentialGroupStandardOAuthProvider = (typeof CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)[number] +/** + * Services whose per-person credential is an API key rather than an OAuth grant. + * + * These have no entry in `OAUTH_PROVIDERS` and must not gain one — `OAuthServiceConfig` + * requires a `providerId` and `scopes`, neither of which means anything here — so their + * presentation and provider id are declared inline on the support record instead of being + * resolved through the OAuth service catalog. + */ +export const CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS = [ + 'aws', + 'fireflies', + 'grain', + 'granola', +] as const + +export type CredentialGroupApiKeyProvider = (typeof CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS)[number] + export const CREDENTIAL_GROUP_PROVIDER_IDS = [ ...CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, 'slack', + ...CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS, ] as const export type CredentialGroupProvider = (typeof CREDENTIAL_GROUP_PROVIDER_IDS)[number] -export interface CredentialGroupProviderSupport { - serviceId: string - description: string - configuration: 'oauth' | 'slack_custom_bot' +/** Providers whose enrollment runs through an OAuth adapter. */ +export type CredentialGroupOAuthProvider = Exclude< + CredentialGroupProvider, + CredentialGroupApiKeyProvider +> + +export interface CredentialGroupProviderPresentation { + name: string + icon: (props: { className?: string }) => ReactNode +} + +/** + * One value an invited person supplies for an API-key option. + * + * `secret: false` exists for the parts of a credential that identify rather than authenticate — + * a tenant subdomain, a regional host. Those must stay out of the redaction catalog: they are + * short, they recur in ordinary log lines, and substituting them would corrupt output that has + * nothing to do with the credential. + */ +/** + * Where an invited person goes to create the credential. + * + * `steps` rather than a bare link because these destinations differ in kind: Granola issues + * keys from its desktop app, so it has no URL anyone else can link to. `url` is set only where the provider publishes a stable address for the + * screen that creates the key — a help article is not one, and linking one under a provider's + * name would send people somewhere other than where the label promises. + * + * Keep `steps` to the navigation itself. Why a provider does or does not have a link is our + * problem, not the reader's. + */ +export interface CredentialGroupApiKeyLocation { + steps: string + url?: string +} + +export interface CredentialGroupApiKeyField { + id: string + label: string + placeholder: string + secret: boolean } +export type CredentialGroupProviderSupport = + | { + configuration: 'oauth' | 'slack_custom_bot' + serviceId: string + description: string + } + | { + configuration: 'api_key' + providerId: string + description: string + /** Every value the invited person must supply, in the order they are shown. */ + fields: readonly CredentialGroupApiKeyField[] + keyLocation: CredentialGroupApiKeyLocation + presentation: CredentialGroupProviderPresentation + } + const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< CredentialGroupProvider, CredentialGroupProviderSupport @@ -155,6 +227,91 @@ const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< description: 'Let each person connect through your custom Slack app', configuration: 'slack_custom_bot', }, + aws: { + configuration: 'api_key', + providerId: 'aws', + description: 'Let each person share their own AWS access key', + fields: [ + { + id: 'accessKeyId', + label: 'Access key ID', + placeholder: 'AKIAIOSFODNN7EXAMPLE', + secret: true, + }, + { + id: 'secretAccessKey', + label: 'Secret access key', + placeholder: 'Paste your secret access key', + secret: true, + }, + { + /** + * Not a secret: a region is a short, public, endlessly recurring string, and + * cataloguing it for redaction would rewrite `us-east-1` out of unrelated log lines. + */ + id: 'region', + label: 'Region', + placeholder: 'us-east-1', + secret: false, + }, + ], + keyLocation: { + steps: + 'In the AWS console, open IAM, then your user, then Security credentials, then Create access key.', + url: 'https://console.aws.amazon.com/iam/', + }, + presentation: { name: 'AWS', icon: S3Icon }, + }, + fireflies: { + configuration: 'api_key', + providerId: 'fireflies', + description: 'Let each person share their own Fireflies API key', + fields: [ + { + id: 'apiKey', + label: 'Fireflies API key', + placeholder: 'Paste your Fireflies API key', + secret: true, + }, + ], + keyLocation: { steps: 'In Fireflies, open Integrations and select Fireflies API.' }, + presentation: { name: 'Fireflies', icon: FirefliesIcon }, + }, + grain: { + configuration: 'api_key', + providerId: 'grain', + description: 'Let each person share their own Grain API key', + fields: [ + { + id: 'apiKey', + label: 'Grain API key', + placeholder: 'Paste your Grain personal access token', + secret: true, + }, + ], + keyLocation: { + steps: 'In Grain, open Workspace settings, then Integrations, then the API tab.', + url: 'https://grain.com/app/settings/integrations?tab=api', + }, + presentation: { name: 'Grain', icon: GrainIcon }, + }, + granola: { + configuration: 'api_key', + providerId: 'granola', + description: 'Let each person share their own Granola API key', + fields: [ + { + id: 'apiKey', + label: 'Granola API key', + placeholder: 'Paste your Granola API key', + secret: true, + }, + ], + keyLocation: { + steps: 'In the Granola desktop app, open Settings, then Connectors, then API keys.', + }, + presentation: { name: 'Granola', icon: GranolaIcon }, + }, } export function isCredentialGroupProvider(value: string): value is CredentialGroupProvider { @@ -167,10 +324,24 @@ export function isCredentialGroupStandardOAuthProvider( return CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS.some((provider) => provider === value) } +export function isCredentialGroupApiKeyProvider( + value: CredentialGroupProvider +): value is CredentialGroupApiKeyProvider { + return CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS.some((provider) => provider === value) +} + +/** + * Throws for an API-key provider, which has no OAuth service to resolve. Call it only + * behind {@link isCredentialGroupApiKeyProvider}, or use + * {@link getCredentialGroupProviderPresentation} when all you need is a name and an icon. + */ export function getCredentialGroupProviderService( provider: CredentialGroupProvider ): OAuthServiceConfig { const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + if (support.configuration === 'api_key') { + throw new Error(`Credential Group provider ${provider} is not an OAuth service`) + } const service = getServiceConfigByServiceId(support.serviceId) if (!service) { throw new Error( @@ -180,14 +351,49 @@ export function getCredentialGroupProviderService( return service } +/** Name and icon for any provider, whichever catalog it is declared in. */ +export function getCredentialGroupProviderPresentation( + provider: CredentialGroupProvider +): CredentialGroupProviderPresentation { + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + if (support.configuration === 'api_key') return support.presentation + const service = getCredentialGroupProviderService(provider) + return { name: service.name, icon: service.icon } +} + export function getCredentialGroupProviderSupport( provider: CredentialGroupProvider ): CredentialGroupProviderSupport { return CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] } +/** Where the invited person creates this provider's credential. */ +export function getCredentialGroupApiKeyLocation( + provider: CredentialGroupApiKeyProvider +): CredentialGroupApiKeyLocation { + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + if (support.configuration !== 'api_key') { + throw new Error(`Credential Group provider ${provider} does not collect API key fields`) + } + return support.keyLocation +} + +/** The fields an API-key provider collects. Throws for any other provider. */ +export function getCredentialGroupApiKeyFields( + provider: CredentialGroupApiKeyProvider +): readonly CredentialGroupApiKeyField[] { + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + if (support.configuration !== 'api_key') { + throw new Error(`Credential Group provider ${provider} does not collect API key fields`) + } + return support.fields +} + export function getCredentialGroupProviderId(provider: CredentialGroupProvider): string { - return getCredentialGroupProviderService(provider).providerId + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + return support.configuration === 'api_key' + ? support.providerId + : getCredentialGroupProviderService(provider).providerId } export function getCredentialGroupProviderFromProviderId( diff --git a/apps/sim/lib/credential-groups/rate-limit.ts b/apps/sim/lib/credential-groups/rate-limit.ts index 373531c0ad5..9d5fc5745a7 100644 --- a/apps/sim/lib/credential-groups/rate-limit.ts +++ b/apps/sim/lib/credential-groups/rate-limit.ts @@ -37,6 +37,7 @@ type PublicCredentialGroupRateLimitScope = | 'oauth-start' | 'oauth-callback' | 'complete' + | 'api-key-submit' function rateLimitResponse(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { const retryAfterSeconds = Math.ceil((retryAfterMs ?? fallbackMs) / 1000) @@ -54,7 +55,11 @@ function rateLimitResponse(retryAfterMs: number | undefined, fallbackMs: number) function configForPublicScope(scope: PublicCredentialGroupRateLimitScope): TokenBucketConfig { if (scope === 'metadata') return PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT - if (scope === 'oauth-start' || scope === 'complete') return PUBLIC_OAUTH_START_RATE_LIMIT + // An API-key submit carries a secret and is guessable, so it shares the tight + // authorization-start bucket rather than the permissive metadata one. + if (scope === 'oauth-start' || scope === 'complete' || scope === 'api-key-submit') { + return PUBLIC_OAUTH_START_RATE_LIMIT + } return PUBLIC_OAUTH_CALLBACK_RATE_LIMIT } diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index 6bff6ee700d..189a1875568 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -4,6 +4,7 @@ import { credential, credentialGroup, credentialGroupEnrollment, + isCredentialGroupApiKeyOptionConfig, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, desc, eq, inArray } from 'drizzle-orm' @@ -11,10 +12,14 @@ import { credentialGroupWorkflowAccessPolicyCodec, requireDefaultCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' +import { requireCredentialGroupOAuthOptionConfig } from '@/lib/credential-groups/option-config' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' -import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' +import { + isCredentialGroupApiKeyProvider, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' import type { CreateCredentialGroupInput, @@ -43,6 +48,16 @@ async function buildOption( credentialGroupId?: string, executor: DbOrTx = db ): Promise { + if (isCredentialGroupApiKeyProvider(option.provider)) { + return { + kind: 'api_key', + id: generateId(), + provider: option.provider, + label: option.label, + required: option.required, + status: 'active', + } + } const providerConfig = await getCredentialGroupProviderAdapter(option.provider).getPolicy( option, { workspaceId, credentialGroupId, executor } @@ -77,6 +92,17 @@ async function updateOptions( throw new Error('A credential option provider cannot be changed; add a new option instead') } + if (isCredentialGroupApiKeyProvider(input.provider)) { + return { + kind: 'api_key' as const, + id: existing.id, + provider: existing.provider, + label: input.label, + required: input.required, + status: existing.status, + } + } + const providerConfig = await getCredentialGroupProviderAdapter(input.provider).getPolicy( input, { workspaceId, credentialGroupId, executor } @@ -120,18 +146,19 @@ async function toCredentialGroup( if (option.provider !== 'slack') { return { ...common, provider: option.provider, configurationStatus: 'ready' as const } } - if (!option.slackBotCredentialId) { + const slackOption = requireCredentialGroupOAuthOptionConfig(option) + if (!slackOption.slackBotCredentialId) { throw new Error(`Slack credential option ${option.id} has no custom bot`) } return { ...common, provider: 'slack' as const, - slackBotCredentialId: option.slackBotCredentialId, + slackBotCredentialId: slackOption.slackBotCredentialId, configurationStatus: !providerConfiguration.slack || - providerConfiguration.slack.slackBotCredentialId !== option.slackBotCredentialId + providerConfiguration.slack.slackBotCredentialId !== slackOption.slackBotCredentialId ? ('not_configured' as const) - : option.scopeVersion !== + : slackOption.scopeVersion !== credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) || !SLACK_MANAGED_USER_SCOPES.every((scope) => providerConfiguration.slack?.scopes.includes(scope) @@ -262,12 +289,15 @@ export async function updateCredentialGroup( const invalidatedOptionIds = existing.options .filter((option) => { const next = nextOptionById.get(option.id) + if (!next || body.status === 'disabled') return true + // An API-key option carries no scope policy, so nothing about editing it can + // invalidate a key its owner already pasted. Only removal or disabling does. + if (isCredentialGroupApiKeyOptionConfig(option)) return false + if (isCredentialGroupApiKeyOptionConfig(next)) return true return ( - !next || next.authorizationAppId !== option.authorizationAppId || next.scopeVersion !== option.scopeVersion || - !scopesEqual(next.requiredScopes, option.requiredScopes) || - body.status === 'disabled' + !scopesEqual(next.requiredScopes, option.requiredScopes) ) }) .map((option) => option.id) diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 8814d4f97f3..d59544dbb0a 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -9,6 +9,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm' import { getRedisClient } from '@/lib/core/config/redis' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import { requireCredentialGroupOAuthOptionConfig } from '@/lib/credential-groups/option-config' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { decryptCredentialGroupProviderConfiguration, @@ -689,8 +690,9 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { if (!updated) throw new Error('Credential Group Slack configuration update returned no row') if ( existingOption && - (existingOption.authorizationAppId !== authorizationAppId || - existingOption.scopeVersion !== scopeVersion) + (requireCredentialGroupOAuthOptionConfig(existingOption).authorizationAppId !== + authorizationAppId || + requireCredentialGroupOAuthOptionConfig(existingOption).scopeVersion !== scopeVersion) ) { const enrollmentIds = tx .select({ id: credentialGroupEnrollment.id }) diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index 4ee972dce31..1819c634629 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -1,5 +1,6 @@ import { normalizeEmail } from '@sim/utils/string' import { getBaseUrl } from '@/lib/core/utils/urls' +import { requireCredentialGroupOAuthOptionConfig } from '@/lib/credential-groups/option-config' import type { CredentialGroupProviderAdapter, CredentialGroupProviderPolicy, @@ -123,7 +124,8 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter const currentPolicy = await getSlackPolicy({ workspaceId: context.workspaceId, credentialGroupId: context.credentialGroupId, - slackBotCredentialId: context.option.slackBotCredentialId, + slackBotCredentialId: requireCredentialGroupOAuthOptionConfig(context.option) + .slackBotCredentialId, }) if (currentPolicy.authorizationAppId !== policy.authorizationAppId) { throw new CredentialGroupOAuthError( @@ -149,7 +151,8 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter const currentPolicy = await getSlackPolicy({ workspaceId: context.workspaceId, credentialGroupId: context.credentialGroupId, - slackBotCredentialId: context.option.slackBotCredentialId, + slackBotCredentialId: requireCredentialGroupOAuthOptionConfig(context.option) + .slackBotCredentialId, }) const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` if ( diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 6f7bc25515e..200ee005ee3 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -12,12 +12,30 @@ type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] -export type OrdinaryCredentialType = Exclude + +/** + * Credentials a Credential Group collected on behalf of someone outside the workspace. + * + * Neither kind is user-managed: they are created by an enrollment, authorized through the + * group's resource policy, and read only by the execution paths that own them. + */ +export type ManagedCredentialType = Extract + +export const MANAGED_CREDENTIAL_TYPES: readonly ManagedCredentialType[] = [ + 'managed_oauth', + 'managed_api_key', +] + +export function isManagedCredentialType(type: CredentialType): type is ManagedCredentialType { + return MANAGED_CREDENTIAL_TYPES.some((managed) => managed === type) +} + +export type OrdinaryCredentialType = Exclude /** Narrows credentials exposed through ordinary user-managed credential surfaces. */ export function requireOrdinaryCredentialType(type: CredentialType): OrdinaryCredentialType { - if (type === 'managed_oauth') { - throw new Error('Managed OAuth credential reached an ordinary credential surface') + if (isManagedCredentialType(type)) { + throw new Error('Managed credential reached an ordinary credential surface') } return type } diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4753c3136a8..f62c533aa6c 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -143,6 +143,17 @@ export const credentialOperations = { workspaceApiKey: 'deny', principalKinds: ['session'], }), + useManagedApiKey: defineWorkspaceOperation({ + id: 'credentials.managed_api_key.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + resourcePolicy: { + resourceType: 'credential_group', + action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, + }, + }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', minimumRole: 'read', diff --git a/apps/sim/lib/credentials/application/resolve-managed-api-key.ts b/apps/sim/lib/credentials/application/resolve-managed-api-key.ts new file mode 100644 index 00000000000..c6b3f1ecf7b --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-api-key.ts @@ -0,0 +1,73 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + requireCredentialGroupCredentialAccess, +} from '@/lib/credential-groups/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedApiKeyCredentialApplicationContext, + type ManagedApiKeyCredentialApplicationContext, + type ResolvedManagedApiKey, + resolveManagedApiKey, +} from '@/lib/credentials/managed-api-key-resolution' + +/** + * Same scope rule as `credentialGroupDelegationPolicy`, restated for this use case's narrower + * context: the caller's delegation must name the group the credential belongs to. + */ +const managedApiKeyDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: ManagedApiKeyCredentialApplicationContext + ) => principal.resourceScope?.credentialGroupId === context.credentialGroupId, +} satisfies WorkspaceDelegationPolicy + +export interface ResolveManagedApiKeyInput { + credentialId: string + /** The group the caller's delegation is scoped to; the credential must belong to it. */ + credentialGroupId: string +} + +/** + * Reads one Credential Group API key for a running workflow. + * + * Authorization runs through the same resource policy as managed OAuth + * (`requireCredentialGroupCredentialAccess`), so the two enrollment kinds share one answer to + * "may this workflow use this person's credential": the actor may always use their own, and + * any other enrollment requires an explicit workflow access grant evaluated in deployment + * mode. Nothing about a key being pasted rather than granted changes that question. + */ +export const resolveManagedApiKeyCredential = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedApiKey, + resolveContext: async ({ input }: { input: ResolveManagedApiKeyInput }) => { + const context = await loadManagedApiKeyCredentialApplicationContext(input.credentialId) + if (!context) throw new OrchestrationError('not_found', 'Managed credential not found') + if (context.credentialGroupId !== input.credentialGroupId) { + throw new OrchestrationError('not_found', 'Managed credential not found') + } + return context + }, + authorizationOptions: { delegation: managedApiKeyDelegationPolicy }, + async authorizeResource({ principal, context, resourcePolicy }) { + await requireCredentialGroupCredentialAccess(principal, context, resourcePolicy) + }, + execute: async ({ context }): Promise => + resolveManagedApiKey({ + credentialId: context.credentialId, + workspaceId: context.workspaceId, + }), + projectAudit({ context }) { + return { + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: 'Accessed managed API key', + metadata: { credentialType: 'managed_api_key' }, + } + }, +}) diff --git a/apps/sim/lib/credentials/managed-api-key-resolution.ts b/apps/sim/lib/credentials/managed-api-key-resolution.ts new file mode 100644 index 00000000000..dff9a65b2b1 --- /dev/null +++ b/apps/sim/lib/credentials/managed-api-key-resolution.ts @@ -0,0 +1,167 @@ +import { db } from '@sim/db' +import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import type { CredentialGroupAuthorizationContext } from '@/lib/credential-groups/application/authorization' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + getCredentialGroupApiKeyFields, + getCredentialGroupProviderFromProviderId, + isCredentialGroupApiKeyProvider, +} from '@/lib/credential-groups/providers' +import { + type ManagedApiKeyProvenanceEntry, + openManagedApiKeySecret, +} from '@/lib/credentials/managed-api-key' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ManagedApiKeyCredentialApplicationContext + extends CredentialGroupAuthorizationContext { + credentialId: string + credentialGroupEnrollmentId: string +} + +export class ManagedApiKeyCredentialError extends Error { + constructor( + readonly code: string, + message: string, + readonly statusCode: 401 | 403 | 404 | 409 + ) { + super(message) + this.name = 'ManagedApiKeyCredentialError' + } +} + +async function getManagedApiKeyCredential(credentialId: string) { + const [row] = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + providerId: credential.providerId, + displayName: credential.displayName, + managedOauthStatus: credential.managedOauthStatus, + encryptedApiKey: credential.encryptedApiKey, + providerMetadata: credential.providerMetadata, + credentialGroupId: credentialGroupEnrollment.credentialGroupId, + credentialGroupEnrollmentId: credentialGroupEnrollment.id, + enrollmentEmail: credentialGroupEnrollment.email, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_api_key'))) + .limit(1) + return row ?? null +} + +/** Resolves the canonical workspace context for authorization without reading key material. */ +export async function loadManagedApiKeyCredentialApplicationContext( + credentialId: string +): Promise { + const row = await getManagedApiKeyCredential(credentialId) + if (!row) return null + + const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + if (!workspaceContext) return null + return { + ...workspaceContext, + credentialId: row.id, + credentialGroupId: row.credentialGroupId, + credentialGroupEnrollmentId: row.credentialGroupEnrollmentId, + } +} + +export interface ResolvedManagedApiKey { + /** Every credential value, keyed by the provider's field ids. */ + fields: Record + /** One bare-value ciphertext per secret field, for the run's trace registry. */ + provenanceEntries: ManagedApiKeyProvenanceEntry[] + credentialId: string + providerId: string + displayName: string + email: string | null +} + +/** + * Reads one managed API key after its caller has authorized the access. + * + * Entitlement is re-checked here rather than trusted from the caller, matching the managed + * OAuth path: a workspace that has lapsed off Enterprise stops being able to use credentials + * its Credential Groups collected, whichever surface asks. + */ +export async function resolveManagedApiKey(params: { + credentialId: string + workspaceId: string + expectedProviderId?: string +}): Promise { + const row = await getManagedApiKeyCredential(params.credentialId) + if (!row || row.workspaceId !== params.workspaceId) { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ) + } + if (params.expectedProviderId && row.providerId !== params.expectedProviderId) { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH', + 'Managed credential belongs to a different provider', + 403 + ) + } + if (row.managedOauthStatus === 'revoked') { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_REVOKED', + 'Managed credential has been revoked', + 401 + ) + } + if (row.managedOauthStatus !== 'active') { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be provided again', + 401 + ) + } + if (!row.encryptedApiKey) { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_UNUSABLE', + 'Managed credential has no stored key', + 409 + ) + } + + const billing = await getWorkspaceOwnerSubscriptionAccess(row.workspaceId) + if ( + !(await isCredentialGroupsAvailable({ workspaceId: row.workspaceId, ownerBilling: billing })) + ) { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_UNAVAILABLE', + 'Credential Groups are not available for this workspace', + 403 + ) + } + + const provider = getCredentialGroupProviderFromProviderId(row.providerId ?? '') + if (!isCredentialGroupApiKeyProvider(provider)) { + throw new ManagedApiKeyCredentialError( + 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH', + 'Managed credential does not belong to an API key provider', + 403 + ) + } + const { fields, provenanceEntries } = await openManagedApiKeySecret( + { encryptedApiKey: row.encryptedApiKey }, + getCredentialGroupApiKeyFields(provider) + ) + return { + fields, + provenanceEntries, + credentialId: row.id, + providerId: row.providerId ?? '', + displayName: row.displayName, + email: row.providerMetadata?.email ?? null, + } +} diff --git a/apps/sim/lib/credentials/managed-api-key.test.ts b/apps/sim/lib/credentials/managed-api-key.test.ts new file mode 100644 index 00000000000..29d07d95bc1 --- /dev/null +++ b/apps/sim/lib/credentials/managed-api-key.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEncryptSecret, mockDecryptSecret } = vi.hoisted(() => ({ + mockEncryptSecret: vi.fn(), + mockDecryptSecret: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: mockEncryptSecret, + decryptSecret: mockDecryptSecret, +})) + +import type { CredentialGroupApiKeyField } from '@/lib/credential-groups/providers' +import { + MAX_MANAGED_API_KEY_LENGTH, + ManagedApiKeyFormatError, + MIN_MANAGED_API_KEY_LENGTH, + openManagedApiKeySecret, + requireStorableManagedApiKeyFields, + sealManagedApiKey, +} from '@/lib/credentials/managed-api-key' + +const singleField: CredentialGroupApiKeyField[] = [ + { id: 'apiKey', label: 'API key', placeholder: '', secret: true }, +] + +const twoSecretFields: CredentialGroupApiKeyField[] = [ + { id: 'accessKey', label: 'Access key', placeholder: '', secret: true }, + { id: 'accessKeySecret', label: 'Access key secret', placeholder: '', secret: true }, +] + +const mixedFields: CredentialGroupApiKeyField[] = [ + { id: 'apiKey', label: 'API key', placeholder: '', secret: true }, + { id: 'subdomain', label: 'Subdomain', placeholder: '', secret: false }, +] + +describe('managed API key envelope', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEncryptSecret.mockImplementation(async (value: string) => ({ + encrypted: `enc(${value})`, + iv: 'iv', + })) + }) + + describe('requireStorableManagedApiKeyFields', () => { + it('trims every declared field', () => { + expect( + requireStorableManagedApiKeyFields(twoSecretFields, { + accessKey: ' key-value-1 ', + accessKeySecret: ' secret-value-1 ', + }) + ).toEqual({ accessKey: 'key-value-1', accessKeySecret: 'secret-value-1' }) + }) + + it('rejects a missing field rather than storing a partial credential', () => { + expect(() => + requireStorableManagedApiKeyFields(twoSecretFields, { accessKey: 'key-value-1' }) + ).toThrow(ManagedApiKeyFormatError) + }) + + it('rejects an undeclared field rather than dropping it', () => { + expect(() => + requireStorableManagedApiKeyFields(singleField, { apiKey: 'key-value-1', rogue: 'value' }) + ).toThrow(/Unexpected field rogue/) + }) + + it('holds each secret field to the redaction floor', () => { + expect(() => + requireStorableManagedApiKeyFields(twoSecretFields, { + accessKey: 'a'.repeat(MIN_MANAGED_API_KEY_LENGTH), + accessKeySecret: 'a'.repeat(MIN_MANAGED_API_KEY_LENGTH - 1), + }) + ).toThrow(/at least/) + }) + + /** + * A subdomain is short and recurs in ordinary log lines, so it is never catalogued for + * redaction — the floor that exists to keep unredactable secrets out would only reject + * valid input here. + */ + it('exempts a non-secret field from the redaction floor', () => { + expect( + requireStorableManagedApiKeyFields(mixedFields, { + apiKey: 'a'.repeat(MIN_MANAGED_API_KEY_LENGTH), + subdomain: 'acme', + }) + ).toEqual({ apiKey: 'a'.repeat(MIN_MANAGED_API_KEY_LENGTH), subdomain: 'acme' }) + }) + + it('still requires a non-secret field to be present', () => { + expect(() => + requireStorableManagedApiKeyFields(mixedFields, { + apiKey: 'a'.repeat(MIN_MANAGED_API_KEY_LENGTH), + subdomain: ' ', + }) + ).toThrow(/Subdomain is required/) + }) + + it('rejects a value past the ceiling', () => { + expect(() => + requireStorableManagedApiKeyFields(singleField, { + apiKey: 'a'.repeat(MAX_MANAGED_API_KEY_LENGTH + 1), + }) + ).toThrow(ManagedApiKeyFormatError) + }) + }) + + it('seals every field inside one versioned envelope', async () => { + await sealManagedApiKey({ accessKey: 'key-1', accessKeySecret: 'secret-1' }) + expect(mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ + type: 'managed-api-key', + version: 1, + fields: { accessKey: 'key-1', accessKeySecret: 'secret-1' }, + }) + ) + }) + + /** + * The whole reason `openManagedApiKeySecret` exists: the trace registry catalogs whatever a + * ciphertext decrypts to and redacts exactly that literal. A credential with two secrets needs + * two entries — the envelope ciphertext would catalog the JSON document and redact neither. + */ + it('returns one bare-value ciphertext per secret field', async () => { + mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-api-key', + version: 1, + fields: { accessKey: 'key-1', accessKeySecret: 'secret-1' }, + }), + }) + + const opened = await openManagedApiKeySecret({ encryptedApiKey: 'enc(env)' }, twoSecretFields) + + expect(opened.fields).toEqual({ accessKey: 'key-1', accessKeySecret: 'secret-1' }) + expect(opened.provenanceEntries).toEqual([ + { name: 'accessKey', encryptedValue: 'enc(key-1)' }, + { name: 'accessKeySecret', encryptedValue: 'enc(secret-1)' }, + ]) + for (const entry of opened.provenanceEntries) { + expect(entry.encryptedValue).not.toContain('managed-api-key') + } + }) + + it('never catalogs a non-secret field for redaction', async () => { + mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-api-key', + version: 1, + fields: { apiKey: 'key-1', subdomain: 'acme' }, + }), + }) + + const opened = await openManagedApiKeySecret({ encryptedApiKey: 'enc(env)' }, mixedFields) + + expect(opened.fields.subdomain).toBe('acme') + expect(opened.provenanceEntries).toEqual([{ name: 'apiKey', encryptedValue: 'enc(key-1)' }]) + }) + + it('rejects a payload that is not an envelope', async () => { + mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify({ apiKey: 'key-1' }) }) + await expect(openManagedApiKeySecret({ encryptedApiKey: 'x' }, singleField)).rejects.toThrow( + ManagedApiKeyFormatError + ) + }) + + it('rejects a payload that is not JSON', async () => { + mockDecryptSecret.mockResolvedValue({ decrypted: 'key-1' }) + await expect(openManagedApiKeySecret({ encryptedApiKey: 'x' }, singleField)).rejects.toThrow( + ManagedApiKeyFormatError + ) + }) + + it('rejects an envelope from a future version', async () => { + mockDecryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-api-key', + version: 2, + fields: { apiKey: 'key-1' }, + }), + }) + await expect(openManagedApiKeySecret({ encryptedApiKey: 'x' }, singleField)).rejects.toThrow( + ManagedApiKeyFormatError + ) + }) +}) diff --git a/apps/sim/lib/credentials/managed-api-key.ts b/apps/sim/lib/credentials/managed-api-key.ts new file mode 100644 index 00000000000..7ae05825cc8 --- /dev/null +++ b/apps/sim/lib/credentials/managed-api-key.ts @@ -0,0 +1,162 @@ +import type { ManagedApiKeyEnvelope } from '@sim/db/schema' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { CredentialGroupApiKeyField } from '@/lib/credential-groups/providers' + +const MANAGED_API_KEY_ENVELOPE_TYPE = 'managed-api-key' as const +const MANAGED_API_KEY_ENVELOPE_VERSION = 1 as const + +/** + * Shortest secret value we will store. + * + * `MIN_SUBSTITUTABLE_LITERAL_LENGTH` in the resolved-secret match policy is the length below + * which a literal is deliberately never redacted, because a match on it is not evidence the + * secret is present. A shorter value would therefore be stored as something we cannot keep out + * of logs or model-visible content, so it is refused at collection instead. + * + * Applies only to fields declared `secret`. A non-secret field (a subdomain, a region) is never + * catalogued for redaction, so the floor is meaningless for it and would just reject valid input. + */ +export const MIN_MANAGED_API_KEY_LENGTH = 8 + +/** Longest value we will store, well past any real credential, to bound the ciphertext. */ +export const MAX_MANAGED_API_KEY_LENGTH = 4096 + +/** Shortest non-secret value, which only has to be non-empty. */ +const MIN_MANAGED_API_KEY_NON_SECRET_LENGTH = 1 + +export class ManagedApiKeyFormatError extends Error { + constructor(message: string) { + super(message) + this.name = 'ManagedApiKeyFormatError' + } +} + +function isManagedApiKeyEnvelope(value: unknown): value is ManagedApiKeyEnvelope { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + if ( + candidate.type !== MANAGED_API_KEY_ENVELOPE_TYPE || + candidate.version !== MANAGED_API_KEY_ENVELOPE_VERSION || + typeof candidate.fields !== 'object' || + candidate.fields === null + ) { + return false + } + const entries = Object.entries(candidate.fields) + return entries.length > 0 && entries.every(([, value]) => typeof value === 'string' && value) +} + +/** + * Validates and trims every value an invited person supplied, against the fields the provider + * declares. Unknown or missing fields are rejected rather than dropped, so a provider whose + * field list changed cannot silently store a partial credential. + */ +export function requireStorableManagedApiKeyFields( + fields: readonly CredentialGroupApiKeyField[], + submitted: Record +): Record { + const declared = new Set(fields.map((field) => field.id)) + for (const key of Object.keys(submitted)) { + if (!declared.has(key)) { + throw new ManagedApiKeyFormatError(`Unexpected field ${key}`) + } + } + + const result: Record = {} + for (const field of fields) { + const raw = submitted[field.id] + if (typeof raw !== 'string') { + throw new ManagedApiKeyFormatError(`${field.label} is required`) + } + const trimmed = raw.trim() + const minimum = field.secret + ? MIN_MANAGED_API_KEY_LENGTH + : MIN_MANAGED_API_KEY_NON_SECRET_LENGTH + if (trimmed.length < minimum) { + throw new ManagedApiKeyFormatError( + field.secret + ? `${field.label} must be at least ${MIN_MANAGED_API_KEY_LENGTH} characters` + : `${field.label} is required` + ) + } + if (trimmed.length > MAX_MANAGED_API_KEY_LENGTH) { + throw new ManagedApiKeyFormatError( + `${field.label} must be at most ${MAX_MANAGED_API_KEY_LENGTH} characters` + ) + } + result[field.id] = trimmed + } + return result +} + +/** + * Encrypts verified credential fields for `credential.encryptedApiKey`. + * + * `encryptSecret`, never `encryptApiKey`: the resolved-secret trace registry decrypts with + * `decryptSecret`, and `encryptApiKey` silently stores plaintext when `API_ENCRYPTION_KEY` + * is unset. + */ +export async function sealManagedApiKey(fields: Record): Promise { + if (Object.keys(fields).length === 0) { + throw new ManagedApiKeyFormatError('Managed API key envelope requires at least one field') + } + const envelope: ManagedApiKeyEnvelope = { + type: MANAGED_API_KEY_ENVELOPE_TYPE, + version: MANAGED_API_KEY_ENVELOPE_VERSION, + fields, + } + const { encrypted } = await encryptSecret(JSON.stringify(envelope)) + return encrypted +} + +export interface ManagedApiKeyProvenanceEntry { + /** Catalog name; scoped per field so two secrets on one credential stay distinguishable. */ + name: string + encryptedValue: string +} + +export interface OpenedManagedApiKey { + fields: Record + /** + * One entry per **secret** field, each the `encryptSecret` of that bare value. + * + * The trace registry catalogs whatever a ciphertext decrypts to and redacts exactly that + * literal, so a credential with two secrets needs two entries — the envelope ciphertext + * would catalog the JSON document and redact neither value. + */ + provenanceEntries: ManagedApiKeyProvenanceEntry[] +} + +/** + * The only way to read a managed API key. + * + * Returns the plaintext fields together with the per-secret ciphertexts the trace registry must + * adopt, so no caller has to know that the at-rest form and the catalog form differ. + */ +export async function openManagedApiKeySecret( + row: { encryptedApiKey: string }, + declaredFields: readonly CredentialGroupApiKeyField[] +): Promise { + const { decrypted } = await decryptSecret(row.encryptedApiKey) + let parsed: unknown + try { + parsed = JSON.parse(decrypted) + } catch { + throw new ManagedApiKeyFormatError('Managed API key envelope is not valid JSON') + } + if (!isManagedApiKeyEnvelope(parsed)) { + throw new ManagedApiKeyFormatError('Invalid managed API key envelope') + } + + const secretFieldIds = new Set( + declaredFields.filter((field) => field.secret).map((field) => field.id) + ) + const provenanceEntries: ManagedApiKeyProvenanceEntry[] = [] + for (const [fieldId, value] of Object.entries(parsed.fields)) { + if (!secretFieldIds.has(fieldId)) continue + const { encrypted } = await encryptSecret(value) + provenanceEntries.push({ name: fieldId, encryptedValue: encrypted }) + } + + return { fields: parsed.fields, provenanceEntries } +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 85da54723a7..17b5df9c9bc 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -337,6 +337,7 @@ export interface PostHogEventMap { credential_type: | 'oauth' | 'managed_oauth' + | 'managed_api_key' | 'env_workspace' | 'env_personal' | 'service_account' @@ -348,6 +349,7 @@ export interface PostHogEventMap { credential_type: | 'oauth' | 'managed_oauth' + | 'managed_api_key' | 'env_workspace' | 'env_personal' | 'service_account' @@ -359,6 +361,7 @@ export interface PostHogEventMap { credential_type: | 'oauth' | 'managed_oauth' + | 'managed_api_key' | 'env_workspace' | 'env_personal' | 'service_account' @@ -370,6 +373,7 @@ export interface PostHogEventMap { credential_type: | 'oauth' | 'managed_oauth' + | 'managed_api_key' | 'env_workspace' | 'env_personal' | 'service_account' @@ -794,6 +798,7 @@ export interface PostHogEventMap { credential_type: | 'oauth' | 'managed_oauth' + | 'managed_api_key' | 'env_workspace' | 'env_personal' | 'service_account' diff --git a/packages/db/migrations/0315_credential_managed_api_key_type.sql b/packages/db/migrations/0315_credential_managed_api_key_type.sql new file mode 100644 index 00000000000..90bf33fb693 --- /dev/null +++ b/packages/db/migrations/0315_credential_managed_api_key_type.sql @@ -0,0 +1,8 @@ +-- Adds the `managed_api_key` credential type, for Credential Group options that collect an +-- API key from each invited person instead of an OAuth grant. Purely additive: no existing +-- row changes type, and every type-discriminated read path filters explicitly. +-- +-- Postgres cannot use a new enum value in the same transaction that adds it, so this must be +-- released BEFORE any code writes it, and separately from 0313 which adds the column and the +-- constraints that describe such a row. +ALTER TYPE "public"."credential_type" ADD VALUE IF NOT EXISTS 'managed_api_key'; diff --git a/packages/db/migrations/0316_credential_managed_api_key_storage.sql b/packages/db/migrations/0316_credential_managed_api_key_storage.sql new file mode 100644 index 00000000000..0ca12ca79aa --- /dev/null +++ b/packages/db/migrations/0316_credential_managed_api_key_storage.sql @@ -0,0 +1,54 @@ +-- Storage for `managed_api_key` credentials (enum value added in 0315). +-- +-- Pure expand. The column is nullable, the new constraint is vacuously true for every +-- existing row, and the widened partial index covers a strict superset of what it covered +-- before. Deployed code that predates this migration ignores both. +-- +-- The check constraint compares `type::text`, matching the two existing `managed_oauth` +-- constraints: a text comparison does not reference the enum label, so it stays valid +-- regardless of transaction boundaries around 0315. +-- +-- The index predicate must NOT do that. An enum-to-text cast is STABLE rather than IMMUTABLE +-- (labels can be renamed), and Postgres rejects a non-immutable expression in an index +-- predicate with `functions in index predicate must be marked IMMUTABLE`. It compares the enum +-- values directly instead, which is safe here because 0315 committed the new label in an +-- earlier migration. +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "encrypted_api_key" text; +--> statement-breakpoint +-- Added NOT VALID so it never takes a validating lock against live writes; no stored row can +-- violate it (none is `managed_api_key` yet), so the VALIDATE below is a formality that takes +-- only a SHARE UPDATE EXCLUSIVE lock. +ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_api_key_source_check" CHECK ((type::text <> 'managed_api_key') OR ( + encrypted_api_key IS NOT NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NOT NULL + AND provider_id IS NOT NULL + AND provider_subject_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND granted_at IS NOT NULL + AND account_id IS NULL + AND encrypted_oauth_token_set IS NULL + AND encrypted_service_account_key IS NULL + AND granted_scopes IS NULL + AND authorization_app_id IS NULL + AND managed_oauth_scope_version IS NULL + AND access_token_expires_at IS NULL + AND refresh_token_expires_at IS NULL + AND last_refreshed_at IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND unredacted = false + )) NOT VALID; +--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_managed_api_key_source_check"; +--> statement-breakpoint +-- Concurrent index operations cannot run inside the migration runner's transaction. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- migration-safe: the replacement predicate is strictly wider than the one it replaces +-- (`managed_oauth` plus `managed_api_key`), so every pair it must keep unique was already +-- unique under the old index and no `managed_api_key` row exists yet. Both operations are +-- concurrent, so writers are never blocked and a replay simply rebuilds an invalid index. +DROP INDEX CONCURRENTLY IF EXISTS "credential_group_option_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_option_unique" ON "credential" USING btree ("credential_group_enrollment_id","credential_group_option_id") WHERE type IN ('managed_oauth', 'managed_api_key');--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index b0baf0d54d6..5e5e0cdf94a 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2199,6 +2199,20 @@ "when": 1788208209301, "tag": "0314_superb_daimon_hellstrom", "breakpoints": true + }, + { + "idx": 315, + "version": "7", + "when": 1788208210301, + "tag": "0315_credential_managed_api_key_type", + "breakpoints": true + }, + { + "idx": 316, + "version": "7", + "when": 1788208211301, + "tag": "0316_credential_managed_api_key_storage", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index b8e07056576..1c3b16252c8 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4135,6 +4135,7 @@ export const usageLog = pgTable( export const credentialTypeEnum = pgEnum('credential_type', [ 'oauth', 'managed_oauth', + 'managed_api_key', 'env_workspace', 'env_personal', 'service_account', @@ -4146,14 +4147,37 @@ export const managedOauthCredentialStatusEnum = pgEnum('managed_oauth_credential 'revoked', ]) -export interface ManagedOAuthProviderMetadata { - email: string +/** + * Identity a managed credential's provider reported at grant time. + * + * `email` is optional because only some grants can prove one. An OAuth grant always + * carries the consenting account's address, which the enrollment flow matches against the + * invitation. An API key is verified through whatever identity endpoint the service + * offers, and not every service names the key's owner — see + * `CredentialGroupApiKeyVerification`, which makes that distinction explicit rather than + * letting a missing address pass unremarked. + */ +export interface ManagedCredentialProviderMetadata { + email?: string displayName?: string avatarUrl?: string username?: string tenantDisplayName?: string } +/** + * Plaintext shape behind `credential.encryptedApiKey`. + * + * `fields` is keyed by the field ids the provider declares, so a service authenticating with + * more than one value (Gong's access key and secret) needs no separate shape. Versioned so a + * future change to that contract can be told apart from a corrupt payload. + */ +export interface ManagedApiKeyEnvelope { + type: 'managed-api-key' + version: 1 + fields: Record +} + export const credential = pgTable( 'credential', { @@ -4187,8 +4211,23 @@ export const credential = pgTable( providerTenantId: text('provider_tenant_id'), managedOauthStatus: managedOauthCredentialStatusEnum('managed_oauth_status'), grantedScopes: text('granted_scopes').array(), - providerMetadata: jsonb('provider_metadata').$type(), + providerMetadata: jsonb('provider_metadata').$type(), encryptedOauthTokenSet: text('encrypted_oauth_token_set'), + /** + * `encryptSecret` of a {@link ManagedApiKeyEnvelope} JSON document, for + * `type = 'managed_api_key'`. + * + * Read it only through `openManagedApiKeySecret`. The resolved-secret trace registry + * catalogs whatever a ciphertext decrypts to and redacts exactly that literal, so the + * envelope ciphertext must never reach `importProvenance` — it would catalog the JSON + * document and leave the key itself unredacted. That helper returns the bare-key + * ciphertext the registry needs alongside the plaintext. + * + * Encrypted with `encryptSecret` (`ENCRYPTION_KEY`), never `encryptApiKey` + * (`API_ENCRYPTION_KEY`): the registry decrypts with `decryptSecret`, and `encryptApiKey` + * silently stores plaintext when its key is unset. + */ + encryptedApiKey: text('encrypted_api_key'), grantedAt: timestamp('granted_at'), revokedAt: timestamp('revoked_at'), accessTokenExpiresAt: timestamp('access_token_expires_at'), @@ -4211,7 +4250,7 @@ export const credential = pgTable( ), credentialGroupOptionUnique: uniqueIndex('credential_group_option_unique') .on(table.credentialGroupEnrollmentId, table.credentialGroupOptionId) - .where(sql`${table.type} = 'managed_oauth'`), + .where(sql`${table.type} IN ('managed_oauth', 'managed_api_key')`), workspaceAccountUnique: uniqueIndex('credential_workspace_account_unique') .on(table.workspaceId, table.accountId) .where(sql`account_id IS NOT NULL`), @@ -4248,6 +4287,30 @@ export const credential = pgTable( AND managed_oauth_scope_version > 0 )` ), + managedApiKeySourceConstraint: check( + 'credential_managed_api_key_source_check', + sql`(type::text <> 'managed_api_key') OR ( + encrypted_api_key IS NOT NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NOT NULL + AND provider_id IS NOT NULL + AND provider_subject_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND granted_at IS NOT NULL + AND account_id IS NULL + AND encrypted_oauth_token_set IS NULL + AND encrypted_service_account_key IS NULL + AND granted_scopes IS NULL + AND authorization_app_id IS NULL + AND managed_oauth_scope_version IS NULL + AND access_token_expires_at IS NULL + AND refresh_token_expires_at IS NULL + AND last_refreshed_at IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND unredacted = false + )` + ), workspaceEnvSourceConstraint: check( 'credential_workspace_env_source_check', sql`(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)` @@ -4261,16 +4324,42 @@ export const credential = pgTable( export const credentialGroupStatusEnum = pgEnum('credential_group_status', ['active', 'disabled']) -export interface CredentialGroupOptionConfig { +interface CredentialGroupOptionConfigBase { id: string provider: string label: string + required: boolean + status: 'active' | 'disabled' +} + +/** An option enrolled through an OAuth adapter, carrying that adapter's scope policy. */ +export interface CredentialGroupOAuthOptionConfig extends CredentialGroupOptionConfigBase { + kind?: undefined slackBotCredentialId?: string authorizationAppId: string requiredScopes: string[] scopeVersion: number - required: boolean - status: 'active' | 'disabled' +} + +/** + * An option enrolled by pasting an API key. + * + * Discriminated by an explicit `kind` rather than by the absence of the OAuth fields, so a + * malformed row is a parse failure instead of silently reading as the other member. Existing + * OAuth options predate the field and leave it undefined. + */ +export interface CredentialGroupApiKeyOptionConfig extends CredentialGroupOptionConfigBase { + kind: 'api_key' +} + +export type CredentialGroupOptionConfig = + | CredentialGroupOAuthOptionConfig + | CredentialGroupApiKeyOptionConfig + +export function isCredentialGroupApiKeyOptionConfig( + option: CredentialGroupOptionConfig +): option is CredentialGroupApiKeyOptionConfig { + return option.kind === 'api_key' } /** Workspace-owned configuration for collecting several managed OAuth credentials. */ diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 025fa9951b3..ca3fb01f587 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1189,11 +1189,13 @@ export const schemaMock = { enumValues: [ 'oauth', 'managed_oauth', + 'managed_api_key', 'env_workspace', 'env_personal', 'service_account', ] as const, }, + isCredentialGroupApiKeyOptionConfig: (option: { kind?: string }) => option.kind === 'api_key', managedOauthCredentialStatusEnum: { enumValues: ['active', 'needs_reauth', 'revoked'] as const, },