From b5e415d579bb75efbff05e6df50f86bed1d7c5a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Nov 2026 17:46:29 -0800 Subject: [PATCH 1/2] improvement(integrations): show the sites an Atlassian account reaches Adding a site to an Atlassian login refreshes the existing account, so the page looked unchanged after a successful add. Record the sites a grant reaches when it connects, list them on the account, count them in the accounts list, and confirm a successful connect or reconnect with a toast. --- .../integrations/integration-detail.tsx | 57 ++++++++-- .../api/contracts/organization-accounts.ts | 3 + .../lib/auth/connectors/managed-oauth.test.ts | 101 ++++++++++++++---- apps/sim/lib/auth/connectors/managed-oauth.ts | 44 ++++++++ .../multiple-provider-accounts.integration.ts | 28 ++++- .../application/organization-access.test.ts | 2 +- .../standard-oauth-provider.ts | 1 + .../lib/credential-groups/viewer-accounts.ts | 23 +++- packages/db/schema.ts | 7 ++ 9 files changed, 232 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx index 184d2423fa7..26824a3f330 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx @@ -33,6 +33,23 @@ interface IntegrationDetailProps { children?: ReactNode } +type IntegrationAccount = MemberIntegration['accounts'][number] + +/** Sites recorded for an account whose one grant reaches several, such as Confluence or Jira. */ +function sitesOf(account: IntegrationAccount) { + return 'sites' in account ? account.sites : [] +} + +function siteHost(url: string) { + return url.replace(/^https:\/\//, '') +} + +function accountSummary(account: IntegrationAccount) { + const status = account.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected' + const count = sitesOf(account).length + return count > 0 ? `${status} ยท ${count} ${count === 1 ? 'site' : 'sites'}` : status +} + function savedProviderIdFor(provider: string) { const providerId = liveSearchSavedMemberProvider(provider) return providerId === 'jama-service-account' || providerId === 'flow-service-account' @@ -81,7 +98,11 @@ export function IntegrationDetail({ ? undefined : 'savedProviderId' in target ? () => savedProvider && setSavedConnection({ providerId: savedProvider }) - : () => connect.mutate({ organizationId, ...target }, { onError }) + : () => + connect.mutate( + { organizationId, ...target }, + { onError, onSuccess: () => toast.success(`${integration.name} connected`) } + ) } onReconnect={() => { if (!selected) return @@ -91,7 +112,11 @@ export function IntegrationDetail({ credentialId: selected.credentialId, displayName: selected.displayName, }) - else reconnect.mutate(selected.credentialId, { onError }) + else + reconnect.mutate(selected.credentialId, { + onError, + onSuccess: () => toast.success(`${integration.name} reconnected`), + }) }} connectionPending={connect.isPending || reconnect.isPending} canConnect={integration.ready} @@ -117,19 +142,33 @@ export function IntegrationDetail({ This account is no longer connected. ) : selected ? ( - - {selected.displayName} - +
+ + {selected.displayName} + + {sitesOf(selected).length > 0 && ( + +
    + {sitesOf(selected).map((site) => ( +
  • + {site.name}{' '} + {siteHost(site.url)} +
  • + ))} +
+
+ )} +
) : integration.accounts.length ? (
{integration.accounts.map((account) => ( { it('uses the existing connector callback contract and verifies the current account', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - account_type: 'atlassian', - account_id: 'account-1', - email: 'person@example.com', - name: 'Person', - picture: 'https://example.com/avatar.png', - account_status: 'active', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) + const fetchMock = vi.fn().mockImplementation( + async () => + new Response( + JSON.stringify({ + account_type: 'atlassian', + account_id: 'account-1', + email: 'person@example.com', + name: 'Person', + picture: 'https://example.com/avatar.png', + account_status: 'active', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) ) vi.stubGlobal('fetch', fetchMock) const connector = createAtlassianManagedOAuthConnector('jira') @@ -60,18 +61,76 @@ describe('Atlassian managed OAuth connector', () => { }) }) + const ATLASSIAN_PROFILE = { + account_type: 'atlassian', + account_id: 'account-1', + email: 'person@example.com', + account_status: 'active', + } + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + function stubAtlassian(sites: () => Response) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => + url === 'https://api.atlassian.com/me' ? json(ATLASSIAN_PROFILE) : sites() + ) + ) + } + const verify = (provider: 'jira' | 'confluence' = 'confluence') => + createAtlassianManagedOAuthConnector(provider).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ATLASSIAN_SCOPES }, + clientId: 'client-1', + }) + + it('records the sites the grant reaches, so an added site is visible', async () => { + stubAtlassian(() => + json([ + { id: 'cloud-1', name: 'Acme', url: 'https://acme.example.net', scopes: [] }, + { id: 'cloud-2', name: 'Beta', url: 'https://beta.example.net/', scopes: [] }, + { id: 'cloud-3', name: 'Insecure', url: 'http://insecure.example.net' }, + { id: 'cloud-4', url: 'https://unnamed.example.net' }, + ]) + ) + + expect((await verify()).sites).toEqual([ + { name: 'Acme', url: 'https://acme.example.net' }, + { name: 'Beta', url: 'https://beta.example.net' }, + ]) + }) + + it.each([ + ['an error status', () => json({ message: 'unavailable' }, 503)], + [ + 'a network failure', + () => { + throw new TypeError('fetch failed') + }, + ], + ['a malformed body', () => json({ sites: 'nope' })], + ])('still connects without a site list after %s', async (_name, sites) => { + stubAtlassian(sites) + const identity = await verify('jira') + expect(identity).toMatchObject({ providerSubjectId: 'account-1' }) + expect(identity.sites).toBeUndefined() + }) + it('fails closed for an inactive or incomplete Atlassian identity', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - account_type: 'atlassian', - account_id: 'account-1', - account_status: 'inactive', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) + vi.fn().mockImplementation( + async () => + new Response( + JSON.stringify({ + account_type: 'atlassian', + account_id: 'account-1', + account_status: 'inactive', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) ) ) const connector = createAtlassianManagedOAuthConnector('confluence') diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index b10118070bd..16e4c081956 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import type { ManagedOAuthSite } from '@sim/db/schema' import { isRecordLike, toRecord } from '@sim/utils/object' import type { OAuth2Tokens } from 'better-auth/oauth2' import type { GenericOAuthConfig } from 'better-auth/plugins' @@ -24,6 +25,8 @@ const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' const ATLASSIAN_USER_INFO_URL = 'https://api.atlassian.com/me' const ATLASSIAN_USER_INFO_MAX_BYTES = 256 * 1024 const ATLASSIAN_USER_INFO_TIMEOUT_MS = 10_000 +const ATLASSIAN_SITES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources' +const ATLASSIAN_SITE_LIMIT = 50 const MICROSOFT_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys' const MICROSOFT_OIDC_USER_INFO_URL = 'https://graph.microsoft.com/oidc/userinfo' const MICROSOFT_OIDC_USER_INFO_MAX_BYTES = 256 * 1024 @@ -49,6 +52,7 @@ export interface ManagedOAuthConnectorIdentity { avatarUrl?: string nonce?: string grantedScopes: string[] + sites?: ManagedOAuthSite[] } export interface ManagedOAuthConnectorConfig { @@ -187,6 +191,44 @@ function requireAtlassianUserProfile(value: AtlassianUserProfile): { } } +function httpsOrigin(value: string): string | null { + try { + const url = new URL(value) + return url.protocol === 'https:' ? url.origin : null + } catch { + return null + } +} + +/** + * The sites one Atlassian grant reaches, shown so a person can see a site they added. Display only: + * Search lists sites again on every request, so a missing or stale list never changes access, and + * a failed listing never fails the connection. + */ +async function listAtlassianSites(accessToken: string): Promise { + try { + const response = await fetch(ATLASSIAN_SITES_URL, { + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(ATLASSIAN_USER_INFO_TIMEOUT_MS), + }) + const body = await readResponseJsonWithLimit(response, { + maxBytes: ATLASSIAN_USER_INFO_MAX_BYTES, + label: 'Atlassian accessible resources response', + }) + if (!response.ok || !Array.isArray(body)) return undefined + return body + .flatMap((entry) => { + const site = toRecord(entry) + const name = typeof site.name === 'string' ? site.name.trim() : '' + const url = typeof site.url === 'string' ? httpsOrigin(site.url) : null + return name && url ? [{ name, url }] : [] + }) + .slice(0, ATLASSIAN_SITE_LIMIT) + } catch { + return undefined + } +} + /** Managed enrollment policy for Atlassian's Jira and Confluence 3LO clients. */ export function createAtlassianManagedOAuthConnector( providerId: AtlassianManagedOAuthProviderId @@ -205,6 +247,7 @@ export function createAtlassianManagedOAuthConnector( if (!tokens.accessToken || !tokens.scopes?.length) { throw new Error('Atlassian returned an incomplete authorization') } + const sites = listAtlassianSites(tokens.accessToken) const response = await fetch(ATLASSIAN_USER_INFO_URL, { headers: { Accept: 'application/json', @@ -228,6 +271,7 @@ export function createAtlassianManagedOAuthConnector( ...(identity.name ? { displayName: identity.name } : {}), ...(identity.picture ? { avatarUrl: identity.picture } : {}), grantedScopes: [...new Set(tokens.scopes)], + ...(await sites.then((listed) => (listed ? { sites: listed } : {}))), } }, hasRequiredScopes(grantedScopes, requiredScopes) { diff --git a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts index 4cd6b31a801..d9169975ef2 100644 --- a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts +++ b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts @@ -15,7 +15,7 @@ import { } from '@sim/db/schema' import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' import { listCredentialGroupEnrollments } from '@/lib/credential-groups/enrollments' @@ -24,6 +24,7 @@ import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts' import { persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' import { decryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth' import { resolveSlackSearchMember } from '@/lib/knowledge/application/slack-search/identity' @@ -357,6 +358,31 @@ describe('adding another site to an Atlassian login', () => { expect(await saved()).not.toBe(firstToken) }) + it('shows the sites recorded for the account, ignoring anything unusable', async () => { + const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE) + const viewer = async () => + (await listViewerOrganizationAccounts({ organizationId, userId })).find( + (row) => row.credentialId === account.credentialId + ) + expect((await viewer())?.sites).toEqual([]) + + await db + .update(credential) + .set({ + providerMetadata: sql`${JSON.stringify({ + email: 'person@atlassian', + sites: [ + { name: 'Acme', url: 'https://acme.example.net' }, + { name: 42, url: 'https://broken.example.net' }, + 'not a site', + ], + })}::jsonb`, + }) + .where(eq(credential.id, account.credentialId)) + + expect((await viewer())?.sites).toEqual([{ name: 'Acme', url: 'https://acme.example.net' }]) + }) + it('still adds a different Atlassian login as its own account', async () => { await connect('person@atlassian', { kind: 'create' }, CONFLUENCE) await connect('other@atlassian', { kind: 'create' }, CONFLUENCE) diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts index 9f0ead2c1c5..c3f72b4821f 100644 --- a/apps/sim/lib/credential-groups/application/organization-access.test.ts +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -194,7 +194,7 @@ describe('organization workspace sharing administration', () => { principal, input: { organizationId: 'org-1' }, }) - expect(result.viewerAccounts).toEqual([account]) + expect(result.viewerAccounts).toEqual([{ ...account, sites: [] }]) expect(result.canManage).toBe(false) expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroup.id, 'group-1') expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'admin-user') diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 5c4c343277d..575aef31263 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -363,6 +363,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( email, ...(identity.displayName ? { displayName: identity.displayName } : {}), ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + ...(identity.sites ? { sites: identity.sites } : {}), }, accessToken: tokens.accessToken, ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), diff --git a/apps/sim/lib/credential-groups/viewer-accounts.ts b/apps/sim/lib/credential-groups/viewer-accounts.ts index 3208145acde..43ff3945ed8 100644 --- a/apps/sim/lib/credential-groups/viewer-accounts.ts +++ b/apps/sim/lib/credential-groups/viewer-accounts.ts @@ -1,9 +1,25 @@ import { db } from '@sim/db' -import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + type ManagedOAuthSite, +} from '@sim/db/schema' +import { toArray, toRecord } from '@sim/utils/object' import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limits' +/** Stored metadata is provider-shaped JSON, so only well-formed entries are shown. */ +function recordedSites(metadata: unknown): ManagedOAuthSite[] { + return toArray(toRecord(metadata).sites).flatMap((entry) => { + const site = toRecord(entry) + return typeof site.name === 'string' && typeof site.url === 'string' + ? [{ name: site.name, url: site.url }] + : [] + }) +} + /** Own grant metadata remains manageable even while provider setup or enrollment is disabled. */ export async function listViewerOrganizationAccounts(input: { organizationId: string @@ -19,6 +35,7 @@ export async function listViewerOrganizationAccounts(input: { groupId: credentialGroup.id, optionId: credential.credentialGroupOptionId, providerId: credential.providerId, + metadata: credential.providerMetadata, }) .from(credential) .innerJoin( @@ -47,11 +64,13 @@ export async function listViewerOrganizationAccounts(input: { (account.status !== 'active' && account.status !== 'needs_reauth') ) throw new Error('Invalid personal account metadata') + const { metadata, ...rest } = account return { - ...account, + ...rest, optionId: account.optionId, providerId: account.providerId, status: account.status, + sites: recordedSites(metadata), } }) } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 979adaf251e..f820b423751 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -6408,12 +6408,19 @@ export const managedOauthCredentialStatusEnum = pgEnum('managed_oauth_credential 'revoked', ]) +/** A site one grant reaches, recorded for display only; access is always rechecked live. */ +export interface ManagedOAuthSite { + name: string + url: string +} + export interface ManagedOAuthProviderMetadata { email: string displayName?: string avatarUrl?: string username?: string tenantDisplayName?: string + sites?: ManagedOAuthSite[] } export interface ManagedMcpToolSnapshot { From 68bef5994cb166024e3b73d6746439446ea71d7e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Nov 2026 17:59:11 -0800 Subject: [PATCH 2/2] fix(integrations): bound recorded sites and keep them when a reconnect cannot list them --- .../api/contracts/organization-accounts.ts | 12 ++++- apps/sim/lib/auth/connectors/managed-oauth.ts | 12 ++--- .../multiple-provider-accounts.integration.ts | 51 ++++++++++++++++++- .../lib/credential-groups/account-sites.ts | 27 ++++++++++ apps/sim/lib/credential-groups/limits.ts | 4 ++ apps/sim/lib/credential-groups/oauth.ts | 7 ++- .../lib/credential-groups/viewer-accounts.ts | 22 ++------ 7 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/credential-groups/account-sites.ts diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index 2a5d224b6ef..39c0426b337 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -18,6 +18,9 @@ import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/pri import { defineRouteContract } from '@/lib/api/contracts/types' import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types' import { + ACCOUNT_SITE_LIMIT, + ACCOUNT_SITE_NAME_MAX_LENGTH, + ACCOUNT_SITE_URL_MAX_LENGTH, ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT, ORGANIZATION_VIEWER_ACCOUNT_LIMIT, } from '@/lib/credential-groups/limits' @@ -80,8 +83,13 @@ export const getOrganizationAccountsContract = defineRouteContract({ optionId: z.string().min(1).max(128), status: z.enum(['active', 'needs_reauth']), sites: z - .array(z.object({ name: z.string().max(512), url: z.string().max(2048) })) - .max(50), + .array( + z.object({ + name: z.string().max(ACCOUNT_SITE_NAME_MAX_LENGTH), + url: z.string().max(ACCOUNT_SITE_URL_MAX_LENGTH), + }) + ) + .max(ACCOUNT_SITE_LIMIT), }) ) .max(ORGANIZATION_VIEWER_ACCOUNT_LIMIT) diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index 16e4c081956..aad3acd77b4 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -7,6 +7,7 @@ import { OAuth2Client, type TokenPayload } from 'google-auth-library' import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { boundedAccountSites } from '@/lib/credential-groups/account-sites' import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' import { verifyGitHubRepositoriesIdentity } from '@/lib/oauth/github-repositories' import { deriveMicrosoftEmailVerified, mapMicrosoftProfileToUser } from '@/lib/oauth/microsoft' @@ -26,7 +27,6 @@ const ATLASSIAN_USER_INFO_URL = 'https://api.atlassian.com/me' const ATLASSIAN_USER_INFO_MAX_BYTES = 256 * 1024 const ATLASSIAN_USER_INFO_TIMEOUT_MS = 10_000 const ATLASSIAN_SITES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources' -const ATLASSIAN_SITE_LIMIT = 50 const MICROSOFT_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys' const MICROSOFT_OIDC_USER_INFO_URL = 'https://graph.microsoft.com/oidc/userinfo' const MICROSOFT_OIDC_USER_INFO_MAX_BYTES = 256 * 1024 @@ -216,14 +216,12 @@ async function listAtlassianSites(accessToken: string): Promise { + return boundedAccountSites( + body.map((entry) => { const site = toRecord(entry) - const name = typeof site.name === 'string' ? site.name.trim() : '' - const url = typeof site.url === 'string' ? httpsOrigin(site.url) : null - return name && url ? [{ name, url }] : [] + return { name: site.name, url: typeof site.url === 'string' ? httpsOrigin(site.url) : null } }) - .slice(0, ATLASSIAN_SITE_LIMIT) + ) } catch { return undefined } diff --git a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts index d9169975ef2..430e5aef103 100644 --- a/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts +++ b/apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts @@ -42,6 +42,8 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({ /** Each grant carries its own token, as a real exchange does, so a saved grant is identifiable. */ let grants = 0 +/** Sites the next grant reports, or `undefined` when its site listing failed. */ +let grantSites: Array<{ name: string; url: string }> | undefined vi.mock('@/lib/credential-groups/provider-registry', async (importOriginal) => { const { getCredentialGroupProviderId } = await import('@/lib/credential-groups/providers') @@ -64,7 +66,7 @@ vi.mock('@/lib/credential-groups/provider-registry', async (importOriginal) => { providerSubjectId: subject, providerTenantId: tenant || null, displayName: code, - metadata: { email: code }, + metadata: { email: code, ...(grantSites ? { sites: grantSites } : {}) }, accessToken: `token-for-${code}-${(grants += 1)}`, grantedScopes: POLICY.requiredScopes, accessTokenExpiresAt: null, @@ -230,6 +232,7 @@ beforeAll(async () => { }) beforeEach(async () => { + grantSites = undefined await db.delete(credential).where(eq(credential.organizationId, organizationId)) }) @@ -383,6 +386,52 @@ describe('adding another site to an Atlassian login', () => { expect((await viewer())?.sites).toEqual([{ name: 'Acme', url: 'https://acme.example.net' }]) }) + it('never lets oversized stored sites break the account list', async () => { + const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE) + const site = (index: number) => ({ + name: `Site ${index}`, + url: `https://s${index}.example.net`, + }) + await db + .update(credential) + .set({ + providerMetadata: sql`${JSON.stringify({ + email: 'person@atlassian', + sites: [ + { name: 'x'.repeat(600), url: 'https://long-name.example.net' }, + { name: 'Long URL', url: `https://example.net/${'x'.repeat(2100)}` }, + ...Array.from({ length: 60 }, (_, index) => site(index)), + ], + })}::jsonb`, + }) + .where(eq(credential.id, account.credentialId)) + + const [viewer] = await listViewerOrganizationAccounts({ organizationId, userId }) + expect(viewer?.sites).toHaveLength(50) + expect(viewer?.sites[0]).toEqual(site(0)) + }) + + it('keeps the recorded sites when a reconnect cannot list them', async () => { + grantSites = [{ name: 'Acme', url: 'https://acme.example.net' }] + const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE) + const reconnect = () => + connect( + 'person@atlassian', + { kind: 'reconnect', credentialId: account.credentialId }, + CONFLUENCE + ) + const sites = async () => + (await listViewerOrganizationAccounts({ organizationId, userId }))[0]?.sites + + grantSites = undefined + await reconnect() + expect(await sites()).toEqual([{ name: 'Acme', url: 'https://acme.example.net' }]) + + grantSites = [] + await reconnect() + expect(await sites()).toEqual([]) + }) + it('still adds a different Atlassian login as its own account', async () => { await connect('person@atlassian', { kind: 'create' }, CONFLUENCE) await connect('other@atlassian', { kind: 'create' }, CONFLUENCE) diff --git a/apps/sim/lib/credential-groups/account-sites.ts b/apps/sim/lib/credential-groups/account-sites.ts new file mode 100644 index 00000000000..a37798289c5 --- /dev/null +++ b/apps/sim/lib/credential-groups/account-sites.ts @@ -0,0 +1,27 @@ +import type { ManagedOAuthSite } from '@sim/db/schema' +import { toArray, toRecord } from '@sim/utils/object' +import { + ACCOUNT_SITE_LIMIT, + ACCOUNT_SITE_NAME_MAX_LENGTH, + ACCOUNT_SITE_URL_MAX_LENGTH, +} from '@/lib/credential-groups/limits' + +/** + * Keeps the well-formed sites within the recorded bounds. Site lists are display data from a + * provider, so a bad entry is dropped rather than allowed to fail the account it belongs to. + */ +export function boundedAccountSites(value: unknown): ManagedOAuthSite[] { + return toArray(value) + .flatMap((entry) => { + const site = toRecord(entry) + const name = typeof site.name === 'string' ? site.name.trim() : '' + const url = typeof site.url === 'string' ? site.url : '' + return name && + url && + name.length <= ACCOUNT_SITE_NAME_MAX_LENGTH && + url.length <= ACCOUNT_SITE_URL_MAX_LENGTH + ? [{ name, url }] + : [] + }) + .slice(0, ACCOUNT_SITE_LIMIT) +} diff --git a/apps/sim/lib/credential-groups/limits.ts b/apps/sim/lib/credential-groups/limits.ts index 9d2e1c019ec..29f917d17aa 100644 --- a/apps/sim/lib/credential-groups/limits.ts +++ b/apps/sim/lib/credential-groups/limits.ts @@ -9,3 +9,7 @@ export const CREDENTIAL_GROUP_KNOWLEDGE_CONNECTOR_ACCESS_LIMIT = 32 export const ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT = 100 /** Live accounts one member may connect per provider; bounds a search's per-account fan-out. */ export const CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT = 5 +/** Bounds on the sites recorded for one account, enforced wherever they are written or read. */ +export const ACCOUNT_SITE_LIMIT = 50 +export const ACCOUNT_SITE_NAME_MAX_LENGTH = 512 +export const ACCOUNT_SITE_URL_MAX_LENGTH = 2048 diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 7853425c85e..f8773adffd7 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -231,6 +231,7 @@ async function persistGrant( id: credential.id, revokedAt: credential.revokedAt, managedOauthStatus: credential.managedOauthStatus, + providerMetadata: credential.providerMetadata, providerSubjectId: credential.providerSubjectId, providerTenantId: credential.providerTenantId, encryptedOauthTokenSet: credential.encryptedOauthTokenSet, @@ -287,7 +288,11 @@ async function persistGrant( providerTenantId: grant.providerTenantId, managedOauthStatus: 'active' as const, grantedScopes: grant.grantedScopes, - providerMetadata: grant.metadata, + // A site listing that failed reports no sites; keep the account's last known list. + providerMetadata: + grant.metadata.sites === undefined && existing?.providerMetadata?.sites + ? { ...grant.metadata, sites: existing.providerMetadata.sites } + : grant.metadata, encryptedOauthTokenSet, grantedAt: now, revokedAt: null, diff --git a/apps/sim/lib/credential-groups/viewer-accounts.ts b/apps/sim/lib/credential-groups/viewer-accounts.ts index 43ff3945ed8..c838dfaba3a 100644 --- a/apps/sim/lib/credential-groups/viewer-accounts.ts +++ b/apps/sim/lib/credential-groups/viewer-accounts.ts @@ -1,25 +1,11 @@ import { db } from '@sim/db' -import { - credential, - credentialGroup, - credentialGroupEnrollment, - type ManagedOAuthSite, -} from '@sim/db/schema' -import { toArray, toRecord } from '@sim/utils/object' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { toRecord } from '@sim/utils/object' import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { boundedAccountSites } from '@/lib/credential-groups/account-sites' import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limits' -/** Stored metadata is provider-shaped JSON, so only well-formed entries are shown. */ -function recordedSites(metadata: unknown): ManagedOAuthSite[] { - return toArray(toRecord(metadata).sites).flatMap((entry) => { - const site = toRecord(entry) - return typeof site.name === 'string' && typeof site.url === 'string' - ? [{ name: site.name, url: site.url }] - : [] - }) -} - /** Own grant metadata remains manageable even while provider setup or enrollment is disabled. */ export async function listViewerOrganizationAccounts(input: { organizationId: string @@ -70,7 +56,7 @@ export async function listViewerOrganizationAccounts(input: { optionId: account.optionId, providerId: account.providerId, status: account.status, - sites: recordedSites(metadata), + sites: boundedAccountSites(toRecord(metadata).sites), } }) }