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
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
---
title: Atlassian Service Accounts
description: Set up an Atlassian service account with a scoped API token to use Jira and Confluence in Sim workflows
description: Set up an Atlassian service account with a scoped API token to use Jira, Jira Service Management, and Confluence in Sim workflows
---

import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'

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.
Atlassian service accounts let your workflows authenticate to Jira, Jira Service Management, 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.

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.
This is the recommended way to use Atlassian products 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.

One service account covers all three products. You add it once, and it appears as a connected credential on the Jira, Jira Service Management, and Confluence integration pages alike — there is no separate credential to create per product.

## Prerequisites

Expand Down Expand Up @@ -124,12 +126,17 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo

<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
Open **Integrations** in your workspace sidebar
</Step>
<Step>
Search for "Atlassian Service Account" and click it
Open **Jira**, **Jira Service Management**, or **Confluence** — any of the three works, since they share one service account

{/* TODO(screenshot): Integrations page with "Atlassian Service Account" in the service list */}
{/* TODO(screenshot): Integrations page with Jira in the list */}
</Step>
<Step>
Click **Add to Sim** and choose **Add service account**

{/* TODO(screenshot): Jira integration page with the "Add to Sim" dropdown open */}
</Step>
<Step>
Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description
Expand All @@ -145,15 +152,17 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo
</div>
</Step>
<Step>
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.
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.
</Step>
</Steps>

The token, domain, and discovered cloudId are encrypted before being stored.

Once added, the credential is listed under **Connected** on all three Atlassian integration pages. It is named after the service account's own Atlassian display name, so several service accounts on the same site stay easy to tell apart.

## Using the Service Account in Workflows

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.
Add a Jira, Jira Service Management, 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.

