mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
fix(studio): eliminate per-row query duplication on org team page (#50238)
## Summary * Fixes extreme slowness (browser-crashing on filter) on `/org/[slug]/team` for orgs with 200+ members. * Root cause: `MemberRow`/`MemberActions` each independently subscribed to org-wide React Query data (roles, projects, permissions, feature flags) and rendered a hidden `UpdateRolesPanel` per row. Filtering caused hundreds of duplicate query observers to mount/unmount on every keystroke, each scheduling its own stale-timeout bookkeeping and blocking the main thread for multiple seconds. * Hoisted all org-wide data fetching (`useOrganizationRolesV2Query`, `useOrgProjectsInfiniteQuery`, `usePermissionsQuery`, `useSelectedOrganizationQuery`, `useIsFeatureEnabled`) to `MembersView` and passed the results down as props. * Replaced the per-row `useAsyncCheckPermissions` hook calls in `MemberActions` with the underlying pure `doPermissionsCheck` function memoized locally, removing their internal query subscriptions. * Simplified `useGetRolesManagementPermissions` to stop calling a query-fetching fallback hook that was unreachable given all current call sites already pass `permissions`/`orgSlug` directly. * Replaced 200 hidden per-row `UpdateRolesPanel` instances with a single shared instance owned by `MembersView`, opened via an `onManageAccess` callback. * Cached the regex built by `doPermissionsCheck`'s `toRegexpString` instead of rebuilding it on every permission check. Diagnosed from two Chrome performance traces of the team page while typing in the filter box (multi-second main-thread blocking tasks traced to React Query `QueryObserver` mount/unmount storms). ## Test plan - [X] `tsc --noEmit` clean (only one pre-existing, unrelated error in `packages/ui-patterns`) - [X] `eslint` clean on all changed files (only pre-existing warnings) - [X] `vitest run tests/components/Organization/TeamSettings` — 51 tests pass - [X] Manually verify filtering is smooth on an org with 200+ members <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Team Settings provides centralized member access and role management. * Members can update roles through the access-management panel. * **Improvements** * Permission checks now more accurately handle organization and project scopes, including wildcard patterns. * Member search is debounced for smoother filtering while typing. * Access-management actions use current organization members, roles, permissions, and feature settings. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
9b1dddde11
commit
82d7d347c4
7 files changed
+276
-76
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useIsLoggedIn, useParams } from 'common'
|
||||
import { MoreVertical, Redo2, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
@@ -14,38 +14,42 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { LeaveTeamButton } from './LeaveTeamButton'
|
||||
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
|
||||
import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
|
||||
import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
|
||||
import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
|
||||
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
|
||||
import type { OrganizationRolesResponse } from '@/data/organization-members/organization-roles-query'
|
||||
import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
|
||||
import {
|
||||
useOrganizationMembersQuery,
|
||||
type OrganizationMember,
|
||||
} from '@/data/organizations/organization-members-query'
|
||||
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import type { OrganizationMember } from '@/data/organizations/organization-members-query'
|
||||
import type { OrganizationBase } from '@/data/organizations/organizations-query'
|
||||
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
interface MemberActionsProps {
|
||||
member: OrganizationMember
|
||||
members: OrganizationMember[]
|
||||
allRoles: OrganizationRolesResponse | undefined
|
||||
permissions: Permission[] | undefined
|
||||
selectedOrganization: OrganizationBase | undefined
|
||||
organizationMembersDeletionEnabled: boolean
|
||||
onManageAccess: (member: OrganizationMember) => void
|
||||
}
|
||||
|
||||
export const MemberActions = ({ member }: MemberActionsProps) => {
|
||||
export const MemberActions = ({
|
||||
member,
|
||||
members,
|
||||
allRoles,
|
||||
permissions,
|
||||
selectedOrganization,
|
||||
organizationMembersDeletionEnabled,
|
||||
onManageAccess,
|
||||
}: MemberActionsProps) => {
|
||||
const { slug } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const [showAccessModal, setShowAccessModal] = useState(false)
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
|
||||
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { data: allRoles } = useOrganizationRolesV2Query({ slug })
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
|
||||
const memberIsUser = member.gotrue_id == profile?.gotrue_id
|
||||
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
|
||||
@@ -66,18 +70,32 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
|
||||
const roleId = member.role_ids?.[0] ?? -1
|
||||
const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id))
|
||||
|
||||
const { can: canCreateUserInvites } = useAsyncCheckPermissions(
|
||||
PermissionAction.CREATE,
|
||||
'user_invites',
|
||||
{ resource: { role_id: roleId } }
|
||||
)
|
||||
const canCreateUserInvites = useMemo(() => {
|
||||
if (!IS_PLATFORM) return true
|
||||
if (!isLoggedIn) return false
|
||||
if (!permissions) return false
|
||||
return doPermissionsCheck(
|
||||
permissions,
|
||||
PermissionAction.CREATE,
|
||||
'user_invites',
|
||||
{ resource: { role_id: roleId } },
|
||||
selectedOrganization?.slug
|
||||
)
|
||||
}, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
|
||||
const canResendInvite = canCreateUserInvites && hasOrgRole
|
||||
|
||||
const { can: canDeleteUserInvites } = useAsyncCheckPermissions(
|
||||
PermissionAction.DELETE,
|
||||
'user_invites',
|
||||
{ resource: { role_id: roleId } }
|
||||
)
|
||||
const canDeleteUserInvites = useMemo(() => {
|
||||
if (!IS_PLATFORM) return true
|
||||
if (!isLoggedIn) return false
|
||||
if (!permissions) return false
|
||||
return doPermissionsCheck(
|
||||
permissions,
|
||||
PermissionAction.DELETE,
|
||||
'user_invites',
|
||||
{ resource: { role_id: roleId } },
|
||||
selectedOrganization?.slug
|
||||
)
|
||||
}, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
|
||||
const canRevokeInvite = canDeleteUserInvites && hasOrgRole
|
||||
|
||||
const { mutate: deleteOrganizationMember, isPending: isDeletingMember } =
|
||||
@@ -165,7 +183,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
disabled={isPendingInviteAcceptance || !canRemoveMember}
|
||||
onClick={() => setShowAccessModal(true)}
|
||||
onClick={() => onManageAccess(member)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
@@ -291,12 +309,6 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
|
||||
<span className="text-foreground">{selectedOrganization?.name}</span>?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<UpdateRolesPanel
|
||||
visible={showAccessModal}
|
||||
member={member}
|
||||
onClose={() => setShowAccessModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useParams } from 'common'
|
||||
import { ArrowRight, Check, ChevronRight, User, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
cn,
|
||||
@@ -18,34 +16,44 @@ import { isInviteExpired } from '../Organization.utils'
|
||||
import { MemberActions } from './MemberActions'
|
||||
import PartnerIcon from '@/components/ui/PartnerIcon'
|
||||
import { ProfileImage } from '@/components/ui/ProfileImage'
|
||||
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
|
||||
import { OrganizationRolesResponse } from '@/data/organization-members/organization-roles-query'
|
||||
import { OrganizationMember } from '@/data/organizations/organization-members-query'
|
||||
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { OrganizationBase } from '@/data/organizations/organizations-query'
|
||||
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
interface MemberRowProps {
|
||||
member: OrganizationMember
|
||||
members: OrganizationMember[]
|
||||
roles: OrganizationRolesResponse | undefined
|
||||
isLoadingRoles: boolean
|
||||
orgProjects: OrgProject[]
|
||||
permissions: Permission[] | undefined
|
||||
selectedOrganization: OrganizationBase | undefined
|
||||
organizationMembersDeletionEnabled: boolean
|
||||
onManageAccess: (member: OrganizationMember) => void
|
||||
}
|
||||
|
||||
const MEMBER_ORIGIN_TO_MANAGED_BY = {
|
||||
vercel: 'vercel-marketplace',
|
||||
} as const
|
||||
|
||||
export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
const { slug } = useParams()
|
||||
export const MemberRow = ({
|
||||
member,
|
||||
members,
|
||||
roles,
|
||||
isLoadingRoles,
|
||||
orgProjects,
|
||||
permissions,
|
||||
selectedOrganization,
|
||||
organizationMembersDeletionEnabled,
|
||||
onManageAccess,
|
||||
}: MemberRowProps) => {
|
||||
const { profile } = useProfile()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
|
||||
const { data: roles, isPending: isLoadingRoles } = useOrganizationRolesV2Query({
|
||||
slug: selectedOrganization?.slug,
|
||||
})
|
||||
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
|
||||
|
||||
const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
|
||||
const orgProjects =
|
||||
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
|
||||
|
||||
const isInvitedUser = Boolean(member.invited_id)
|
||||
|
||||
// Use generic avatar for all team members instead of attempting to fetch from GitHub
|
||||
@@ -188,7 +196,15 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<MemberActions member={member} />
|
||||
<MemberActions
|
||||
member={member}
|
||||
members={members}
|
||||
allRoles={roles}
|
||||
permissions={permissions}
|
||||
selectedOrganization={selectedOrganization}
|
||||
organizationMembersDeletionEnabled={organizationMembersDeletionEnabled}
|
||||
onManageAccess={onManageAccess}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import { partition } from 'lodash'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Loading,
|
||||
@@ -17,9 +17,17 @@ import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { MemberRow } from './MemberRow'
|
||||
import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
|
||||
import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
|
||||
import {
|
||||
useOrganizationMembersQuery,
|
||||
type OrganizationMember,
|
||||
} from '@/data/organizations/organization-members-query'
|
||||
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
|
||||
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
|
||||
export interface MembersViewProps {
|
||||
@@ -40,12 +48,23 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
const {
|
||||
data: roles,
|
||||
error: rolesError,
|
||||
isPending: isLoadingRoles,
|
||||
isSuccess: isSuccessRoles,
|
||||
isError: isErrorRoles,
|
||||
} = useOrganizationRolesV2Query({
|
||||
slug,
|
||||
})
|
||||
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
|
||||
|
||||
const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
|
||||
const orgProjects = useMemo(
|
||||
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
|
||||
[projectsData?.pages]
|
||||
)
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
return !searchString
|
||||
? members
|
||||
@@ -67,6 +86,13 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
(m) => m.gotrue_id === profile?.gotrue_id
|
||||
)
|
||||
|
||||
const [memberForRoleUpdate, setMemberForRoleUpdate] = useState<OrganizationMember>()
|
||||
const [showRoleUpdatePanel, setShowRoleUpdatePanel] = useState(false)
|
||||
const handleManageAccess = (member: OrganizationMember) => {
|
||||
setMemberForRoleUpdate(member)
|
||||
setShowRoleUpdatePanel(true)
|
||||
}
|
||||
|
||||
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
|
||||
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
|
||||
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
|
||||
@@ -122,9 +148,35 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
</TableRow>,
|
||||
]
|
||||
: []),
|
||||
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
|
||||
...(!!user
|
||||
? [
|
||||
<MemberRow
|
||||
key={user.gotrue_id}
|
||||
member={user}
|
||||
members={members}
|
||||
roles={roles}
|
||||
isLoadingRoles={isLoadingRoles}
|
||||
orgProjects={orgProjects}
|
||||
permissions={permissions}
|
||||
selectedOrganization={selectedOrganization}
|
||||
organizationMembersDeletionEnabled={organizationMembersDeletionEnabled}
|
||||
onManageAccess={handleManageAccess}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
...sortedMembers.map((member) => (
|
||||
<MemberRow key={member.gotrue_id} member={member} />
|
||||
<MemberRow
|
||||
key={member.gotrue_id}
|
||||
member={member}
|
||||
members={members}
|
||||
roles={roles}
|
||||
isLoadingRoles={isLoadingRoles}
|
||||
orgProjects={orgProjects}
|
||||
permissions={permissions}
|
||||
selectedOrganization={selectedOrganization}
|
||||
organizationMembersDeletionEnabled={organizationMembersDeletionEnabled}
|
||||
onManageAccess={handleManageAccess}
|
||||
/>
|
||||
)),
|
||||
...(searchString.length > 0 && filteredMembers.length === 0
|
||||
? [
|
||||
@@ -156,6 +208,14 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memberForRoleUpdate && (
|
||||
<UpdateRolesPanel
|
||||
visible={showRoleUpdatePanel}
|
||||
member={memberForRoleUpdate}
|
||||
onClose={() => setShowRoleUpdatePanel(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
@@ -24,6 +25,8 @@ export const TeamSettings = () => {
|
||||
const { slug } = useParams()
|
||||
const [searchString, setSearchString] = useState('')
|
||||
|
||||
const debouncedSearch = useDebounce(searchString, 500)
|
||||
|
||||
const { data: roles } = useOrganizationRolesV2Query({ slug })
|
||||
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
|
||||
|
||||
@@ -42,7 +45,7 @@ export const TeamSettings = () => {
|
||||
autoComplete="off"
|
||||
icon={<Search />}
|
||||
value={searchString}
|
||||
onChange={(e: any) => setSearchString(e.target.value)}
|
||||
onChange={(e) => setSearchString(e.target.value)}
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="Filter members"
|
||||
@@ -62,7 +65,9 @@ export const TeamSettings = () => {
|
||||
)}
|
||||
|
||||
<ScaffoldSectionContent className="w-full">
|
||||
<MembersView searchString={searchString} />
|
||||
<MembersView
|
||||
searchString={searchString.length === 0 ? searchString : debouncedSearch}
|
||||
/>
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldFilterAndContent>
|
||||
</ScaffoldSection>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import type { OrganizationMember } from '@/data/organizations/organization-members-query'
|
||||
import { doPermissionsCheck, useGetPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
|
||||
import type { Permission, Role } from '@/types'
|
||||
|
||||
export const useGetRolesManagementPermissions = (
|
||||
@@ -9,36 +9,30 @@ export const useGetRolesManagementPermissions = (
|
||||
roles?: Role[],
|
||||
permissions?: Permission[]
|
||||
): { rolesAddable: Number[]; rolesRemovable: Number[] } => {
|
||||
const { permissions: allPermissions, organizationSlug } = useGetPermissions(
|
||||
permissions,
|
||||
orgSlug,
|
||||
permissions !== undefined && orgSlug !== undefined
|
||||
)
|
||||
|
||||
const rolesAddable: Number[] = []
|
||||
const rolesRemovable: Number[] = []
|
||||
if (!roles || !orgSlug) return { rolesAddable, rolesRemovable }
|
||||
|
||||
roles.forEach((role: Role) => {
|
||||
const canAdd = doPermissionsCheck(
|
||||
allPermissions,
|
||||
permissions,
|
||||
PermissionAction.CREATE,
|
||||
'auth.subject_roles',
|
||||
{
|
||||
resource: { role_id: role.id },
|
||||
},
|
||||
organizationSlug
|
||||
orgSlug
|
||||
)
|
||||
if (canAdd) rolesAddable.push(role.id)
|
||||
|
||||
const canRemove = doPermissionsCheck(
|
||||
allPermissions,
|
||||
permissions,
|
||||
PermissionAction.DELETE,
|
||||
'auth.subject_roles',
|
||||
{
|
||||
resource: { role_id: role.id },
|
||||
},
|
||||
organizationSlug
|
||||
orgSlug
|
||||
)
|
||||
if (canRemove) rolesRemovable.push(role.id)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { doPermissionsCheck } from './useCheckPermissions'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
function permission(overrides: Partial<Permission>): Permission {
|
||||
return {
|
||||
actions: ['read'] as any,
|
||||
condition: null as unknown as Permission['condition'],
|
||||
organization_slug: 'org-slug',
|
||||
resources: ['tables'],
|
||||
restrictive: false,
|
||||
project_refs: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('doPermissionsCheck', () => {
|
||||
it('returns false when permissions are missing', () => {
|
||||
expect(doPermissionsCheck(undefined, 'read', 'tables', undefined, 'org-slug')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches a literal action and resource', () => {
|
||||
const permissions = [permission({ actions: ['read'] as any, resources: ['tables'] })]
|
||||
expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(true)
|
||||
expect(doPermissionsCheck(permissions, 'read', 'columns', undefined, 'org-slug')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats every "." in a resource as literal, not "any character"', () => {
|
||||
// Regression for the incomplete-escaping bug: only the first "." used to get escaped,
|
||||
// so a resource with two dots would let any single character stand in for the second one.
|
||||
const permissions = [permission({ resources: ['queue_job.projects.update_jwt'] })]
|
||||
expect(
|
||||
doPermissionsCheck(
|
||||
permissions,
|
||||
'read',
|
||||
'queue_job.projects.update_jwt',
|
||||
undefined,
|
||||
'org-slug'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
doPermissionsCheck(
|
||||
permissions,
|
||||
'read',
|
||||
'queue_jobXprojectsXupdate_jwt',
|
||||
undefined,
|
||||
'org-slug'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('expands every "%" wildcard in a resource, not just the first one', () => {
|
||||
const permissions = [permission({ resources: ['queue_job.%.%'] })]
|
||||
expect(
|
||||
doPermissionsCheck(permissions, 'read', 'queue_job.restore.prepare', undefined, 'org-slug')
|
||||
).toBe(true)
|
||||
expect(
|
||||
doPermissionsCheck(
|
||||
permissions,
|
||||
'read',
|
||||
'queue_job.walg.prepare_restore',
|
||||
undefined,
|
||||
'org-slug'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a literal backslash in a resource as a literal character, not a regex escape', () => {
|
||||
const permissions = [permission({ resources: ['a\\d'] })]
|
||||
// If the backslash weren't escaped, "\d" would be interpreted as the regex digit class
|
||||
// and incorrectly match "a1".
|
||||
expect(doPermissionsCheck(permissions, 'read', 'a1', undefined, 'org-slug')).toBe(false)
|
||||
expect(doPermissionsCheck(permissions, 'read', 'a\\d', undefined, 'org-slug')).toBe(true)
|
||||
})
|
||||
|
||||
it('denies when a restrictive permission matches, even if a non-restrictive one also matches', () => {
|
||||
const permissions = [
|
||||
permission({ restrictive: false, resources: ['tables'] }),
|
||||
permission({ restrictive: true, resources: ['tables'] }),
|
||||
]
|
||||
expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(false)
|
||||
})
|
||||
|
||||
it('only matches permissions for the given organization', () => {
|
||||
const permissions = [permission({ organization_slug: 'other-org' })]
|
||||
expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers a project-scoped permission over an org-level one when a projectRef is given', () => {
|
||||
const permissions = [
|
||||
permission({ resources: ['tables'], project_refs: [] }),
|
||||
permission({ resources: ['tables'], project_refs: ['project-ref'], restrictive: true }),
|
||||
]
|
||||
expect(
|
||||
doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug', 'project-ref')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,18 @@ 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('%', '.*')}$`
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const regexpCache = new Map<string, RegExp>()
|
||||
const getActionResourceRegexp = (actionOrResource: string) => {
|
||||
let regexp = regexpCache.get(actionOrResource)
|
||||
if (!regexp) {
|
||||
const pattern = actionOrResource.split('%').map(escapeRegExp).join('.*')
|
||||
regexp = new RegExp(`^${pattern}$`)
|
||||
regexpCache.set(actionOrResource, regexp)
|
||||
}
|
||||
return regexp
|
||||
}
|
||||
|
||||
function doPermissionConditionCheck(permissions: Permission[], data?: object) {
|
||||
const isRestricted = permissions
|
||||
@@ -44,8 +54,10 @@ export function doPermissionsCheck(
|
||||
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.actions.some((act) =>
|
||||
action ? getActionResourceRegexp(act).test(action) : null
|
||||
) &&
|
||||
permission.resources.some((res) => getActionResourceRegexp(res).test(resource)) &&
|
||||
permission.project_refs?.includes(projectRef)
|
||||
)
|
||||
if (projectPermissions.length > 0) {
|
||||
@@ -59,8 +71,10 @@ export function doPermissionsCheck(
|
||||
.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.actions.some((act) =>
|
||||
action ? getActionResourceRegexp(act).test(action) : null
|
||||
) &&
|
||||
permission.resources.some((res) => getActionResourceRegexp(res).test(resource))
|
||||
)
|
||||
return doPermissionConditionCheck(orgPermissions, { resource_name: resource, ...data })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user