diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 7371cded9cb..1e8c61f6168 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -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) => { setShowAccessModal(true)} + onClick={() => onManageAccess(member)} tooltip={{ content: { side: 'bottom', @@ -291,12 +309,6 @@ export const MemberActions = ({ member }: MemberActionsProps) => { {selectedOrganization?.name}?

- - setShowAccessModal(false)} - /> ) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index eab49d5d7a6..ead944a213a 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -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) => { - + ) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 867e13c2e27..47a347a9057 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -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() + 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) => { , ] : []), - ...(!!user ? [] : []), + ...(!!user + ? [ + , + ] + : []), ...sortedMembers.map((member) => ( - + )), ...(searchString.length > 0 && filteredMembers.length === 0 ? [ @@ -156,6 +208,14 @@ const MembersView = ({ searchString }: MembersViewProps) => { )} + + {memberForRoleUpdate && ( + setShowRoleUpdatePanel(false)} + /> + )} ) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index afa81dc1426..3d3eebce77c 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -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={} 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 = () => { )} - + diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts index 74b1702e286..343d5285316 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts @@ -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) }) diff --git a/apps/studio/hooks/misc/useCheckPermissions.test.ts b/apps/studio/hooks/misc/useCheckPermissions.test.ts new file mode 100644 index 00000000000..3ac0de113b4 --- /dev/null +++ b/apps/studio/hooks/misc/useCheckPermissions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { doPermissionsCheck } from './useCheckPermissions' +import type { Permission } from '@/types' + +function permission(overrides: Partial): 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) + }) +}) diff --git a/apps/studio/hooks/misc/useCheckPermissions.ts b/apps/studio/hooks/misc/useCheckPermissions.ts index 68868edae3c..f7ed7aa5936 100644 --- a/apps/studio/hooks/misc/useCheckPermissions.ts +++ b/apps/studio/hooks/misc/useCheckPermissions.ts @@ -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() +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 }) }