<div className="flex justify-center">
<Image
Expand All @@ -170,7 +179,7 @@ The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/..
<FAQ items={[
{ 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." },
{ 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." },
{ 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." },
{ question: "Can the same service account work with Jira, Jira Service Management, and Confluence?", answer: "Yes — one service account covers all three. Give it access to each product you need on your site, include scopes for each when you create the API token, then connect it once in Sim. It appears as a connected credential on all three integration pages and can be selected from any of their blocks." },
{ 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." },
{ 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." },
{ 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." },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { useQueryState } from 'nuqs'
import {
blockTypeToIconMap,
type Integration,
resolveCredentialDisplay,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { credentialProviderMatchesService } from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
Expand Down Expand Up @@ -65,13 +66,21 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration

useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })

/**
* Credentials that authenticate this integration. Matching goes through
* `credentialProviderMatchesService` so a family service account lists on
* every product it powers — one Atlassian token covers Jira, Jira Service
* Management, and Confluence. Comparing resolved `providerId`s instead would
* hide it from all three, since `atlassian-service-account` resolves to its
* own pseudo-service rather than to any product.
*/
const connectedCredentials = useMemo(() => {
if (!oauthService) return []
return credentials.filter(
(c) =>
(c.type === 'oauth' || c.type === 'service_account') &&
c.providerId &&
getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId
credentialProviderMatchesService(c.providerId, oauthService)
)
}, [credentials, oauthService])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
Expand Down Expand Up @@ -112,7 +121,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
{
value: CONNECT_MODE.serviceAccount,
label: serviceAccountConnectLabel,
icon: oauthService.serviceIcon,
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
},
]
: []
Expand Down Expand Up @@ -170,14 +179,14 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
serviceIcon={oauthService.serviceIcon}
/>
)}
{hasServiceAccount && oauthService?.serviceAccountProviderId && (
{hasServiceAccount && serviceAccountTarget && (
<ConnectServiceAccountModal
open={serviceAccountOpen}
onOpenChange={setServiceAccountOpen}
workspaceId={workspaceId}
serviceAccountProviderId={oauthService.serviceAccountProviderId}
serviceName={oauthService.serviceName}
serviceIcon={oauthService.serviceIcon}
serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId}
serviceName={serviceAccountTarget.serviceName}
serviceIcon={serviceAccountTarget.serviceIcon}
/>
)}
<div
Expand Down Expand Up @@ -219,7 +228,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
{credential.displayName}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{credential.description || oauthService?.serviceName}
{credential.description || resolveCredentialDisplay(credential).subtitle}
</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getTokenServiceAccountDescriptor,
type TokenServiceAccountProviderId,
} from '@/lib/credentials/token-service-accounts/descriptors'
import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential-display'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
Expand Down Expand Up @@ -60,6 +61,16 @@ function openDocs(url: string): void {
*/
const ATLASSIAN_DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i

/**
* States the site-wide reach of the token up front. Users reaching this modal
* from the Jira page were left unsure whether they had connected Jira or Jira
* Service Management; the credential covers both, plus Confluence. Derived from
* the catalog so it cannot drift as Atlassian integrations are added.
*/
const ATLASSIAN_COVERAGE_HINT = getServiceAccountCoverageSentence(
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
)

/**
* Maps server `error.code` values returned by the Atlassian service-account
* route to user-facing messages. Falls back to {@link FALLBACK_ERROR_MESSAGE}
Expand Down Expand Up @@ -524,6 +535,7 @@ function AtlassianServiceAccountModal({
? 'Atlassian sites usually look like your-team.atlassian.net.'
: undefined
}
hint={ATLASSIAN_COVERAGE_HINT}
Comment thread
cursor[bot] marked this conversation as resolved.
/>

<ChipModalField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import {
getServiceAccountConnectNoun,
getServiceAccountGatingBlockType,
} from '@/lib/credentials/service-account-provider-ids'
/**
* Imported from the module rather than the `@/lib/integrations` barrel: the
* barrel builds `POPULAR_WORKFLOWS` by calling `getAllBlockMeta()` at module
* load, so importing it from a leaf component drags the whole block registry
* into that component's graph.
*/
import {
getServiceAccountFamilyIcon,
getServiceAccountFamilyName,
} from '@/lib/integrations/credential-display'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
import { getBlock } from '@/blocks'
Expand All @@ -17,6 +27,13 @@ import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
*/
export interface ServiceAccountConnectTarget {
serviceAccountProviderId: ServiceAccountProviderId
/**
* Name the setup surface is titled with. For a family service account this is
* the vendor ("Atlassian"), not the product page you came from — one Atlassian
* token authenticates Jira, Jira Service Management, and Confluence alike, so
* calling it a "Jira service account" is what made users think they had
* connected the wrong product.
*/
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/**
Expand Down Expand Up @@ -71,6 +88,15 @@ export function useServiceAccountConnectTarget({
? 'Set up a custom bot'
: `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}`

return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden }
const familyName = getServiceAccountFamilyName(serviceAccountProviderId)
const familyIcon = getServiceAccountFamilyIcon(serviceAccountProviderId)

return {
serviceAccountProviderId,
serviceName: familyName ?? serviceName,
serviceIcon: familyIcon ?? serviceIcon,
label,
hidden,
}
}, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden])
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { INTEGRATIONS, resolveOAuthServiceForIntegration } from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { resolveCredentialDisplay } from '@/lib/integrations'
import {
AddPeopleModal,
CredentialDetailHeading,
Expand Down Expand Up @@ -97,27 +96,18 @@ export function ConnectedCredentialDetail({
[oauthServiceNameByProviderId]
)

const serviceConfig = useMemo(() => {
if (!credential?.providerId) return null
return getServiceConfigByProviderId(credential.providerId)
}, [credential])

/**
* Resolve the integration block type from the credential's OAuth service so
* the header tile can render with the same brand background used by the rows
* on the integrations list page. Several integrations can share one service
* (e.g. Jira and Jira Service Management); the one named after the service
* is preferred since it is the service's canonical integration.
* Service, brand tile, and copy all come from the shared resolver so this
* page, the integrations list, and the Cmd-K search agree on how a credential
* is named and branded — a family service account reads as its family
* ("Atlassian"), not as whichever product the provider walk happened to hit.
*/
const integrationBlockType = useMemo(() => {
if (!serviceConfig) return ''
const candidates = INTEGRATIONS.filter(
(i) => resolveOAuthServiceForIntegration(i)?.providerId === serviceConfig.providerId
)
const serviceName = serviceConfig.name.toLowerCase()
const canonical = candidates.find((i) => i.name.toLowerCase() === serviceName)
return (canonical ?? candidates[0])?.type ?? ''
}, [serviceConfig])
const display = useMemo(
() => (credential ? resolveCredentialDisplay(credential) : null),
[credential]
)
const serviceConfig = display?.service ?? null
const integrationBlockType = display?.blockType ?? ''

const handleReconnectOAuth = async () => {
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
Expand Down Expand Up @@ -206,7 +196,7 @@ export function ConnectedCredentialDetail({
: handleReconnectOAuth
}
disabled={connectOAuthService.isPending}
leftIcon={serviceConfig?.icon}
leftIcon={display?.icon ?? undefined}
>
Reconnect
</Chip>
Expand Down Expand Up @@ -242,19 +232,16 @@ export function ConnectedCredentialDetail({
)
}

const serviceLabel =
serviceConfig?.name || resolveProviderLabel(credential.providerId) || 'Unknown service'
const headingTitle =
display?.detailTitle || resolveProviderLabel(credential.providerId) || 'Unknown service'

return (
<>
<CredentialDetailLayout back={back} actions={actions}>
<CredentialDetailHeading
leading={
serviceConfig ? (
<IntegrationTile
blockType={integrationBlockType}
icon={serviceConfig.icon as ComponentType<{ className?: string }>}
/>
display?.icon ? (
<IntegrationTile blockType={integrationBlockType} icon={display.icon} />
) : (
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
<span className='font-medium text-[var(--text-tertiary)] text-small'>
Expand All @@ -263,8 +250,8 @@ export function ConnectedCredentialDetail({
</div>
)
}
title={serviceLabel}
subtitle={serviceConfig?.description || 'Connected service'}
title={headingTitle}
subtitle={display?.detailSubtitle ?? 'Connected service'}
/>

<DetailSection title='Credential ID'>
Expand Down Expand Up @@ -335,8 +322,8 @@ export function ConnectedCredentialDetail({
onOpenChange={setReconnectOpen}
workspaceId={workspaceId}
serviceAccountProviderId={credential.providerId as ServiceAccountProviderId}
serviceName={serviceConfig?.name || credential.displayName}
serviceIcon={serviceConfig?.icon as ComponentType<{ className?: string }>}
serviceName={display?.familyName || serviceConfig?.name || credential.displayName}
serviceIcon={display?.icon as ComponentType<{ className?: string }>}
credentialId={credential.id}
credentialDisplayName={credential.displayName}
credentialDescription={credential.description ?? undefined}
Expand Down
Loading
Loading