From 6e5cc0086e07158e06899112dda6eae9d2ad6176 Mon Sep 17 00:00:00 2001 From: easy-street <64047794+easy-street@users.noreply.github.com> Date: Sun, 16 May 2021 22:05:19 -0500 Subject: [PATCH] feat: extend nextjs-slack-clone with RBAC --- examples/nextjs-slack-clone/.gitignore | 3 + examples/nextjs-slack-clone/README.md | 39 ++++++++++- .../nextjs-slack-clone/components/Layout.js | 21 ++++-- .../nextjs-slack-clone/components/Message.js | 31 +++++++-- .../components/TrashIcon.js | 25 +++++++ examples/nextjs-slack-clone/lib/Store.js | 67 +++++++++++++++++-- examples/nextjs-slack-clone/pages/_app.js | 53 +++++++-------- .../nextjs-slack-clone/pages/channels/[id].js | 7 ++ examples/nextjs-slack-clone/pages/index.js | 4 +- 9 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 examples/nextjs-slack-clone/components/TrashIcon.js diff --git a/examples/nextjs-slack-clone/.gitignore b/examples/nextjs-slack-clone/.gitignore index 141ec63810c..acc077f8823 100644 --- a/examples/nextjs-slack-clone/.gitignore +++ b/examples/nextjs-slack-clone/.gitignore @@ -24,3 +24,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Supabase +.supabase diff --git a/examples/nextjs-slack-clone/README.md b/examples/nextjs-slack-clone/README.md index 8d5fbafd70a..6b6cd67822e 100644 --- a/examples/nextjs-slack-clone/README.md +++ b/examples/nextjs-slack-clone/README.md @@ -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 diff --git a/examples/nextjs-slack-clone/components/Layout.js b/examples/nextjs-slack-clone/components/Layout.js index 1e05dd399da..a592e0d83be 100644 --- a/examples/nextjs-slack-clone/components/Layout.js +++ b/examples/nextjs-slack-clone/components/Layout.js @@ -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) {
-
+
+
{user?.email}
+ )} ) diff --git a/examples/nextjs-slack-clone/components/Message.js b/examples/nextjs-slack-clone/components/Message.js index 1590a0ab9c0..201012e089d 100644 --- a/examples/nextjs-slack-clone/components/Message.js +++ b/examples/nextjs-slack-clone/components/Message.js @@ -1,10 +1,27 @@ -const Message = ({ message }) => ( - <> -
-

{message.author.username}

-

{message.message}

+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 ( +
+
+ {(user?.id === message.user_id || + userRoles.some((role) => ['admin', 'moderator'].includes(role))) && ( + + )} +
+
+

{message.author.username}

+

{message.message}

+
- -) + ) +} export default Message diff --git a/examples/nextjs-slack-clone/components/TrashIcon.js b/examples/nextjs-slack-clone/components/TrashIcon.js new file mode 100644 index 00000000000..23bfaf953a1 --- /dev/null +++ b/examples/nextjs-slack-clone/components/TrashIcon.js @@ -0,0 +1,25 @@ +const TrashIcon = (props) => { + const { size = 16 } = props + + return ( + + + + + + + ) +} + +export default TrashIcon diff --git a/examples/nextjs-slack-clone/lib/Store.js b/examples/nextjs-slack-clone/lib/Store.js index 30dc1eaf905..402aecd01be 100644 --- a/examples/nextjs-slack-clone/lib/Store.js +++ b/examples/nextjs-slack-clone/lib/Store.js @@ -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) + } +} diff --git a/examples/nextjs-slack-clone/pages/_app.js b/examples/nextjs-slack-clone/pages/_app.js index ecae6586e12..b83c998dd31 100644 --- a/examples/nextjs-slack-clone/pages/_app.js +++ b/examples/nextjs-slack-clone/pages/_app.js @@ -2,64 +2,57 @@ 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}){ +export default function SupabaseSlackClone({ Component, pageProps }) { const [userLoaded, setUserLoaded] = useState(false) const [user, setUser] = useState(null) - const [session, setSession] = useState(null); + const [session, setSession] = useState(null) + const [userRoles, setUserRoles] = useState([]) useEffect(() => { - const session = supabase.auth.session(); - setSession(session); - setUser(session?.user ?? null); + const session = supabase.auth.session() + setSession(session) + setUser(session?.user ?? null) setUserLoaded(session ? true : false) if (user) { - signIn(user.id, user.email) + signIn() Router.push('/channels/[id]', '/channels/1') } - const { data: authListener } = supabase.auth.onAuthStateChange( - async (event, session) => { - setSession(session); - const currentUser = session?.user - setUser(currentUser ?? null); - setUserLoaded(!!currentUser) - if (currentUser) { - signIn(currentUser.id, currentUser.email) - Router.push('/channels/[id]', '/channels/1') - } + const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => { + setSession(session) + const currentUser = session?.user + setUser(currentUser ?? null) + setUserLoaded(!!currentUser) + if (currentUser) { + signIn() + Router.push('/channels/[id]', '/channels/1') } - ) + }) return () => { authListener.unsubscribe() } }, [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 () => { const result = await supabase.auth.signOut() Router.push('/') - } + } return ( diff --git a/examples/nextjs-slack-clone/pages/channels/[id].js b/examples/nextjs-slack-clone/pages/channels/[id].js index 3274de0cbdc..ffb4891d38b 100644 --- a/examples/nextjs-slack-clone/pages/channels/[id].js +++ b/examples/nextjs-slack-clone/pages/channels/[id].js @@ -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 ( diff --git a/examples/nextjs-slack-clone/pages/index.js b/examples/nextjs-slack-clone/pages/index.js index fad9d73c383..42ef1679e23 100644 --- a/examples/nextjs-slack-clone/pages/index.js +++ b/examples/nextjs-slack-clone/pages/index.js @@ -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('')