Skip to content

Commit

Permalink
Merge pull request #74 from marmelab/sales-policies
Browse files Browse the repository at this point in the history
Add more secure RLS policies on the sales table
  • Loading branch information
fzaninotto authored Nov 8, 2024
2 parents 515f6b0 + 8828319 commit c2bcf51
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 37 deletions.
10 changes: 9 additions & 1 deletion src/providers/supabase/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,14 @@ const dataProviderWithCustomMethods = {
id: Identifier,
data: Partial<Omit<SalesFormData, 'password'>>
) {
const { email, first_name, last_name, administrator, disabled } = data;
const {
email,
first_name,
last_name,
administrator,
avatar,
disabled,
} = data;

const { data: sale, error } = await supabase.functions.invoke<Sale>(
'users',
Expand All @@ -160,6 +167,7 @@ const dataProviderWithCustomMethods = {
last_name,
administrator,
disabled,
avatar,
},
}
);
Expand Down
2 changes: 1 addition & 1 deletion src/sales/SalesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function SalesList() {
actions={<SalesListActions />}
sort={{ field: 'first_name', order: 'ASC' }}
>
<DatagridConfigurable rowClick="edit">
<DatagridConfigurable rowClick="edit" bulkActionButtons={false}>
<TextField source="first_name" />
<TextField source="last_name" />
<TextField source="email" />
Expand Down
47 changes: 22 additions & 25 deletions src/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ import {
useGetIdentity,
useGetOne,
useNotify,
useUpdate,
useRecordContext,
} from 'react-admin';
import { useFormState } from 'react-hook-form';
import ImageEditorField from '../misc/ImageEditorField';
import { CrmDataProvider } from '../providers/types';
import { SalesFormData } from '../types';
import { Sale, SalesFormData } from '../types';
import { useMutation } from '@tanstack/react-query';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';

Expand Down Expand Up @@ -84,13 +84,13 @@ const SettingsForm = ({
isEditMode: boolean;
setEditMode: (value: boolean) => void;
}) => {
const [update] = useUpdate();
const notify = useNotify();
const record = useRecordContext<Sale>();
const { identity, refetch } = useGetIdentity();
const { isDirty } = useFormState();
const dataProvider = useDataProvider<CrmDataProvider>();

const { mutate } = useMutation({
const { mutate: updatePassword } = useMutation({
mutationKey: ['updatePassword'],
mutationFn: async () => {
if (!identity) {
Expand All @@ -110,33 +110,30 @@ const SettingsForm = ({
},
});

const { mutate: mutateSale } = useMutation({
mutationKey: ['signup'],
mutationFn: async (data: SalesFormData) => {
if (!record) {
throw new Error('Record not found');
}
return dataProvider.salesUpdate(record.id, data);
},
onSuccess: () => {
refetch();
notify('Your profile has been updated');
},
onError: () => {
notify('An error occurred. Please try again.');
},
});
if (!identity) return null;

const handleClickOpenPasswordChange = () => {
mutate();
updatePassword();
};

const handleAvatarUpdate = async (values: any) => {
await update(
'sales',
{
id: identity.id,
data: values,
previousData: identity,
},
{
onSuccess: () => {
refetch();
setEditMode(false);
notify('Your profile has been updated');
},
onError: _ => {
notify('An error occurred. Please try again', {
type: 'error',
});
},
}
);
mutateSale(values);
};

return (
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type SignUpData = {
};

export type SalesFormData = {
avatar: string;
email: string;
password: string;
first_name: string;
Expand Down
83 changes: 73 additions & 10 deletions supabase/functions/users/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,34 @@ async function updateSaleAdministrator(
.select('*');

if (!sales?.length || salesError) {
console.error('Error inviting user:', salesError);
console.error('Error updating user:', salesError);
throw salesError ?? new Error('Failed to update sale');
}
return sales.at(0);
}

async function inviteUser(req: Request) {
async function updateSaleAvatar(user_id: string, avatar: string) {
const { data: sales, error: salesError } = await supabaseAdmin
.from('sales')
.update({ avatar })
.eq('user_id', user_id)
.select('*');

if (!sales?.length || salesError) {
console.error('Error updating user:', salesError);
throw salesError ?? new Error('Failed to update sale');
}
return sales.at(0);
}

async function inviteUser(req: Request, currentUserSale: any) {
const { email, password, first_name, last_name, disabled, administrator } =
await req.json();

if (!currentUserSale.administrator) {
return createErrorResponse(401, 'Not Authorized');
}

const { data, error: userError } =
await supabaseAdmin.auth.admin.createUser({
email,
Expand Down Expand Up @@ -69,9 +87,16 @@ async function inviteUser(req: Request) {
}
}

async function patchUser(req: Request) {
const { sales_id, email, first_name, last_name, administrator, disabled } =
await req.json();
async function patchUser(req: Request, currentUserSale: any) {
const {
sales_id,
email,
first_name,
last_name,
avatar,
administrator,
disabled,
} = await req.json();
const { data: sale } = await supabaseAdmin
.from('sales')
.select('*')
Expand All @@ -82,6 +107,11 @@ async function patchUser(req: Request) {
return createErrorResponse(404, 'Not Found');
}

// Users can only update their own profile unless they are an administrator
if (!currentUserSale.administrator && currentUserSale.id !== sale.id) {
return createErrorResponse(401, 'Not Authorized');
}

const { data, error: userError } =
await supabaseAdmin.auth.admin.updateUserById(sale.user_id, {
email,
Expand All @@ -94,16 +124,42 @@ async function patchUser(req: Request) {
return createErrorResponse(500, 'Internal Server Error');
}

if (avatar) {
await updateSaleAvatar(data.user.id, avatar);
}

// Only administrators can update the administrator and disabled status
if (!currentUserSale.administrator) {
const { data: new_sale } = await supabaseAdmin
.from('sales')
.select('*')
.eq('id', sales_id)
.single();
return new Response(
JSON.stringify({
data: new_sale,
}),
{
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
}

try {
await updateSaleDisabled(data.user.id, disabled);
const sale = await updateSaleAdministrator(data.user.id, administrator);

return new Response(
JSON.stringify({
data: sale,
}),
{
headers: { 'Content-Type': 'application/json', ...corsHeaders },
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
}
);
} catch (e) {
Expand All @@ -126,18 +182,25 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ global: { headers: { Authorization: authHeader } } }
);

const { data } = await localClient.auth.getUser();
if (!data?.user) {
return createErrorResponse(401, 'Unauthorized');
}
const currentUserSale = await supabaseAdmin
.from('sales')
.select('*')
.eq('user_id', data.user.id)
.single();

if (!currentUserSale?.data) {
return createErrorResponse(401, 'Unauthorized');
}
if (req.method === 'POST') {
return inviteUser(req);
return inviteUser(req, currentUserSale.data);
}

if (req.method === 'PATCH') {
return patchUser(req);
return patchUser(req, currentUserSale.data);
}

return createErrorResponse(405, 'Method Not Allowed');
Expand Down
7 changes: 7 additions & 0 deletions supabase/migrations/20241104153231_sales_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
create schema if not exists "private";

set check_function_bodies = off;

drop policy "Enable insert for authenticated users only" on "public"."sales";

drop policy "Enable update for authenticated users only" on "public"."sales";

0 comments on commit c2bcf51

Please sign in to comment.