mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 03:19:36 +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>
196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
import { useIsLoggedIn, useParams } from 'common'
|
|
import jsonLogic from 'json-logic-js'
|
|
import { useMemo } from 'react'
|
|
|
|
import { useSelectedOrganizationQuery } from './useSelectedOrganization'
|
|
import { useSelectedProjectQuery } from './useSelectedProject'
|
|
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
|
|
import { IS_PLATFORM } from '@/lib/constants'
|
|
import type { Permission } from '@/types'
|
|
|
|
const toRegexpString = (actionOrResource: string) =>
|
|
`^${actionOrResource.replace('.', '\\.').replace('%', '.*')}$`
|
|
|
|
function doPermissionConditionCheck(permissions: Permission[], data?: object) {
|
|
const isRestricted = permissions
|
|
.filter((permission) => permission.restrictive)
|
|
.some(
|
|
({ condition }: { condition: jsonLogic.RulesLogic }) =>
|
|
condition === null || jsonLogic.apply(condition, data)
|
|
)
|
|
if (isRestricted) return false
|
|
|
|
return permissions
|
|
.filter((permission) => !permission.restrictive)
|
|
.some(
|
|
({ condition }: { condition: jsonLogic.RulesLogic }) =>
|
|
condition === null || jsonLogic.apply(condition, data)
|
|
)
|
|
}
|
|
|
|
export function doPermissionsCheck(
|
|
permissions: Permission[] | undefined,
|
|
action: string,
|
|
resource: string,
|
|
data?: object,
|
|
organizationSlug?: string,
|
|
projectRef?: string | null
|
|
) {
|
|
if (!permissions || !Array.isArray(permissions)) {
|
|
return false
|
|
}
|
|
|
|
if (projectRef) {
|
|
const projectPermissions = permissions.filter(
|
|
(permission) =>
|
|
permission.organization_slug === organizationSlug &&
|
|
permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) &&
|
|
permission.resources.some((res) => resource.match(toRegexpString(res))) &&
|
|
permission.project_refs?.includes(projectRef)
|
|
)
|
|
if (projectPermissions.length > 0) {
|
|
return doPermissionConditionCheck(projectPermissions, { resource_name: resource, ...data })
|
|
}
|
|
}
|
|
|
|
const orgPermissions = permissions
|
|
// filter out org-level permission
|
|
.filter((permission) => !permission.project_refs || permission.project_refs.length === 0)
|
|
.filter(
|
|
(permission) =>
|
|
permission.organization_slug === organizationSlug &&
|
|
permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) &&
|
|
permission.resources.some((res) => resource.match(toRegexpString(res)))
|
|
)
|
|
return doPermissionConditionCheck(orgPermissions, { resource_name: resource, ...data })
|
|
}
|
|
|
|
export function useGetPermissions(
|
|
permissionsOverride?: Permission[],
|
|
organizationSlugOverride?: string,
|
|
enabled = true
|
|
) {
|
|
return useGetProjectPermissions(permissionsOverride, organizationSlugOverride, undefined, enabled)
|
|
}
|
|
|
|
function useGetProjectPermissions(
|
|
permissionsOverride?: Permission[],
|
|
organizationSlugOverride?: string,
|
|
projectRefOverride?: string | null,
|
|
enabled = true
|
|
) {
|
|
const {
|
|
data,
|
|
isPending: isLoadingPermissions,
|
|
isSuccess: isSuccessPermissions,
|
|
} = usePermissionsQuery({
|
|
enabled: permissionsOverride === undefined && enabled,
|
|
})
|
|
const permissions = permissionsOverride === undefined ? data : permissionsOverride
|
|
|
|
const getOrganizationDataFromParamsSlug = organizationSlugOverride === undefined && enabled
|
|
const {
|
|
data: organizationData,
|
|
isPending: isLoadingOrganization,
|
|
isSuccess: isSuccessOrganization,
|
|
} = useSelectedOrganizationQuery({
|
|
enabled: getOrganizationDataFromParamsSlug,
|
|
})
|
|
const organization =
|
|
organizationSlugOverride === undefined ? organizationData : { slug: organizationSlugOverride }
|
|
const organizationSlug = organization?.slug
|
|
|
|
const { ref: urlProjectRef } = useParams()
|
|
const getProjectDataFromParamsRef = !!urlProjectRef && projectRefOverride === undefined && enabled
|
|
const {
|
|
data: projectData,
|
|
isPending: isLoadingProject,
|
|
isSuccess: isSuccessProject,
|
|
} = useSelectedProjectQuery({
|
|
enabled: getProjectDataFromParamsRef,
|
|
})
|
|
const project =
|
|
projectRefOverride === undefined || projectData?.parent_project_ref
|
|
? projectData
|
|
: { ref: projectRefOverride, parent_project_ref: undefined }
|
|
|
|
const projectRef =
|
|
projectRefOverride === null
|
|
? null
|
|
: project?.parent_project_ref
|
|
? project.parent_project_ref
|
|
: project?.ref
|
|
|
|
const isLoading =
|
|
isLoadingPermissions ||
|
|
(getOrganizationDataFromParamsSlug && isLoadingOrganization) ||
|
|
(getProjectDataFromParamsRef && isLoadingProject)
|
|
const isSuccess =
|
|
isSuccessPermissions &&
|
|
(!getOrganizationDataFromParamsSlug || isSuccessOrganization) &&
|
|
(!getProjectDataFromParamsRef || isSuccessProject)
|
|
|
|
return {
|
|
permissions,
|
|
organizationSlug,
|
|
projectRef,
|
|
isLoading,
|
|
isSuccess,
|
|
}
|
|
}
|
|
|
|
/** [Joshen] To be renamed to be useAsyncCheckPermissions, more generic as it covers both org and project perms */
|
|
// Useful when you want to avoid layout changes while waiting for permissions to load
|
|
export function useAsyncCheckPermissions(
|
|
action: string,
|
|
resource: string,
|
|
data?: object,
|
|
overrides?: {
|
|
organizationSlug?: string
|
|
projectRef?: string | null
|
|
permissions?: Permission[]
|
|
}
|
|
) {
|
|
const isLoggedIn = useIsLoggedIn()
|
|
const { organizationSlug, projectRef, permissions } = overrides ?? {}
|
|
|
|
const {
|
|
permissions: allPermissions,
|
|
organizationSlug: _organizationSlug,
|
|
projectRef: _projectRef,
|
|
isLoading: isPermissionsLoading,
|
|
isSuccess: isPermissionsSuccess,
|
|
} = useGetProjectPermissions(permissions, organizationSlug, projectRef, isLoggedIn)
|
|
|
|
const can = useMemo(() => {
|
|
if (!IS_PLATFORM) return true
|
|
if (!isLoggedIn) return false
|
|
if (!isPermissionsSuccess || !allPermissions) return false
|
|
|
|
return doPermissionsCheck(
|
|
allPermissions,
|
|
action,
|
|
resource,
|
|
data,
|
|
_organizationSlug,
|
|
_projectRef
|
|
)
|
|
}, [
|
|
isLoggedIn,
|
|
isPermissionsSuccess,
|
|
allPermissions,
|
|
action,
|
|
resource,
|
|
data,
|
|
_organizationSlug,
|
|
_projectRef,
|
|
])
|
|
|
|
// Derive loading/success consistently from the same branches
|
|
const isLoading = !IS_PLATFORM ? false : !isLoggedIn ? true : isPermissionsLoading
|
|
|
|
const isSuccess = !IS_PLATFORM ? true : !isLoggedIn ? false : isPermissionsSuccess
|
|
|
|
return { isLoading, isSuccess, can }
|
|
}
|