Skip to content

Commit 0337ccd

Browse files
TheodoreSpeakswaleedlatif1Sg312icecrasher321
authored
feat(credentials): add Atlassian service account credentials (#4432)
* v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li <theo@sim.ai> * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * feat(credentials): add Atlassian service account credentials * improvement(credentials): tighten Atlassian service account plumbing - Collapse fetchOAuthTokenBundle into fetchOAuthToken (returns the bundle) - Reuse serviceAccountJsonSchema in the JSON form instead of hand-rolled checks - Use parseAtlassianErrorMessage for log details; drop one-line bearer helper - Extract ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID/_SECRET_TYPE constants - Use Drizzle .returning() instead of post-insert SELECT - Helper for the duplicated 401/403 + non-OK pattern in the validator * docs(credentials): add Atlassian service account setup guide - New /integrations/atlassian-service-account doc covers token creation, scope selection, and adding the credential to Sim - Form's "View setup guide" link now points at the doc - Fix the existing Google form link that pointed to the wrong path Screenshot TODOs left inline as MDX comments for the docs team. * docs(credentials): add Atlassian service account screenshots - Auth type picker, Sim add-credential modal, Jira block credential dropdown - Scope-picker screenshot still TODO * docs(credentials): add Atlassian scope picker screenshot * fix(credentials): address greptile feedback on Atlassian SA - Drop stale 'email and API token' copy from the service description (we only collect a token + domain, no email field) - Move duplicate display-name check inside the create transaction so concurrent POSTs can't both pass the check and insert duplicates * fix(docs): move Atlassian screenshots to docs/public Docs site serves /static/* from apps/docs/public, not apps/sim/public — matches the existing google-service-account screenshot convention. * fix(credentials): address review feedback on Atlassian SA - SSRF: only accept *.atlassian.net / *.jira-dev.com hosts before fetching tenant_info, blocking probes against localhost/internal IPs - Confluence spaces selector: pull cloudId from the SA secret instead of calling accessible-resources, which 401s for scoped service-account tokens - Case-insensitive https?:// strip so HTTPS://team.atlassian.net normalizes correctly * chore: merge staging and bump API validation route baseline to 727 * perf(credentials): single-resolve in confluence spaces selector Atlassian SAs were hitting resolveOAuthAccountId twice (once via refreshAccessTokenIfNeeded, once directly to read cloudId) and decrypting the secret twice (via getAtlassianServiceAccountToken inside refresh, then again via getAtlassianServiceAccountSecret). Resolve once up front and branch the whole flow on the result — SA path skips refresh entirely and pulls token+cloudId from a single secret read. * refactor(credentials): consolidate Atlassian SA creation into /api/credentials Atlassian service-account creation lived in its own route, contract, and mutation hook, copy-pasting ~140 lines of insert/membership/audit/posthog boilerplate from /api/credentials. Two endpoints means two authz paths, two audit shapes, two TOCTOU stories — they will drift. Fold Atlassian into the existing service_account branch of /api/credentials, dispatching by providerId. The Atlassian validator (tenant_info + Bearer /myself, SSRF host allowlist, typed error codes) lives in lib/credentials/atlassian-service-account.ts and is the only Atlassian- specific piece left. AtlassianValidationError maps to a {code, error} 400 in the existing catch block; the rest of the flow (transaction, members, audit, posthog, dup-check) is now shared with Google SA + env credentials. Delete: - /api/auth/atlassian-service-account route - contracts/atlassian-service-account.ts + barrel export - useCreateAtlassianServiceAccount hook - API audit baseline 727 → 726 Both forms (Google JSON-key, Atlassian token+domain) now call useCreateWorkspaceCredential with the appropriate body shape. * fix(credentials): close TOCTOU and restore typed errors after consolidation - Add inner duplicate-guard inside the create transaction (DuplicateCredentialError) to close the race that the outer findExistingCredentialBySource leaves open. service_account rows have no DB-level unique index on (workspaceId, providerId, displayName), so this is the actual safety net. Tx-internal check applies to Google + env_workspace too — race-safety win for all credential types. - Re-emit {code: 'duplicate_display_name', error: ...} on conflict so the form's ERROR_MESSAGES.duplicate_display_name mapping is reachable again. - Thread Atlassian-specific audit metadata (atlassianDomain, atlassianCloudId) back into recordAudit; consolidation had dropped them. - Use ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID constant in contract superRefine. - Drop `error: any` in catch in favor of `error: unknown` + getPostgresErrorCode. * chore(credentials): drop dead createWorkspaceCredentialBodySchema + updateWorkspaceCredentialBodySchema Both shadowed the actually-used schemas (createCredentialBodySchema / updateCredentialByIdBodySchema) and were missing the apiToken/domain Atlassian fields. A future change could pick the wrong one and silently drop those fields. Confirmed zero non-definition references in the repo (grep across apps/, packages/, scripts/ minus build artifacts). * fix(credentials): scope inner duplicate re-check to service_account OAuth dedupes by accountId, env_* by envKey — both have DB-level partial unique indexes that surface as 23505. The previous inner re-check fired for all types and always threw DuplicateCredentialError, which mapped to 'duplicate_display_name' in the UI even when the real conflict was a duplicate OAuth account or env key. Restrict the in-tx re-check to service_account (the only type without a DB-level index) and let the 23505 handler emit a generic message for everything else. --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
1 parent c09e0a0 commit 0337ccd

24 files changed

Lines changed: 1214 additions & 379 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
title: Atlassian Service Accounts
3+
description: Set up an Atlassian service account with a scoped API token to use Jira and Confluence in Sim workflows
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
import { Step, Steps } from 'fumadocs-ui/components/steps'
8+
import { Image } from '@/components/ui/image'
9+
import { FAQ } from '@/components/ui/faq'
10+
11+
Atlassian service accounts let your workflows authenticate to Jira and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com.
12+
13+
This is the recommended way to use Jira and Confluence in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account.
14+
15+
## Prerequisites
16+
17+
You need an Atlassian organization admin to create the service account. Service accounts are an Atlassian organization-level feature — they cannot be created from a regular user account.
18+
19+
## Setting Up the Service Account
20+
21+
### 1. Create the Service Account
22+
23+
<Steps>
24+
<Step>
25+
Open [admin.atlassian.com](https://admin.atlassian.com/) and go to **Directory****Service accounts**
26+
27+
{/* TODO(screenshot): admin.atlassian.com directory page with the "Service accounts" tab highlighted */}
28+
</Step>
29+
<Step>
30+
Click **Create service account**, give it a name (e.g. `sim-jira-bot`), and finish creation
31+
</Step>
32+
<Step>
33+
Grant the service account access to the Atlassian sites and products it needs. Open the service account, go to **Product access**, and add Jira and/or Confluence on the relevant site
34+
35+
{/* TODO(screenshot): service account "Product access" tab showing Jira granted on a site */}
36+
</Step>
37+
</Steps>
38+
39+
<Callout type="info">
40+
The service account inherits permissions from the project/space roles you grant it — exactly like a human user. If a workflow needs to write to a specific Jira project, give the service account write access to that project in Jira's project settings.
41+
</Callout>
42+
43+
### 2. Create a Scoped API Token
44+
45+
<Steps>
46+
<Step>
47+
From the service account's page in admin.atlassian.com, open the **API tokens** tab and click **Create API token**
48+
49+
{/* TODO(screenshot): service account API tokens tab with "Create API token" button */}
50+
</Step>
51+
<Step>
52+
Choose **API token** as the authentication type (not OAuth 2.0 — Sim uses the API token flow)
53+
54+
<div className="flex justify-center">
55+
<Image
56+
src="/static/credentials/atlassian/admin-auth-type-picker.png"
57+
alt="Atlassian admin — Choose authentication type with API token selected"
58+
width={700}
59+
height={500}
60+
className="my-4"
61+
/>
62+
</div>
63+
</Step>
64+
<Step>
65+
Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is:
66+
67+
**Jira (granular):**
68+
```
69+
read:jira-user
70+
read:jira-work
71+
write:jira-work
72+
```
73+
74+
**Confluence (granular):**
75+
```
76+
read:confluence-content.all
77+
read:confluence-space.summary
78+
write:confluence-content
79+
read:page:confluence
80+
write:page:confluence
81+
```
82+
83+
Add more scopes only if you need the corresponding operations (delete, manage webhooks, etc.). The full list of scopes Sim's blocks may use is documented in [Atlassian's developer reference](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/).
84+
85+
<div className="flex justify-center">
86+
<Image
87+
src="/static/credentials/atlassian/admin-scope-picker.png"
88+
alt="Atlassian token scope picker filtered to App: Jira and Scope type: Classic"
89+
width={1000}
90+
height={600}
91+
className="my-4"
92+
/>
93+
</div>
94+
95+
<Callout type="info">
96+
Use the **App** and **Scope type** filters to narrow the list to the scopes you need. Filter by `App: Jira` (or `Confluence`) and `Scope type: Classic` to find the three core Jira scopes; switch to **Granular** if your org doesn't expose Classic.
97+
</Callout>
98+
</Step>
99+
<Step>
100+
Copy the token when it's shown. Atlassian only displays it once — if you close the dialog, you'll have to create a new token.
101+
</Step>
102+
</Steps>
103+
104+
<Callout type="warn">
105+
The API token is bearer credentials for the service account. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
106+
</Callout>
107+
108+
### 3. Find Your Site Domain
109+
110+
Your Atlassian site domain is the URL you use to access Jira or Confluence in your browser — for example, `your-team.atlassian.net`. Open Jira or Confluence, look at the address bar, and copy the part before the first `/`.
111+
112+
## Adding the Service Account to Sim
113+
114+
<Steps>
115+
<Step>
116+
Open your workspace **Settings** and go to the **Integrations** tab
117+
</Step>
118+
<Step>
119+
Search for "Atlassian Service Account" and click it
120+
121+
{/* TODO(screenshot): Integrations page with "Atlassian Service Account" in the service list */}
122+
</Step>
123+
<Step>
124+
Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description
125+
126+
<div className="flex justify-center">
127+
<Image
128+
src="/static/credentials/atlassian/sim-add-modal.png"
129+
alt="Add Atlassian Service Account dialog with API token and site domain filled in"
130+
width={420}
131+
height={560}
132+
className="my-6"
133+
/>
134+
</div>
135+
</Step>
136+
<Step>
137+
Click **Add Service Account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong.
138+
</Step>
139+
</Steps>
140+
141+
The token, domain, and discovered cloudId are encrypted before being stored.
142+
143+
## Using the Service Account in Workflows
144+
145+
Add a Jira or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
146+
147+
<div className="flex justify-center">
148+
<Image
149+
src="/static/credentials/atlassian/sim-jira-block-credential.png"
150+
alt="Jira block in a workflow with the Atlassian service account selected as the credential"
151+
width={1000}
152+
height={500}
153+
className="my-4"
154+
/>
155+
</div>
156+
157+
The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/...`) using the service account's token. There's no impersonation step — the service account acts as itself, with whatever permissions you granted it in admin.atlassian.com.
158+
159+
<FAQ items={[
160+
{ question: "Why an API token instead of OAuth?", answer: "API tokens for service accounts don't have a 1-hour expiry and don't require any user to consent. They're issued by an org admin and are stable until you revoke them — which is what you want for an automated workflow." },
161+
{ question: "Can a regular user create a service account?", answer: "No. Service accounts are an Atlassian organization-level feature and only an organization admin can create them." },
162+
{ question: "Can the same service account work with both Jira and Confluence?", answer: "Yes — give the service account access to both products on your site, and include scopes for both when you create the API token. Then connect it once in Sim and use it from either Jira or Confluence blocks." },
163+
{ question: "What if my workflow needs different permissions than the token has?", answer: "Either widen the token's scopes (revoke it and create a new one with more scopes), or grant the service account higher project/space roles in Jira or Confluence. Scope failures look like 401/403 errors with descriptive messages." },
164+
{ question: "How do I rotate the API token?", answer: "Create a new token from the same service account in admin.atlassian.com, update the credential in Sim with the new token, and once it's working, revoke the old one." },
165+
{ question: "Does this work with Atlassian Data Center / on-prem?", answer: "No — this integration uses Atlassian Cloud's API gateway (`api.atlassian.com`). For Data Center, use the OAuth flow or set up a self-hosted bot user." },
166+
]} />
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"title": "Integrations",
3-
"pages": ["index", "google-service-account"],
3+
"pages": ["index", "google-service-account", "atlassian-service-account"],
44
"defaultOpen": false
55
}
152 KB
Loading
394 KB
Loading
170 KB
Loading
236 KB
Loading

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
99
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1010
import { generateRequestId } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
1213
import {
14+
getAtlassianServiceAccountSecret,
1315
getCredential,
1416
getOAuthToken,
1517
getServiceAccountToken,
@@ -118,6 +120,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
118120
}
119121

120122
try {
123+
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
124+
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
125+
return NextResponse.json(
126+
{
127+
accessToken: secret.apiToken,
128+
cloudId: secret.cloudId,
129+
domain: secret.domain,
130+
},
131+
{ status: 200 }
132+
)
133+
}
121134
const accessToken = await getServiceAccountToken(
122135
resolved.credentialId,
123136
scopes ?? [],

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
isMicrosoftProvider,
1212
PROACTIVE_REFRESH_THRESHOLD_DAYS,
1313
} from '@/lib/oauth/microsoft'
14+
import {
15+
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
16+
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
17+
} from '@/lib/oauth/types'
1418

1519
const logger = createLogger('OAuthUtilsAPI')
1620

@@ -44,6 +48,7 @@ export interface ResolvedCredential {
4448
usedCredentialTable: boolean
4549
credentialType?: string
4650
credentialId?: string
51+
providerId?: string
4752
}
4853

4954
/**
@@ -61,6 +66,7 @@ export async function resolveOAuthAccountId(
6166
type: credential.type,
6267
accountId: credential.accountId,
6368
workspaceId: credential.workspaceId,
69+
providerId: credential.providerId,
6470
})
6571
.from(credential)
6672
.where(eq(credential.id, credentialId))
@@ -73,6 +79,7 @@ export async function resolveOAuthAccountId(
7379
credentialId: credentialRow.id,
7480
credentialType: 'service_account',
7581
workspaceId: credentialRow.workspaceId,
82+
providerId: credentialRow.providerId ?? undefined,
7683
usedCredentialTable: true,
7784
}
7885
}
@@ -208,6 +215,53 @@ export async function getServiceAccountToken(
208215
return tokenData.access_token
209216
}
210217

218+
interface AtlassianServiceAccountSecret {
219+
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
220+
apiToken: string
221+
domain: string
222+
cloudId: string
223+
atlassianAccountId?: string
224+
}
225+
226+
/**
227+
* Loads the decrypted Atlassian service account secret blob for a credential.
228+
* Throws if the credential is missing or not an Atlassian service account.
229+
*/
230+
export async function getAtlassianServiceAccountSecret(
231+
credentialId: string
232+
): Promise<AtlassianServiceAccountSecret> {
233+
const [credentialRow] = await db
234+
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
235+
.from(credential)
236+
.where(eq(credential.id, credentialId))
237+
.limit(1)
238+
239+
if (!credentialRow?.encryptedServiceAccountKey) {
240+
throw new Error('Atlassian service account secret not found')
241+
}
242+
243+
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
244+
const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
245+
if (
246+
parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE ||
247+
!parsed.apiToken ||
248+
!parsed.cloudId
249+
) {
250+
throw new Error('Stored Atlassian service account secret is malformed')
251+
}
252+
return parsed
253+
}
254+
255+
/**
256+
* For Atlassian service accounts, the API token IS the access token —
257+
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
258+
* No exchange or refresh is needed; we just decrypt and return the raw token.
259+
*/
260+
export async function getAtlassianServiceAccountToken(credentialId: string): Promise<string> {
261+
const secret = await getAtlassianServiceAccountSecret(credentialId)
262+
return secret.apiToken
263+
}
264+
211265
/**
212266
* Safely inserts an account record, handling duplicate constraint violations gracefully.
213267
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -374,6 +428,10 @@ export async function refreshAccessTokenIfNeeded(
374428
}
375429

376430
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
431+
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
432+
logger.info(`[${requestId}] Using Atlassian service account token for credential`)
433+
return getAtlassianServiceAccountToken(resolved.credentialId)
434+
}
377435
if (!scopes?.length) {
378436
throw new Error('Scopes are required for service account credentials')
379437
}

0 commit comments

Comments
 (0)