Implement ABAC in greater granularity for roles management in org team members

This commit is contained in:
Joshen Lim committed 2022-08-01 12:11:46 +08:00
1 parent 18fcc1d1ec
commit ee3e598423
7 files changed
+232 -136

No files matched your search

@@ -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 || ''
}
@@ -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<Props> = ({ members = [], user }) => {
const InviteMemberButton: FC<Props> = ({ user, members = [], roles = [], rolesAddable = [] }) => {
const { ui } = useStore()
const router = useRouter()
const { slug } = router.query
@@ -22,7 +24,6 @@ const InviteMemberModal: FC<Props> = ({ 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<Props> = ({ members = [], user }) => {
<>
<Button onClick={() => setIsOpen(true)}>Invite</Button>
<Modal
size="small"
hideFooter
size="medium"
layout="vertical"
className="!overflow-visible"
visible={isOpen}
onCancel={() => 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
>
<Modal.Content>
<Form validationSchema={schema} initialValues={initialValues} onSubmit={onInviteMember}>
{({ 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])
<Form validationSchema={schema} initialValues={initialValues} onSubmit={onInviteMember}>
{({ 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 (
<div className="w-full py-4">
<div className="space-y-4">
{roles && enablePermissions && (
<Select name="role" label="Member role">
{roles.map((role: any) => (
<Select.Option key={role.id} value={role.id}>
{role.name}
</Select.Option>
))}
</Select>
)}
const selectedRole = roles.find((role) => role.id === Number(values.role))
const invalidRoleSelected = enablePermissions
? values.role && !rolesAddable.includes(Number(values.role))
: false
<Input
autoFocus
id="email"
icon={<IconMail />}
placeholder="Enter email address"
label="Email address"
/>
return (
<>
<Modal.Content>
<div className="w-full py-4">
<div className="space-y-4">
<div className="space-y-2">
{roles && enablePermissions && (
<Select
name="role"
label="Member role"
error={
invalidRoleSelected
? `You need additional permissions to assign users the role of ${selectedRole?.name}`
: ''
}
>
{roles.map((role: any) => (
<Select.Option key={role.id} value={role.id}>
{role.name}
</Select.Option>
))}
</Select>
)}
</div>
<Input
autoFocus
id="email"
icon={<IconMail />}
placeholder="Enter email address"
label="Email address"
/>
</div>
</div>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<div className="pt-2 pb-3">
<Button
block
size="medium"
htmlType="submit"
disabled={isSubmitting}
disabled={isSubmitting || invalidRoleSelected}
loading={isSubmitting}
>
Invite new member
</Button>
</div>
</div>
)
}}
</Form>
</Modal.Content>
</Modal.Content>
</>
)
}}
</Form>
</Modal>
</>
)
}
export default InviteMemberModal
export default InviteMemberButton
@@ -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<Props> = ({ members, member, roles }) => {
const MemberActions: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ members, member, roles }) => {
}
}
if (!canRemoveMember || (isPendingInviteAcceptance && !canResendInvite && !canRevokeInvite)) {
return (
<div className="flex items-center justify-end">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button as="span" type="text" icon={<IconMoreHorizontal />} />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow', // background
'border-scale-200 border ', //border
].join(' ')}
>
<span className="text-scale-1200 text-xs">
You need additional permissions to manage this team member
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
)
}
return (
<div className="flex items-center justify-end">
<Dropdown
@@ -151,7 +193,7 @@ const OwnerDropdown: FC<Props> = ({ members, member, roles }) => {
align="end"
overlay={
<>
{!enablePermissions && !member.invited_at && (
{!enablePermissions && !isPendingInviteAcceptance && (
<>
<Dropdown.Item onClick={() => setOwnerTransferIsVisible(!ownerTransferIsVisible)}>
<div className="flex flex-col">
@@ -162,25 +204,31 @@ const OwnerDropdown: FC<Props> = ({ members, member, roles }) => {
<Dropdown.Seperator />
</>
)}
{member.invited_at ? (
{isPendingInviteAcceptance ? (
<>
<Dropdown.Item onClick={() => handleRevokeInvitation(member)}>
<div className="flex flex-col">
<p>Cancel invitation</p>
<p className="block opacity-50">Revoke this invitation.</p>
</div>
</Dropdown.Item>
<Dropdown.Seperator />
<Dropdown.Item onClick={() => handleResendInvite(member)}>
<div className="flex flex-col">
<p>Resend invitation</p>
<p className="block opacity-50">Invites expire after 24hrs.</p>
</div>
</Dropdown.Item>
{canResendInvite && (
<Dropdown.Item onClick={() => handleRevokeInvitation(member)}>
<div className="flex flex-col">
<p>Cancel invitation</p>
<p className="block opacity-50">Revoke this invitation.</p>
</div>
</Dropdown.Item>
)}
{canRevokeInvite && (
<>
<Dropdown.Seperator />
<Dropdown.Item onClick={() => handleResendInvite(member)}>
<div className="flex flex-col">
<p>Resend invitation</p>
<p className="block opacity-50">Invites expire after 24hrs.</p>
</div>
</Dropdown.Item>
</>
)}
</>
) : (
<Dropdown.Item icon={<IconTrash size="tiny" />} onClick={handleMemberDelete}>
Remove member
<Dropdown.Item icon={<IconTrash size={16} />} onClick={handleMemberDelete}>
<p>Remove member</p>
</Dropdown.Item>
)}
</>
@@ -217,4 +265,4 @@ const OwnerDropdown: FC<Props> = ({ members, member, roles }) => {
)
}
export default observer(OwnerDropdown)
export default observer(MemberActions)
@@ -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<SelectedMember>()
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 = () => {
</span>
</div>
</Tooltip.Content>
) : !canEditMemberRoles ? (
) : !canRemoveRole ? (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
@@ -213,8 +204,8 @@ const MembersView = () => {
].join(' ')}
>
<span className="text-scale-1200 text-xs">
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
</span>
</div>
</Tooltip.Content>
@@ -227,8 +218,8 @@ const MembersView = () => {
)}
</Table.td>
<Table.td>
{PageState.isOrgOwner && !memberIsUser && (
<OwnerDropdown members={PageState.members} member={x} roles={roles} />
{hasAccessToMemberActions && !memberIsUser && (
<MemberActions members={PageState.members} member={x} roles={roles} />
)}
</Table.td>
</Table.tr>
@@ -253,23 +244,24 @@ const MembersView = () => {
</div>
<Modal
visible={userRoleChangeModalVisible}
hideFooter
size="medium"
visible={userRoleChangeModalVisible}
onCancel={() => setUserRoleChangeModalVisible(false)}
header="Change role of member"
size="small"
>
<div className="flex flex-col gap-2 my-3">
<div className="flex flex-col gap-2 py-4">
<Modal.Content>
<p className="text-sm text-scale-1200 mb-3">
By changing the role of this member their permissions will change.
</p>
<p className="text-sm text-scale-1100">
You are going to change the role of {selectedMember?.primary_email} from{' '}
You are changing the role of{' '}
<span className="text-scale-1200">{getUserDisplayName(selectedMember)}</span> from{' '}
<span className="text-scale-1200">{getRoleNameById(selectedMember?.oldRoleId)}</span>{' '}
to{' '}
<span className="text-scale-1200">{getRoleNameById(selectedMember?.newRoleId)}</span>
</p>
<p className="text-sm text-scale-1200 mt-3">
By changing the role of this member their permissions will change.
</p>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
@@ -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(() => {
<div className="flex items-center space-x-4">
{canAddMembers && (
<div>
<InviteMemberModal members={PageState.members} user={PageState.user} />
<InviteMemberModal
user={user}
members={members}
roles={roles}
rolesAddable={rolesAddable}
/>
</div>
)}
{!isOrgOwner && (
@@ -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 }
}
+10 -2
View File
@@ -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)