Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/docs/content/docs/integrations/greenhouse.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ Use [Greenhouse](https://www.greenhouse.com/) to manage recruiting through Harve

### Connect a Greenhouse account

The current block uses [Greenhouse partner OAuth](https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth). Deployment administrators must obtain partner-issued OAuth credentials, register the deployment's Greenhouse OAuth callback URL, and have Greenhouse enable the scopes requested by Sim. This requires Greenhouse partner onboarding; a legacy Harvest API key cannot authenticate the new block. Select a Greenhouse account in the block and complete consent with a Site Admin, which Greenhouse requires for list endpoints. A connection that has not refreshed within the provider's 14-day refresh-token lifetime must reconnect.
Save your own Greenhouse API credential, then select it in the block. In Greenhouse, open **Dev Center > API Credentials**, create a **Harvest V3 (OAuth)** credential, and copy its client ID and client secret. On the credential's settings, enable the Harvest scopes for the operations your workflows use; the scopes listed below cover every operation. Sim obtains access tokens with the [client credentials grant](https://harvestdocs.greenhouse.io/docs/authentication) and gets a new one when it expires. Requests act as the integration service user Greenhouse creates with the credential. You do not need a redirect URI or a Sim-owned OAuth application, and a legacy Harvest API key cannot authenticate the new block.

Deployments that have [Greenhouse partner OAuth](https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth) credentials can also connect an account through consent. Complete consent with a Site Admin, which Greenhouse requires for list endpoints. A partner connection that has not refreshed within the provider's 14-day refresh-token lifetime must reconnect.

### Upgrade existing workflows

The original API-key block remains as **Greenhouse (Legacy)** so existing workflows keep their saved configuration. New blocks use Harvest v3. Upgrade an existing block explicitly, connect an OAuth account, choose its operation again, and update input and output references before running it. The legacy integration is subject to Greenhouse's v1 availability; preserving a saved block does not extend provider support.
The original API-key block remains as **Greenhouse (Legacy)** so existing workflows keep their saved configuration. New blocks use Harvest v3. Upgrade an existing block explicitly, select a Greenhouse credential, choose its operation again, and update input and output references before running it. The legacy integration is subject to Greenhouse's v1 availability; preserving a saved block does not extend provider support.

Harvest v3 returns applications, attachments, education, and employment through separate resources. Use their list operations and candidate or application filters to assemble a complete record. Single-record reads use the provider's `ids` filter. Do not assume the old embedded response structure survives the upgrade.

Expand Down
1 change: 1 addition & 0 deletions apps/sim/blocks/blocks/greenhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ export const GreenhouseV2Block: BlockConfig = {
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
serviceId: 'greenhouse',
credentialKind: 'any',
requiredScopes: getScopesForService('greenhouse'),
placeholder: 'Select Greenhouse account',
required: true,
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/lib/credentials/client-credential-accounts/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID = 'fivetran-service-account' a
export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const
export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const
export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const
export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-account' as const

/** Permissions requested when connecting a Manage Vanta application. */
export const VANTA_PERMISSION_OPTIONS = [
Expand All @@ -141,6 +142,7 @@ export type ClientCredentialAccountProviderId =
| typeof FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID
| typeof GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID

/**
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
Expand Down Expand Up @@ -400,6 +402,26 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
],
docsUrl: 'https://docs.sim.ai/integrations/ramp',
},
[GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID,
serviceLabel: 'Greenhouse',
connectNoun: 'API credential',
fields: [
{
id: 'clientId',
label: 'Client ID',
placeholder: 'Paste the client ID',
secret: false,
},
{
id: 'clientSecret',
label: 'Client secret',
placeholder: 'Paste the client secret',
secret: true,
},
],
docsUrl: 'https://docs.sim.ai/integrations/greenhouse',
},
[VANTA_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: VANTA_SERVICE_ACCOUNT_PROVIDER_ID,
serviceLabel: 'Vanta',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { jsonResponse } from '@sim/testing/helpers/http'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse'

const FIELDS = { clientId: 'greenhouse-client', clientSecret: 'greenhouse-secret', orgId: '' }

const mockFetch = vi.fn<typeof fetch>()

describe('Greenhouse client credential exchange', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.stubGlobal('fetch', mockFetch)
})

it('exchanges the credential with a scope-free client credentials grant', async () => {
const requests: Request[] = []
mockFetch.mockImplementation(async (input, init) => {
const request = new Request(input, init)
requests.push(request)
expect(request.url).toBe('https://auth.greenhouse.io/token')
expect(request.method).toBe('POST')
expect(request.redirect).toBe('error')
const [scheme, encodedCredentials = ''] = (request.headers.get('authorization') ?? '').split(
' '
)
expect(scheme).toBe('Basic')
expect(Buffer.from(encodedCredentials, 'base64').toString('utf8')).toBe(
'greenhouse-client:greenhouse-secret'
)
expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded')
expect([...new URLSearchParams(await request.text())]).toEqual([
['grant_type', 'client_credentials'],
])
return jsonResponse({
access_token: 'greenhouse-access',
token_type: 'Bearer',
expires_in: 3600,
})
})

const result = await mintGreenhouseServiceAccountToken(FIELDS)

expect(requests).toHaveLength(1)
expect(result).toMatchObject({ accessToken: 'greenhouse-access', expiresInSeconds: 3600 })
expect(result.identity).toMatchObject({
principal: null,
auditMetadata: { greenhouseClientId: 'greenhouse-client' },
})
expect(JSON.stringify(result.identity)).not.toContain(FIELDS.clientSecret)
expect(JSON.stringify(result.identity)).not.toContain('greenhouse-access')
})

it.each([
[401, 'invalid_credentials'],
[403, 'invalid_credentials'],
[429, 'provider_unavailable'],
[503, 'provider_unavailable'],
])('maps an HTTP %i token response to %s', async (status, code) => {
mockFetch.mockResolvedValue(jsonResponse({ error: 'denied' }, { status }))

await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status })
})

it.each([
['no access token', { token_type: 'Bearer', expires_in: 3600 }],
['a blank access token', { access_token: ' ', expires_in: 3600 }],
['no lifetime', { access_token: 'greenhouse-access' }],
['a non-positive lifetime', { access_token: 'greenhouse-access', expires_in: 0 }],
['a fractional lifetime', { access_token: 'greenhouse-access', expires_in: 1.5 }],
])('refuses a token response with %s', async (_label, body) => {
mockFetch.mockResolvedValue(jsonResponse(body))

await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 502,
})
})

it('refuses an oversized token response', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ access_token: 'x'.repeat(70 * 1024), expires_in: 3600 }), {
headers: { 'Content-Type': 'application/json' },
})
)

await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 502,
})
})

it('never sends a request without a client secret', async () => {
await expect(
mintGreenhouseServiceAccountToken({ ...FIELDS, clientSecret: undefined })
).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 })
expect(mockFetch).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { toRecord } from '@sim/utils/object'
Comment thread
waleedlatif1 marked this conversation as resolved.
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import type {
ClientCredentialAccountFields,
ClientCredentialAccountMintOptions,
ClientCredentialAccountMintResult,
} from '@/lib/credentials/client-credential-accounts/server'
import {
fetchProvider,
requireClientSecret,
TokenServiceAccountValidationError,
throwForProviderResponse,
} from '@/lib/credentials/token-service-accounts/errors'
import { GREENHOUSE_TOKEN_URL } from '@/lib/oauth/greenhouse'

const STEP = 'greenhouse_token_mint'
const MAX_GREENHOUSE_TOKEN_RESPONSE_BYTES = 64 * 1024

/**
* Obtains a Harvest v3 access token for a customer's own Greenhouse API credential through the
* client-credentials grant. The token acts as the credential's integration service user with the
* scopes configured on the credential in Greenhouse, so the grant requests none. Greenhouse
* documents no identity endpoint for this grant; a successful mint is the connect-time check.
*
* @see https://harvestdocs.greenhouse.io/docs/authentication
*/
export async function mintGreenhouseServiceAccountToken(
fields: ClientCredentialAccountFields,
options?: ClientCredentialAccountMintOptions
): Promise<ClientCredentialAccountMintResult> {
Comment thread
waleedlatif1 marked this conversation as resolved.
options?.signal?.throwIfAborted()
const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'Greenhouse')
const response = await fetchProvider(
GREENHOUSE_TOKEN_URL,
{
method: 'POST',
redirect: 'error',
signal: options?.signal,
headers: {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${fields.clientId}:${clientSecret}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(),
},
STEP
)
await throwForProviderResponse(response, STEP)

let token: Record<string, unknown>
try {
token = toRecord(
await readResponseJsonWithLimit(response, {
maxBytes: MAX_GREENHOUSE_TOKEN_RESPONSE_BYTES,
label: 'Greenhouse token response',
signal: options?.signal,
})
)
} catch {
options?.signal?.throwIfAborted()
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
step: STEP,
reason: 'provider returned an invalid or oversized response',
})
}

if (
typeof token.access_token !== 'string' ||
!token.access_token.trim() ||
typeof token.expires_in !== 'number' ||
!Number.isSafeInteger(token.expires_in) ||
token.expires_in <= 0
) {
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
step: STEP,
reason: 'token response missing a valid access token or lifetime',
})
}

