From ee3e5984233b9b4bc88116a7990c00a960cb155b Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 29 Jul 2022 14:25:35 +0800 Subject: [PATCH] Implement ABAC in greater granularity for roles management in org team members --- .../Organization/Organization.utils.ts | 4 +- ...MemberModal.tsx => InviteMemberButton.tsx} | 115 +++++++++++------- .../{OwnerDropdown.tsx => MemberActions.tsx} | 88 +++++++++++--- .../Organization/TeamSettings/MembersView.tsx | 70 +++++------ .../TeamSettings/TeamSettings.tsx | 29 +++-- .../TeamSettings/TeamSettings.utils.ts | 50 +++++--- studio/pages/org/[slug]/settings.tsx | 12 +- 7 files changed, 232 insertions(+), 136 deletions(-) rename studio/components/interfaces/Organization/TeamSettings/{InviteMemberModal.tsx => InviteMemberButton.tsx} (54%) rename studio/components/interfaces/Organization/TeamSettings/{OwnerDropdown.tsx => MemberActions.tsx} (70%) diff --git a/studio/components/interfaces/Organization/Organization.utils.ts b/studio/components/interfaces/Organization/Organization.utils.ts index 2568d2948f1..a0508fbb40e 100644 --- a/studio/components/interfaces/Organization/Organization.utils.ts +++ b/studio/components/interfaces/Organization/Organization.utils.ts @@ -11,7 +11,9 @@ export function isInviteExpired(timestamp: string) { return true } -export const getUserDisplayName = (user: Member) => { +export const getUserDisplayName = (user?: Member) => { + if (!user) return '' + const { username, primary_email, invited_id } = user || {} return invited_id !== undefined ? primary_email : username || '' } diff --git a/studio/components/interfaces/Organization/TeamSettings/InviteMemberModal.tsx b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx similarity index 54% rename from studio/components/interfaces/Organization/TeamSettings/InviteMemberModal.tsx rename to studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 46a1e3b4c1a..c4623d94276 100644 --- a/studio/components/interfaces/Organization/TeamSettings/InviteMemberModal.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -4,17 +4,19 @@ import { FC, useEffect, useState } from 'react' import { object, string } from 'yup' import { Button, Form, IconMail, Input, Modal, Select } from '@supabase/ui' -import { Member, User } from 'types' -import { useFlag, useOrganizationDetail, useOrganizationRoles, useStore } from 'hooks' +import { Member, User, Role } from 'types' +import { useFlag, useOrganizationDetail, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' interface Props { - members: Member[] user: User + members: Member[] + roles: Role[] + rolesAddable: Number[] } -const InviteMemberModal: FC = ({ members = [], user }) => { +const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAddable = [] }) => { const { ui } = useStore() const router = useRouter() const { slug } = router.query @@ -22,7 +24,6 @@ const InviteMemberModal: FC = ({ members = [], user }) => { const enablePermissions = useFlag('enablePermissions') const [isOpen, setIsOpen] = useState(false) - const { roles } = useOrganizationRoles((slug as string) || '') const { mutateOrgMembers } = useOrganizationDetail((slug as string) || '') const initialValues = { email: '', role: '' } @@ -92,66 +93,86 @@ const InviteMemberModal: FC = ({ members = [], user }) => { <> setIsOpen(false)} header="Invite a member to this organization" - description="Members you'd like to invite must already be registered on Supabase" - layout="vertical" - hideFooter > - -
- {({ isSubmitting, resetForm }: any) => { - // Catches 'roles' when its available and then adds a default value for role select - useEffect(() => { - if (roles) { - resetForm({ - values: { ...initialValues, role: roles[0].id }, - initialValues: { ...initialValues, role: roles[0].id }, - }) - } - }, [roles]) + + {({ values, isSubmitting, resetForm }: any) => { + // Catches 'roles' when its available and then adds a default value for role select + useEffect(() => { + if (roles) { + resetForm({ + values: { ...initialValues, role: roles[0].id }, + initialValues: { ...initialValues, role: roles[0].id }, + }) + } + }, [roles]) - return ( -
-
- {roles && enablePermissions && ( - - )} + const selectedRole = roles.find((role) => role.id === Number(values.role)) + const invalidRoleSelected = enablePermissions + ? values.role && !rolesAddable.includes(Number(values.role)) + : false - } - placeholder="Enter email address" - label="Email address" - /> + return ( + <> + +
+
+
+ {roles && enablePermissions && ( + + )} +
+ } + placeholder="Enter email address" + label="Email address" + /> +
+
+
+ + +
-
- ) - }} - - + + + ) + }} + ) } -export default InviteMemberModal +export default InviteMemberButton diff --git a/studio/components/interfaces/Organization/TeamSettings/OwnerDropdown.tsx b/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx similarity index 70% rename from studio/components/interfaces/Organization/TeamSettings/OwnerDropdown.tsx rename to studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 633ccb64415..08dd9c62ad4 100644 --- a/studio/components/interfaces/Organization/TeamSettings/OwnerDropdown.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -1,9 +1,11 @@ import { FC, useState, useContext } from 'react' import { observer } from 'mobx-react-lite' +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Button, Dropdown, IconTrash, IconMoreHorizontal } from '@supabase/ui' import { Member, Role } from 'types' -import { useStore, useOrganizationDetail, useFlag } from 'hooks' +import { useStore, useOrganizationDetail, useFlag, checkPermissions } from 'hooks' import { delete_, post, patch } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' @@ -18,8 +20,9 @@ interface Props { roles: Role[] } -const OwnerDropdown: FC = ({ members, member, roles }) => { +const MemberActions: FC = ({ members, member, roles }) => { const PageState: any = useContext(PageContext) + const { rolesRemovable } = PageState const { slug, name: orgName } = PageState.organization const { ui } = useStore() @@ -29,6 +32,20 @@ const OwnerDropdown: FC = ({ members, member, roles }) => { const [loading, setLoading] = useState(false) const [ownerTransferIsVisible, setOwnerTransferIsVisible] = useState(false) + const isPendingInviteAcceptance = member.invited_id + + const canRemoveMember = enablePermissions + ? rolesRemovable.includes((member?.role_ids ?? [-1])[0]) + : true + const canResendInvite = checkPermissions( + PermissionAction.SQL_INSERT, + 'postgres.auth.user_invites' + ) + const canRevokeInvite = checkPermissions( + PermissionAction.SQL_DELETE, + 'postgres.auth.user_invites' + ) + const handleMemberDelete = async () => { confirmAlert({ title: 'Confirm to remove', @@ -144,6 +161,31 @@ const OwnerDropdown: FC = ({ members, member, roles }) => { } } + if (!canRemoveMember || (isPendingInviteAcceptance && !canResendInvite && !canRevokeInvite)) { + return ( +
+ + +
+ ) + } + return (
= ({ members, member, roles }) => { align="end" overlay={ <> - {!enablePermissions && !member.invited_at && ( + {!enablePermissions && !isPendingInviteAcceptance && ( <> setOwnerTransferIsVisible(!ownerTransferIsVisible)}>
@@ -162,25 +204,31 @@ const OwnerDropdown: FC = ({ members, member, roles }) => { )} - {member.invited_at ? ( + {isPendingInviteAcceptance ? ( <> - handleRevokeInvitation(member)}> -
-

Cancel invitation

-

Revoke this invitation.

-
-
- - handleResendInvite(member)}> -
-

Resend invitation

-

Invites expire after 24hrs.

-
-
+ {canResendInvite && ( + handleRevokeInvitation(member)}> +
+

Cancel invitation

+

Revoke this invitation.

+
+
+ )} + {canRevokeInvite && ( + <> + + handleResendInvite(member)}> +
+

Resend invitation

+

Invites expire after 24hrs.

+
+
+ + )} ) : ( - } onClick={handleMemberDelete}> - Remove member + } onClick={handleMemberDelete}> +

Remove member

)} @@ -217,4 +265,4 @@ const OwnerDropdown: FC = ({ members, member, roles }) => { ) } -export default observer(OwnerDropdown) +export default observer(MemberActions) diff --git a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 47580740406..19c507abe19 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -3,24 +3,16 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { useState, useContext, Fragment } from 'react' import { observer } from 'mobx-react-lite' import { Badge, Button, Loading, Listbox, IconUser, Modal } from '@supabase/ui' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Member } from 'types' -import { - checkPermissions, - useStore, - useFlag, - useOrganizationDetail, - useOrganizationRoles, -} from 'hooks' +import { Member, Role } from 'types' +import { useStore, useFlag, useOrganizationDetail } from 'hooks' +import { patch } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { isInviteExpired, getUserDisplayName } from '../Organization.utils' import Table from 'components/to-be-cleaned/Table' -import OwnerDropdown from './OwnerDropdown' +import MemberActions from './MemberActions' import { PageContext } from 'pages/org/[slug]/settings' -import { patch } from 'lib/common/fetch' -import { getRolesManagementPermissions } from './TeamSettings.utils' interface SelectedMember extends Member { oldRoleId: number @@ -29,23 +21,23 @@ interface SelectedMember extends Member { const MembersView = () => { const PageState: any = useContext(PageContext) + const { + roles, + rolesAddable, + rolesRemovable, + }: { roles: Role[]; rolesAddable: Number[]; rolesRemovable: Number[] } = PageState const { ui } = useStore() const slug = ui.selectedOrganization?.slug || '' const enablePermissions = useFlag('enablePermissions') - const { roles } = useOrganizationRoles(slug) const { mutateOrgMembers } = useOrganizationDetail(slug) const [loading, setLoading] = useState(false) const [selectedMember, setSelectedMember] = useState() const [userRoleChangeModalVisible, setUserRoleChangeModalVisible] = useState(false) - const rolesPermissions = getRolesManagementPermissions(roles) - const canEditMemberRoles = checkPermissions( - PermissionAction.SQL_INSERT, - 'postgres.auth.subject_roles' - ) + const hasAccessToMemberActions = enablePermissions ? true : PageState.isOrgOwner const getRoleNameById = (id: number | undefined) => { if (!roles) return id @@ -102,28 +94,27 @@ const MembersView = () => { const role = (roles || []).find((role) => role.id === memberRoleId) const memberIsUser = x.primary_email == PageState.user.primary_email const memberIsPendingInvite = !!x.invited_id - const disableRoleEdit = !canEditMemberRoles || memberIsUser || memberIsPendingInvite + const canRemoveRole = rolesRemovable.includes(memberRoleId) + const disableRoleEdit = !canRemoveRole || memberIsUser || memberIsPendingInvite const validateSelectedRoleToChange = (roleId: any) => { if (!role) return const selectedRole = (roles || []).find((role) => role.id === roleId) - const rolePermission = rolesPermissions[roleId] - if (!rolePermission || !rolePermission.canChangeTo) { + const canAddRole = rolesAddable.includes(selectedRole?.id ?? -1) + + if (!canAddRole) { return ui.setNotification({ category: 'error', - message: `You do not have permission to update members to ${ + duration: 4000, + message: `You do not have permission to update this team member to ${ selectedRole!.name }`, }) } setUserRoleChangeModalVisible(true) - setSelectedMember({ - ...x, - oldRoleId: role.id, - newRoleId: roleId, - }) + setSelectedMember({ ...x, oldRoleId: role.id, newRoleId: roleId }) } return ( @@ -203,7 +194,7 @@ const MembersView = () => {
- ) : !canEditMemberRoles ? ( + ) : !canRemoveRole ? (
{ ].join(' ')} > - You need additional permissions to update the role of other - members in this organization + You need additional permissions to update the role of this + team member
@@ -227,8 +218,8 @@ const MembersView = () => { )} - {PageState.isOrgOwner && !memberIsUser && ( - + {hasAccessToMemberActions && !memberIsUser && ( + )} @@ -253,23 +244,24 @@ const MembersView = () => {
setUserRoleChangeModalVisible(false)} header="Change role of member" - size="small" > -
+
-

- By changing the role of this member their permissions will change. -

- You are going to change the role of {selectedMember?.primary_email} from{' '} + You are changing the role of{' '} + {getUserDisplayName(selectedMember)} from{' '} {getRoleNameById(selectedMember?.oldRoleId)}{' '} to{' '} {getRoleNameById(selectedMember?.newRoleId)}

+

+ By changing the role of this member their permissions will change. +

diff --git a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 47e50ce6a0f..2c52e2dbddb 100644 --- a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -2,27 +2,27 @@ import { useState, useContext } from 'react' import { observer } from 'mobx-react-lite' import { Button, Input, IconSearch } from '@supabase/ui' -import { checkPermissions, useFlag, useStore } from 'hooks' +import { useFlag, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' -import InviteMemberModal from './InviteMemberModal' +import InviteMemberModal from './InviteMemberButton' import MembersView from './MembersView' +import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' import { PageContext } from 'pages/org/[slug]/settings' -import { PermissionAction } from '@supabase/shared-types/out/constants' const TeamSettings = observer(() => { const PageState: any = useContext(PageContext) - const { ui } = useStore() - const [isLeaving, setIsLeaving] = useState(false) - const enablePermissions = useFlag('enablePermissions') + const { user, members, roles, rolesAddable } = PageState - const slug = ui.selectedOrganization?.slug + const { ui } = useStore() + const slug = ui.selectedOrganization?.slug ?? '' const isOrgOwner = ui.selectedOrganization?.is_owner - const canAddMembers = enablePermissions - ? checkPermissions(PermissionAction.SQL_INSERT, 'postgres.auth.subject_roles') - : isOrgOwner + + const enablePermissions = useFlag('enablePermissions') + const [isLeaving, setIsLeaving] = useState(false) + + const canAddMembers = enablePermissions ? rolesAddable.length > 0 : isOrgOwner function onFilterMemberChange(e: any) { PageState.membersFilterString = e.target.value @@ -69,7 +69,12 @@ const TeamSettings = observer(() => {
{canAddMembers && (
- +
)} {!isOrgOwner && ( diff --git a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts index 982b2173db5..d93e8045b58 100644 --- a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts +++ b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts @@ -2,28 +2,48 @@ import { Role } from 'types' import { checkPermissions } from 'hooks' import { PermissionAction } from '@supabase/shared-types/out/constants' -export const getRolesManagementPermissions = (roles: Role[]) => { - if (!roles) return roles +export const getRolesManagementPermissions = ( + roles: Role[] +): { rolesAddable: Number[]; rolesRemovable: Number[] } => { + const rolesAddable: Number[] = [] + const rolesRemovable: Number[] = [] + if (!roles) return { rolesAddable, rolesRemovable } - const rolesPermissions: any = {} const availableRoles = ['Owner', 'Administrator', 'Developer'] + // [Joshen TODO] Hardcoded logic until BE is ready availableRoles.forEach((role: string) => { const selectedRole = roles.find((r) => r.name === role) if (!selectedRole) return - const canChangeTo = checkPermissions( - PermissionAction.SQL_INSERT, - 'postgres.auth.subject_roles', - { resource: { role_id: selectedRole!.id } } - ) - const canChangeFrom = checkPermissions( - PermissionAction.SQL_DELETE, - 'postgres.auth.subject_roles', - { resource: { role_id: selectedRole!.id } } - ) - rolesPermissions[selectedRole!.id] = { canChangeTo, canChangeFrom } + // This is if the logged in user is an Owner + rolesAddable.push(selectedRole.id) + rolesRemovable.push(selectedRole.id) + + // This is if the logged in user is an Admin + // if (role !== 'Owner') { + // rolesAddable.push(selectedRole.id) + // rolesRemovable.push(selectedRole.id) + // } + + // Just comment out everything if doing for developer }) - return rolesPermissions + // [Joshen TODO] This is the correct logic but BE is not ready + // availableRoles.forEach((role: string) => { + // const selectedRole = roles.find((r) => r.name === role) + // if (!selectedRole) return + + // const canAdd = checkPermissions(PermissionAction.SQL_INSERT, 'postgres.auth.subject_roles', { + // resource: { role_id: selectedRole!.id }, + // }) + // if (canAdd) rolesAddable.push(selectedRole.id) + + // const canRemove = checkPermissions(PermissionAction.SQL_DELETE, 'postgres.auth.subject_roles', { + // resource: { role_id: selectedRole!.id }, + // }) + // if (canRemove) rolesRemovable.push(selectedRole.id) + // }) + + return { rolesAddable, rolesRemovable } } diff --git a/studio/pages/org/[slug]/settings.tsx b/studio/pages/org/[slug]/settings.tsx index 011c6aa73fe..50e31cdff08 100644 --- a/studio/pages/org/[slug]/settings.tsx +++ b/studio/pages/org/[slug]/settings.tsx @@ -12,6 +12,7 @@ import { BillingSettings, InvoicesSettings, } from 'components/interfaces/Organization' +import { getRolesManagementPermissions } from 'components/interfaces/Organization/TeamSettings/TeamSettings.utils' export const PageContext = createContext(null) @@ -26,11 +27,14 @@ const OrgSettingsLayout = withAuth( // [Refactor] Eventually move away from useLocalObservable const PageState: any = useLocalObservable(() => ({ user: {} as User, - organization: {} as Organization[], + organization: {} as Organization, projects: [] as Project[], members: [] as Member[], roles: [] as Role[], + rolesAddable: [] as Number[], + rolesRemovable: [] as Number[], + membersFilterString: '', get isOrgOwner() { const userMember = this.members.find( @@ -54,11 +58,15 @@ const OrgSettingsLayout = withAuth( }) return temp.sort((a: any, b: any) => a.username.localeCompare(b.username)) }, - initData(organization: Organization[], user: User, projects: Project[], roles: Role[]) { + initData(organization: Organization, user: User, projects: Project[], roles: Role[]) { this.user = user this.projects = projects this.organization = organization this.roles = roles + + const { rolesAddable, rolesRemovable } = getRolesManagementPermissions(roles) + this.rolesAddable = rolesAddable + this.rolesRemovable = rolesRemovable }, onOrgUpdated(updatedOrg: any) { app.onOrgUpdated(updatedOrg)