mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
Resolves FE-4014
A user with a project-scoped role opening any project integration
overview (e.g. Cron) hits an unbounded request loop — the page sits on a
skeleton forever while hammering the platform API until it gets rate
limited.
**Changed:**
- `useProjectOAuthIntegrationData` now passes `retryOnMount: false` to
its five queries, so a 403 settles as a terminal error instead of
refetching on every consumer mount
## Why
Project-scoped roles have no org-level permissions, so `GET
/platform/organizations/{slug}/oauth/apps` 403s. We don't retry 4xx, so
the query settles into `error` with no data — and an errored query with
no data is never fresh, so it refetches on *every* new observer mount.
That feeds a loop: refetch → `isLoading` true → `IntegrationPage` swaps
its whole subtree to a skeleton → `<Component />` unmounts → 403 lands →
`isLoading` false → remounts → mounts fresh observers → refetch.
Measured ~20 req/s (480 observer add/removes and 120 requests in a 6s
window) until the API 429s it, then it continues at the retry cadence
indefinitely.
The other four queries in that hook can 403 the same way for restricted
roles, and any one of them alone sustains the loop — hence the option on
all five.
Not fixed here: `IntegrationPage` tearing down its subtree whenever
`isLoading` flips
(`pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx:58-94`)
is the amplifier that turns a wasted request into a loop, and will still
reset UI state on any background refetch. Worth a follow-up.
## To test
Needs an account with a project-scoped role in a shared org (not an org
owner/admin).
- Open `/project/{ref}/integrations` for that project, click into Cron
(or any integration) → overview should render, not sit on a skeleton
- Network tab: `organizations/{slug}/oauth/apps?type=authorized` should
fire once and 403, not repeat
- Console should show 1 error, not hundreds ending in a 429
- As an org owner, integration overviews should behave exactly as before
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Prevented repeated refetching of integration data after handled
authorization/403 errors, avoiding refetch loops on remount.
* Improved consistency on integration landing screens by standardizing
how related integration queries are enabled and retried.
* **Enhancements**
* Added permission-aware loading/error handling for OAuth integration
data, showing OAuth results only when the selected organization grants
read access.
* **Chores**
* Updated permission-check typings to treat an explicitly empty project
reference as absent.
* **Tests**
* Extended integration settings tests with permission fixtures to cover
OAuth read access.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
import { parseSchemaComment } from '@stripe/sync-engine/supabase'
|
|
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useMemo } from 'react'
|
|
|
|
import { type WrapperMeta } from '../Wrappers/Wrappers.types'
|
|
import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils'
|
|
import { type IntegrationDefinition } from './Integrations.constants'
|
|
import {
|
|
isInstalled as checkIsInstalled,
|
|
findStripeSchema,
|
|
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
|
|
import { useAPIKeysQuery, type APIKey } from '@/data/api-keys/api-keys-query'
|
|
import { ProjectAuthConfigData, useAuthConfigQuery } from '@/data/auth/auth-config-query'
|
|
import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query'
|
|
import { type Schema } from '@/data/database/schemas-query'
|
|
import { type FDW } from '@/data/fdw/fdws-query'
|
|
import { AuthorizedApp, useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query'
|
|
import {
|
|
IntegrationStatus,
|
|
usePartnerIntegrationsQuery,
|
|
} from '@/data/partners/integration-status-query'
|
|
import { useSecretsQuery, type ProjectSecret } from '@/data/secrets/secrets-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { ResponseError } from '@/types'
|
|
|
|
export const isStripeSyncEngineInstalled = (schemas: Schema[]) => {
|
|
const stripeSchema = findStripeSchema(schemas)
|
|
const parsedSchema = parseSchemaComment(stripeSchema?.comment)
|
|
return checkIsInstalled(parsedSchema.status)
|
|
}
|
|
|
|
export type ProjectOAuthIntegrationData = {
|
|
apiKeys: APIKey[]
|
|
edgeFunctionSecrets: ProjectSecret[]
|
|
authConfig: ProjectAuthConfigData | null
|
|
partnerIntegrations: IntegrationStatus[]
|
|
oauthApps: AuthorizedApp[]
|
|
}
|
|
|
|
const isPermissionError = (error: unknown): error is ResponseError =>
|
|
error instanceof ResponseError && error.code === 403
|
|
|
|
/**
|
|
* Reinterprets a 403 from an existing query as a fallback value, for graceful degradation of the
|
|
* marketplace UI for users in roles that don't have access to certain resources.
|
|
*/
|
|
function permissionSafeData<TData, TDefault>(
|
|
data: TData | undefined,
|
|
error: ResponseError | null,
|
|
defaultVal: TDefault
|
|
): TData | TDefault {
|
|
if (isPermissionError(error)) return defaultVal
|
|
return data ?? defaultVal
|
|
}
|
|
|
|
/**
|
|
* Gathers all the information needed to determine if an arbitrary OAuth integration is installed.
|
|
*/
|
|
export const useProjectOAuthIntegrationData = (
|
|
projectRef: string | undefined,
|
|
{ enabled = true } = {}
|
|
): {
|
|
data: ProjectOAuthIntegrationData
|
|
error: ResponseError | null
|
|
isError: boolean
|
|
isLoading: boolean
|
|
isPending: boolean
|
|
isSuccess: boolean
|
|
} => {
|
|
const { data: org } = useSelectedOrganizationQuery({ enabled })
|
|
|
|
// Any error here has to be terminal on mount. An errored query holds no data, so it never counts
|
|
// as fresh and refetches on every consumer mount — and consumers that gate rendering on
|
|
// `isLoading` remount on each refetch, which loops. Transient failures still recover: the retry
|
|
// policy gives 5xx three attempts, and refetch-on-focus/reconnect are staleness-driven, so they
|
|
// are unaffected by this.
|
|
const { can: canReadOAuthApps } = useAsyncCheckPermissions(
|
|
PermissionAction.READ,
|
|
'oauth_apps',
|
|
undefined,
|
|
{
|
|
organizationSlug: org?.slug,
|
|
projectRef: null,
|
|
}
|
|
)
|
|
|
|
const sharedOptions = { enabled, retryOnMount: false }
|
|
const queries = {
|
|
apiKeys: useAPIKeysQuery({ projectRef, reveal: false }, sharedOptions),
|
|
edgeFunctionSecrets: useSecretsQuery({ projectRef }, sharedOptions),
|
|
authConfig: useAuthConfigQuery({ projectRef }, sharedOptions),
|
|
partnerIntegrations: usePartnerIntegrationsQuery({ projectRef }, sharedOptions),
|
|
oauthApps: useAuthorizedAppsQuery(
|
|
{ slug: org?.slug },
|
|
{ ...sharedOptions, enabled: canReadOAuthApps && enabled && !!org }
|
|
),
|
|
}
|
|
|
|
const data = useMemo(() => {
|
|
return {
|
|
apiKeys: permissionSafeData(queries.apiKeys.data, queries.apiKeys.error, []),
|
|
edgeFunctionSecrets: permissionSafeData(
|
|
queries.edgeFunctionSecrets.data,
|
|
queries.edgeFunctionSecrets.error,
|
|
[]
|
|
),
|
|
authConfig: permissionSafeData(queries.authConfig.data, queries.authConfig.error, null),
|
|
partnerIntegrations: permissionSafeData(
|
|
queries.partnerIntegrations.data,
|
|
queries.partnerIntegrations.error,
|
|
[]
|
|
),
|
|
oauthApps: permissionSafeData(queries.oauthApps.data, queries.oauthApps.error, []),
|
|
}
|
|
}, [
|
|
queries.apiKeys.data,
|
|
queries.apiKeys.error,
|
|
queries.edgeFunctionSecrets.data,
|
|
queries.edgeFunctionSecrets.error,
|
|
queries.authConfig.data,
|
|
queries.authConfig.error,
|
|
queries.partnerIntegrations.data,
|
|
queries.partnerIntegrations.error,
|
|
queries.oauthApps.data,
|
|
queries.oauthApps.error,
|
|
])
|
|
|
|
// A 403 is intentionally handled (the resource degrades to its fallback), so it should not
|
|
// surface as an error to consumers, and a query that 403s still counts as "settled".
|
|
const queryList = Object.values(queries)
|
|
const unhandledErrors = queryList.map((q) => q.error).filter((e) => !!e && !isPermissionError(e))
|
|
|
|
return {
|
|
data,
|
|
error: unhandledErrors[0] ?? null,
|
|
isError: unhandledErrors.length > 0,
|
|
isLoading: queryList.some((q) => q.isLoading),
|
|
isPending: queryList.some((q) => q.isPending),
|
|
isSuccess: queryList.every((q) => q.isSuccess || isPermissionError(q.error)),
|
|
}
|
|
}
|
|
|
|
const isPartnerIntegrationReady = (
|
|
projectData: ProjectOAuthIntegrationData,
|
|
integration: IntegrationDefinition
|
|
) => {
|
|
return projectData.partnerIntegrations.some(
|
|
(i) => i.listing_slug === integration.id && i.status === 'ready'
|
|
)
|
|
}
|
|
|
|
const isOAuthAppAuthorized = (
|
|
projectData: ProjectOAuthIntegrationData,
|
|
integration: IntegrationDefinition
|
|
) => {
|
|
return (
|
|
!!integration.oauthAppId &&
|
|
projectData.oauthApps.some((app) => app.app_id === integration.oauthAppId)
|
|
)
|
|
}
|
|
|
|
const isSecretKeyPrefixPresent = (projectData: ProjectOAuthIntegrationData, prefix?: string) => {
|
|
if (!prefix) return false
|
|
return projectData.apiKeys.some((key) => key.type === 'secret' && key.name.startsWith(prefix))
|
|
}
|
|
|
|
const isEdgeFunctionSecretPresent = (
|
|
projectData: ProjectOAuthIntegrationData,
|
|
secretName?: string
|
|
) => {
|
|
if (!secretName) return false
|
|
return projectData.edgeFunctionSecrets.some((secret) => secret.name === secretName)
|
|
}
|
|
|
|
export const isOAuthInstalled = ({
|
|
integration,
|
|
projectData,
|
|
}: {
|
|
integration: IntegrationDefinition
|
|
projectData: ProjectOAuthIntegrationData
|
|
}) => {
|
|
// Special-case logic for in-development integrations
|
|
if (integration.id === 'resend') {
|
|
return (
|
|
projectData.authConfig?.SMTP_HOST === 'smtp.resend.com' &&
|
|
// Keying off of OAuth App instead of integration status lets us show the integration as
|
|
// installed for partner-initiated connections, without degrading the experience for
|
|
// marketplace-initiated installations.
|
|
isOAuthAppAuthorized(projectData, integration)
|
|
)
|
|
}
|
|
|
|
if (integration.id === 'grafana' || integration.id === 'grafana-cloud') {
|
|
// Grafana is not yet sending integration status, so just use presence of API key.
|
|
return (
|
|
isOAuthAppAuthorized(projectData, integration) ||
|
|
isSecretKeyPrefixPresent(projectData, 'grafana_cloud_integration_')
|
|
)
|
|
}
|
|
|
|
if (integration.id === 'aikido') {
|
|
return isOAuthAppAuthorized(projectData, integration)
|
|
}
|
|
|
|
if (integration.id === 'doppler') {
|
|
return (
|
|
isOAuthAppAuthorized(projectData, integration) ||
|
|
isEdgeFunctionSecretPresent(projectData, 'DOPPLER_CONFIG') ||
|
|
isPartnerIntegrationReady(projectData, integration)
|
|
)
|
|
}
|
|
|
|
// Fallback logic for generic OAuth integrations.
|
|
if (integration.installIdentificationMethod === 'integration_status') {
|
|
return isPartnerIntegrationReady(projectData, integration)
|
|
}
|
|
|
|
if (integration.installIdentificationMethod === 'oauth_authorization') {
|
|
return isOAuthAppAuthorized(projectData, integration)
|
|
}
|
|
|
|
// Special-case logic that is still encoded as database fields, consider removing.
|
|
if (integration.installIdentificationMethod === 'secret_key_prefix') {
|
|
return isSecretKeyPrefixPresent(projectData, integration.secretKeyPrefix)
|
|
}
|
|
|
|
if (integration.installIdentificationMethod === 'edge_function_secret_name') {
|
|
return isEdgeFunctionSecretPresent(projectData, integration.edgeFunctionSecretName)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* A resource associated with a specific integration provisions that can be managed by the user
|
|
*/
|
|
export type ConnectedResource =
|
|
| { kind: 'oauth_app'; key: string; title: string; description: string; app: AuthorizedApp }
|
|
| { kind: 'api_key'; key: string; title: string; description: string; apiKey: APIKey }
|
|
| {
|
|
kind: 'edge_function_secret'
|
|
key: string
|
|
title: string
|
|
description: string
|
|
secret: ProjectSecret
|
|
}
|
|
| { kind: 'smtp'; key: string; title: string; description: string }
|
|
|
|
export type ConnectedResourceKind = ConnectedResource['kind']
|
|
|
|
/**
|
|
* Integration-specific copy that explains how a particular partner uses a kind of connected
|
|
* resource. When present it replaces the generic, kind-level copy shown on the integration's
|
|
* settings tab so users understand what the resource does for that integration specifically
|
|
* (e.g. that Grafana uses a secret API key to read project metrics).
|
|
*/
|
|
export type ConnectedResourceUsage = {
|
|
/** Explains how this integration uses the resource. Overrides the generic section description. */
|
|
description?: string
|
|
/** Describes the impact of removing the resource. Overrides the generic section note. */
|
|
removalWarning?: string
|
|
/**
|
|
* Describes the impact of this resource being missing while the integration is otherwise still
|
|
* connected. Shown in the resource's zero (missing) state. Overrides the generic absent note.
|
|
*/
|
|
noteWhenAbsent?: string
|
|
}
|
|
|
|
type IntegrationResourceOverride = {
|
|
secretKeyPrefix?: string
|
|
edgeFunctionSecretName?: string
|
|
resendSmtp?: boolean
|
|
/** Per-resource-kind explanations of how this integration uses each connected resource. */
|
|
usage?: Partial<Record<ConnectedResourceKind, ConnectedResourceUsage>>
|
|
}
|
|
|
|
/**
|
|
* Temporary manual overrides for specific integrations.
|
|
* TODO(integrations-team) to move logic to database
|
|
* Complements the special-case logic in {@link isOAuthInstalled}.
|
|
*/
|
|
const INTEGRATION_RESOURCE_OVERRIDES: Record<string, IntegrationResourceOverride> = {
|
|
grafana: {
|
|
secretKeyPrefix: 'grafana_cloud_integration_',
|
|
usage: {
|
|
oauth_app: {
|
|
description:
|
|
'Grants Grafana access to your organization so it can discover projects to monitor.',
|
|
},
|
|
api_key: {
|
|
description:
|
|
'Grafana uses this secret API key to read your project metrics from the Prometheus-compatible metrics endpoint.',
|
|
removalWarning:
|
|
'Removing this key stops Grafana from collecting metrics from your project until a new key is connected.',
|
|
noteWhenAbsent:
|
|
'No secret API key is connected for Grafana to read your project metrics. Dashboards will not receive data without one.',
|
|
},
|
|
},
|
|
},
|
|
doppler: {
|
|
edgeFunctionSecretName: 'DOPPLER_CONFIG',
|
|
usage: {
|
|
oauth_app: {
|
|
description:
|
|
'Grants Doppler access to your organization so it can update secrets in your projects.',
|
|
noteWhenAbsent:
|
|
'Doppler does not have access to update secrets in your project. Any changes you make to secrets in Doppler will not be reflected in your project until access is granted.',
|
|
},
|
|
edge_function_secret: {
|
|
description:
|
|
'Doppler syncs your managed secrets into this Edge Function secret so they are available to your functions at runtime.',
|
|
removalWarning:
|
|
'Connected secrets that are removed while this integration is active may be resynced if still present in Doppler.',
|
|
noteWhenAbsent: 'No Edge Function secrets were found connected to this integration.',
|
|
},
|
|
},
|
|
},
|
|
resend: {
|
|
resendSmtp: true,
|
|
usage: {
|
|
oauth_app: {
|
|
description:
|
|
'Grants Resend access to manage the custom SMTP configuration used to send your project emails.',
|
|
},
|
|
smtp: {
|
|
description:
|
|
'Resend is configured as the custom SMTP relay your project uses to deliver authentication and transactional emails.',
|
|
noteWhenAbsent:
|
|
"SMTP settings for Resend were not detected. Authentication emails may not be sent through Resend's SMTP service.",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
/**
|
|
* The connected resource kinds an integration is expected to provision, in display order. Derived
|
|
* from the same identifiers used by {@link getConnectedResources} and {@link isOAuthInstalled} so
|
|
* the settings tab can render a zero (missing) state for any expected resource that is absent.
|
|
*/
|
|
export const getExpectedResourceKinds = (
|
|
integration: IntegrationDefinition
|
|
): ConnectedResourceKind[] => {
|
|
const overrides = INTEGRATION_RESOURCE_OVERRIDES[integration.id] ?? {}
|
|
const kinds: ConnectedResourceKind[] = []
|
|
|
|
if (integration.oauthAppId) kinds.push('oauth_app')
|
|
if (overrides.secretKeyPrefix ?? integration.secretKeyPrefix) kinds.push('api_key')
|
|
if (overrides.edgeFunctionSecretName ?? integration.edgeFunctionSecretName) {
|
|
kinds.push('edge_function_secret')
|
|
}
|
|
if (overrides.resendSmtp) kinds.push('smtp')
|
|
|
|
return kinds
|
|
}
|
|
|
|
/**
|
|
* Returns the integration-specific usage copy for a connected resource kind, if one has been
|
|
* defined in {@link INTEGRATION_RESOURCE_OVERRIDES}. Callers fall back to generic, kind-level
|
|
* copy when this returns `undefined`.
|
|
*/
|
|
export const getConnectedResourceUsage = (
|
|
integrationId: string,
|
|
kind: ConnectedResourceKind
|
|
): ConnectedResourceUsage | undefined =>
|
|
INTEGRATION_RESOURCE_OVERRIDES[integrationId]?.usage?.[kind]
|
|
|
|
/**
|
|
* Collects every resource an OAuth integration has provisioned on the current project/organization
|
|
* so it can be displayed and removed from the integration's settings tab. Keyed off the same data
|
|
* and identifiers used by {@link isOAuthInstalled}.
|
|
*/
|
|
export const getConnectedResources = ({
|
|
integration,
|
|
projectData,
|
|
}: {
|
|
integration: IntegrationDefinition
|
|
projectData: ProjectOAuthIntegrationData
|
|
}): ConnectedResource[] => {
|
|
const overrides = INTEGRATION_RESOURCE_OVERRIDES[integration.id] ?? {}
|
|
const resources: ConnectedResource[] = []
|
|
|
|
// OAuth apps
|
|
if (integration.oauthAppId) {
|
|
const app = projectData.oauthApps.find((a) => a.app_id === integration.oauthAppId)
|
|
if (app) {
|
|
resources.push({
|
|
kind: 'oauth_app',
|
|
key: `oauth_app:${app.id}`,
|
|
title: 'OAuth application',
|
|
description: `Grants ${integration.name} access to your organization and its projects.`,
|
|
app,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Secret API keys
|
|
const secretKeyPrefix = overrides.secretKeyPrefix ?? integration.secretKeyPrefix
|
|
if (secretKeyPrefix) {
|
|
projectData.apiKeys
|
|
.filter((key) => key.type === 'secret' && key.name.startsWith(secretKeyPrefix))
|
|
.forEach((apiKey) => {
|
|
resources.push({
|
|
kind: 'api_key',
|
|
key: `api_key:${apiKey.id}`,
|
|
title: 'Secret API key',
|
|
description: apiKey.name,
|
|
apiKey,
|
|
})
|
|
})
|
|
}
|
|
|
|
// Edge Function secrets
|
|
const edgeFunctionSecretName =
|
|
overrides.edgeFunctionSecretName ?? integration.edgeFunctionSecretName
|
|
if (edgeFunctionSecretName) {
|
|
const secret = projectData.edgeFunctionSecrets.find((s) => s.name === edgeFunctionSecretName)
|
|
if (secret) {
|
|
resources.push({
|
|
kind: 'edge_function_secret',
|
|
key: `edge_function_secret:${secret.name}`,
|
|
title: 'Edge Function secret',
|
|
description: secret.name,
|
|
secret,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Custom SMTP relay
|
|
if (overrides.resendSmtp && projectData.authConfig?.SMTP_HOST === 'smtp.resend.com') {
|
|
resources.push({
|
|
kind: 'smtp',
|
|
key: 'smtp',
|
|
title: 'SMTP settings',
|
|
description: `Custom SMTP relay configured to send project emails through ${integration.name}.`,
|
|
})
|
|
}
|
|
|
|
return resources
|
|
}
|
|
|
|
export const hasMatchingWrapper = ({ meta, wrappers }: { meta: WrapperMeta; wrappers: FDW[] }) => {
|
|
return wrappers.find((w) => wrapperMetaComparator(meta, w))
|
|
}
|
|
|
|
export const hasRequiredExtensions = ({
|
|
integration,
|
|
extensions,
|
|
}: {
|
|
integration: IntegrationDefinition
|
|
extensions: DatabaseExtension[]
|
|
}) => {
|
|
return integration.requiredExtensions.every((extName) => {
|
|
const foundExtension = extensions.find((ext) => ext.name === extName)
|
|
return !!foundExtension?.installed_version
|
|
})
|
|
}
|