return {
accessToken: token.access_token,
expiresInSeconds: token.expires_in,
identity: {
displayName: 'Greenhouse API credential',
principal: null,
auditMetadata: { greenhouseClientId: fields.clientId },
},
}
}
3 changes: 3 additions & 0 deletions apps/sim/lib/credentials/client-credential-accounts/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
type ClientCredentialAccountProviderId,
FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID,
GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID,
getClientCredentialAccountDescriptor,
isClientCredentialAccountProviderId,
NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID,
Expand All @@ -17,6 +18,7 @@ import {
} from '@/lib/credentials/client-credential-accounts/descriptors'
import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran'
import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse'
import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite'
import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm'
import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion'
Expand Down Expand Up @@ -148,6 +150,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
[FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID]: mintFivetranServiceAccountToken,
[ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleFusionServiceAccountToken,
[ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleEpmServiceAccountToken,
[GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: mintGreenhouseServiceAccountToken,
}

export function getClientCredentialAccountMinter(
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/integrations/credential-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
'google-tasks',
'google-vault',
],
'greenhouse-service-account': ['greenhouse'],
'harmonic-service-account': [],
'hubspot-service-account': ['hubspot'],
'linear-service-account': ['linear'],
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
name: 'Greenhouse',
description: 'Manage recruiting candidates, applications, jobs, and hiring activity.',
providerId: 'greenhouse',
serviceAccountProviderId: 'greenhouse-service-account',
icon: GreenhouseIcon,
baseProviderIcon: GreenhouseIcon,
scopes: [
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/oauth/token-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ describe('ordinary tool credential binding', () => {
['oracle-fusion', 'oracle-fusion-service-account'],
['vanta', 'vanta-service-account'],
['ramp', 'ramp-service-account'],
['greenhouse', 'greenhouse-service-account'],
['google-email', 'google-service-account'],
['jira', 'atlassian-service-account'],
['confluence', 'atlassian-service-account'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID = {
'google-sheets': 'google-service-account',
'google-tasks': 'google-service-account',
'google-vault': 'google-service-account',
greenhouse: 'greenhouse-service-account',
hubspot: 'hubspot-service-account',
jira: 'atlassian-service-account',
linear: 'linear-service-account',
Expand Down
Loading