Merge pull request #1587 from easy-street/feature/add-slack-clone-rbac

extend nextjs-slack-clone example with RBAC
This commit is contained in:
Ant Wilson authored and GitHub committed 2021-05-25 13:46:15 +08:00
commit eed9c9db1e
9 files changed
+181 -33

No files matched your search

+3
View File
@@ -24,3 +24,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Supabase
.supabase
+38 -1
View File
@@ -114,6 +114,17 @@ Visit http://localhost:3000 and start chatting! Open a channel across two browse
## Supabase details
### Role-based access control (RBAC)
Use [plus addressing](https://en.wikipedia.org/wiki/Email_address#Subaddressing) to sign up users with the `admin` & `moderator` roles. Email addresses including `+supaadmin@` will be assigned the `admin` role, and email addresses including `+supamod@` will be assigned the `moderator` role. For example:
```
// admin user
email+supaadmin@example.com
// moderator user
email+supamod@example.com
```
Users with the `moderator` role can delete all messages. Users with the `admin` role can delete all messages and channels (note: it's not recommended to delete the `public` channel).
### Postgres Row level security
This project uses very high-level Authorization using Postgres' Role Level Security.
@@ -124,6 +135,26 @@ We can use these details to provide fine-grained control over what each user can
This is a trimmed-down schema, with the policies:
```sql
-- authorize with role-based access control (RBAC)
CREATE FUNCTION public.authorize(
requested_permission app_permission,
user_id uuid
)
RETURNS BOOLEAN AS $$
DECLARE
bind_permissions int;
BEGIN
SELECT count(*)
FROM public.role_permissions
INNER JOIN public.user_roles on role_permissions.role = user_roles.role
WHERE role_permissions.permission = authorize.requested_permission
AND user_roles.user_id = authorize.user_id
INTO bind_permissions;
RETURN bind_permissions > 0;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- USER PROFILES
CREATE TYPE public.user_status AS ENUM ('ONLINE', 'OFFLINE');
CREATE TABLE public.users (
@@ -143,7 +174,11 @@ CREATE TABLE public.channels (
slug text NOT NULL UNIQUE
);
ALTER TABLE public.channels ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow logged-in full access" on public.channels FOR ALL USING ( auth.role() = 'authenticated' );
CREATE POLICY "Allow logged-in read access" on public.channels FOR SELECT USING ( auth.role() = 'authenticated' );
CREATE POLICY "Allow individual insert access" on public.channels FOR INSERT WITH CHECK ( auth.uid() = created_by );
CREATE POLICY "Allow individual update access" on public.channels FOR UPDATE USING ( auth.uid() = created_by );
CREATE POLICY "Allow individual delete access" on public.channels FOR DELETE USING ( auth.uid() = created_by );
CREATE POLICY "Allow authorized delete access" on public.channels FOR DELETE USING ( authorize('channels.delete', auth.uid()) );
-- MESSAGES
CREATE TABLE public.messages (
@@ -157,6 +192,8 @@ ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow logged-in read access" on public.messages FOR SELECT USING ( auth.role() = 'authenticated' );
CREATE POLICY "Allow individual insert access" on public.messages FOR INSERT WITH CHECK ( auth.uid() = user_id );
CREATE POLICY "Allow individual update access" on public.messages FOR UPDATE USING ( auth.uid() = user_id );
CREATE POLICY "Allow individual delete access" on public.messages FOR DELETE USING ( auth.uid() = user_id );
CREATE POLICY "Allow authorized delete access" on public.messages FOR DELETE USING ( authorize('messages.delete', auth.uid()) );
```
## Authors
@@ -1,10 +1,11 @@
import Link from 'next/link'
import { useContext } from 'react'
import UserContext from '~/lib/UserContext'
import { addChannel } from '~/lib/Store'
import { addChannel, deleteChannel } from '~/lib/Store'
import TrashIcon from '~/components/TrashIcon'
export default function Layout(props) {
const { signOut } = useContext(UserContext)
const { signOut, user, userRoles } = useContext(UserContext)
const slugify = (text) => {
return text
@@ -20,7 +21,7 @@ export default function Layout(props) {
const newChannel = async () => {
const slug = prompt('Please enter your name')
if (slug) {
addChannel(slugify(slug))
addChannel(slugify(slug), user.id)
}
}
@@ -41,7 +42,8 @@ export default function Layout(props) {
</button>
</div>
<hr className="m-2" />
<div className="p-2">
<div className="p-2 flex flex-col space-y-2">
<h6 className="text-xs">{user?.email}</h6>
<button
className="bg-blue-900 hover:bg-blue-800 text-white py-2 px-4 rounded w-full transition duration-150"
onClick={() => signOut()}
@@ -57,6 +59,8 @@ export default function Layout(props) {
channel={x}
key={x.id}
isActiveChannel={x.id === props.activeChannelId}
user={user}
userRoles={userRoles}
/>
))}
</ul>
@@ -69,12 +73,17 @@ export default function Layout(props) {
)
}
const SidebarItem = ({ channel, isActiveChannel }) => (
const SidebarItem = ({ channel, isActiveChannel, user, userRoles }) => (
<>
<li>
<li className="flex items-center justify-between">
<Link href="/channels/[id]" as={`/channels/${channel.id}`}>
<a className={isActiveChannel ? 'font-bold' : ''}>{channel.slug}</a>
</Link>
{channel.id !== 1 && (channel.created_by === user?.id || userRoles.includes('admin')) && (
<button onClick={() => deleteChannel(channel.id)}>
<TrashIcon />
</button>
)}
</li>
</>
)
@@ -1,10 +1,27 @@
const Message = ({ message }) => (
<>
<div className="py-1">
<p className="text-blue-700 font-bold">{message.author.username}</p>
<p className="text-white">{message.message}</p>
import { useContext } from 'react'
import UserContext from '~/lib/UserContext'
import { deleteMessage } from '~/lib/Store'
import TrashIcon from '~/components/TrashIcon'
const Message = ({ message }) => {
const { user, userRoles } = useContext(UserContext)
return (
<div className="py-1 flex items-center space-x-2">
<div className="text-gray-100 w-4">
{(user?.id === message.user_id ||
userRoles.some((role) => ['admin', 'moderator'].includes(role))) && (
<button onClick={() => deleteMessage(message.id)}>
<TrashIcon />
</button>
)}
</div>
<div>
<p className="text-blue-700 font-bold">{message.author.username}</p>
<p className="text-white">{message.message}</p>
</div>
</div>
</>
)
)
}
export default Message
@@ -0,0 +1,25 @@
const TrashIcon = (props) => {
const { size = 16 } = props
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-trash-2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)
}
export default TrashIcon
+62 -5
View File
@@ -16,25 +16,29 @@ export const useStore = (props) => {
const [newMessage, handleNewMessage] = useState(null)
const [newChannel, handleNewChannel] = useState(null)
const [newOrUpdatedUser, handleNewOrUpdatedUser] = useState(null)
const [deletedChannel, handleDeletedChannel] = useState(null)
const [deletedMessage, handleDeletedMessage] = useState(null)
// Load initial data and set up listeners
useEffect(() => {
// Get Channels
fetchChannels(setChannels)
// Listen for new messages
// Listen for new and deleted messages
const messageListener = supabase
.from('messages')
.on('INSERT', (payload) => handleNewMessage(payload.new))
.on('DELETE', (payload) => handleDeletedMessage(payload.old))
.subscribe()
// Listen for changes to our users
const userListener = supabase
.from('users')
.on('*', (payload) => handleNewOrUpdatedUser(payload.new))
.subscribe()
// Listen for new channels
// Listen for new and deleted channels
const channelListener = supabase
.from('channels')
.on('INSERT', (payload) => handleNewChannel(payload.new))
.on('DELETE', (payload) => handleDeletedChannel(payload.old))
.subscribe()
// Cleanup on unmount
return () => {
@@ -68,12 +72,24 @@ export const useStore = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newMessage])
// Deleted message received from postgres
useEffect(() => {
if (deletedMessage) setMessages(messages.filter((message) => message.id !== deletedMessage.id))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deletedMessage])
// New channel recieved from Postgres
useEffect(() => {
if (newChannel) setChannels(channels.concat(newChannel))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newChannel])
// Deleted channel received from postgres
useEffect(() => {
if (deletedChannel) setChannels(channels.filter((channel) => channel.id !== deletedChannel.id))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deletedChannel])
// New or updated user recieved from Postgres
useEffect(() => {
if (newOrUpdatedUser) users.set(newOrUpdatedUser.id, newOrUpdatedUser)
@@ -109,7 +125,7 @@ export const fetchChannels = async (setState) => {
*/
export const fetchUser = async (userId, setState) => {
try {
let { body } = await supabase.from('users').eq('id', userId).select(`*`)
let { body } = await supabase.from('users').select(`*`).eq('id', userId)
let user = body[0]
if (setState) setState(user)
return user
@@ -118,6 +134,20 @@ export const fetchUser = async (userId, setState) => {
}
}
/**
* Fetch all roles for the current user
* @param {function} setState Optionally pass in a hook or callback to set the state
*/
export const fetchUserRoles = async (setState) => {
try {
let { body } = await supabase.from('user_roles').select(`*`)
if (setState) setState(body)
return body
} catch (error) {
console.log('error', error)
}
}
/**
* Fetch all messages and their authors
* @param {number} channelId
@@ -140,10 +170,11 @@ export const fetchMessages = async (channelId, setState) => {
/**
* Insert a new channel into the DB
* @param {string} slug The channel name
* @param {number} user_id The channel creator
*/
export const addChannel = async (slug) => {
export const addChannel = async (slug, user_id) => {
try {
let { body } = await supabase.from('channels').insert([{ slug }])
let { body } = await supabase.from('channels').insert([{ slug, created_by: user_id }])
return body
} catch (error) {
console.log('error', error)
@@ -164,3 +195,29 @@ export const addMessage = async (message, channel_id, user_id) => {
console.log('error', error)
}
}
/**
* Delete a channel from the DB
* @param {number} channel_id
*/
export const deleteChannel = async (channel_id) => {
try {
let { body } = await supabase.from('channels').delete().match({ id: channel_id })
return body
} catch (error) {
console.log('error', error)
}
}
/**
* Delete a message from the DB
* @param {number} message_id
*/
export const deleteMessage = async (message_id) => {
try {
let { body } = await supabase.from('messages').delete().match({ id: message_id })
return body
} catch (error) {
console.log('error', error)
}
}
+6 -11
View File
@@ -2,12 +2,13 @@ import '~/styles/style.scss'
import React, { useState, useEffect } from 'react'
import Router from 'next/router'
import UserContext from 'lib/UserContext'
import { supabase } from 'lib/Store'
import { supabase, fetchUserRoles } from 'lib/Store'
export default function SupabaseSlackClone({ Component, pageProps }) {
const [userLoaded, setUserLoaded] = useState(false)
const [user, setUser] = useState(null)
const [session, setSession] = useState(null)
const [userRoles, setUserRoles] = useState([])
useEffect(() => {
const session = supabase.auth.session()
@@ -15,7 +16,7 @@ export default function SupabaseSlackClone({ Component, pageProps }) {
setUser(session?.user ?? null)
setUserLoaded(session ? true : false)
if (user) {
signIn(user.id, user.email)
signIn()
Router.push('/channels/[id]', '/channels/1')
}
@@ -35,15 +36,8 @@ export default function SupabaseSlackClone({ Component, pageProps }) {
}
}, [user])
const signIn = async (id, username) => {
const { body } = await supabase.from('users').select('id, username').eq('id', id)
const result = body[0]
// If the user exists in the users table, update the username.
// If not, create a new row.
result?.id
? await supabase.from('users').update({ id, username }).match({ id }).single()
: await supabase.from('users').insert([{ id, username }]).single()
const signIn = async () => {
await fetchUserRoles((userRoles) => setUserRoles(userRoles.map((userRole) => userRole.role)))
}
const signOut = async () => {
@@ -56,6 +50,7 @@ export default function SupabaseSlackClone({ Component, pageProps }) {
value={{
userLoaded,
user,
userRoles,
signIn,
signOut,
}}
@@ -22,6 +22,13 @@ const ChannelsPage = (props) => {
})
}, [messages])
// redirect to public channel when current channel is deleted
useEffect(() => {
if (!channels.some((channel) => channel.id === Number(channelId))) {
router.push('/channels/1')
}
}, [channels, channelId])
// Render the channels and messages
return (
<Layout channels={channels} activeChannelId={channelId}>
+1 -3
View File
@@ -1,9 +1,7 @@
import { useState, useContext } from 'react'
import UserContext from 'lib/UserContext'
import { useState } from 'react'
import { supabase } from 'lib/Store'
const Home = () => {
const { signIn } = useContext(UserContext)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')