이전 글을 작성하고 나서 RLS를 적용하던 중, 의문이 생겼다.
문제상황
RLS를 위 이미지처럼 적용해주려고 했다.
owner / authenticated / every는 각각 다음과 같다.
- owner: 본인이 작성한 글에만 접근 가능
- authenticated: 로그인한 유저에게만 접근 가능
- every: 모든 유저에게 접근 가능
select / insert / update / delete는 각각 다음과 같다.
- select: 데이터 조회
- insert: 데이터 추가
- update: 데이터 수정
- delete: 데이터 삭제
즉, 로그인한 유저에게만 데이터 조회가 가능하고,
데이터의 생성 / 수정 / 삭제는 본인의 글에만 가능하도록 하고 싶었다.
하지만, RLS를 적용하고 나서 문제가 발생했다.
본인글에 대한 데이터는 정상적으로 삭제가 잘 된다.
하지만 본인글이 아닌 경우 삭제는 불가능하지만,
mutate의 onSuccess 메서드 내 소스코드가 동작하는 것이다.
재현하기
UI & 비즈니스로직 구현
테스트해보자.
해당 버튼을 눌렀을 때, Toast 메시지를 띄우고 삭제를 해보자.
// src/lib/table/columns.tsx
export const columns: ColumnDef<TaskProps>[] = [
//...
{
id: 'actions',
cell: ({ row }) => {
const selectedTask = row.original;
const deleteMutation = useTaskDeleteMutation();
const onDelete = ({ id }: { id: string }) => {
deleteMutation.mutate(id);
};
return (
<DropdownMenu>
// trigger
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
// content
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDelete({ id: selectedTask.id })}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
Tanstack-table을 사용하고 있어서, columns.tsx
내 id action으로 DropdownMenu를 만들어주었다.
const deleteTask = async (id: string) => {
const { error } = await supabase.from(TASK).delete().eq('id', id);
if (error) {
throw new Error(error.message);
}
return {
message: '데이터를 성공적으로 삭제하였습니다.',
};
};
export const useTaskDeleteMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTask,
onSuccess: ({ message }) => {
queryClient.invalidateQueries({ queryKey: taskKeys.all });
toast.success(message);
},
onError: (error) => {
toast.error(error.message);
},
});
};
useTaskDeleteMutation
을 만들어주었다.
supabase의 DB 테이블 내, id와 동일한 데이터를 삭제해줄 것이다.
Delete RLS 적용
이전 글에서 RLS Select 적용방법은 작성해두었다.
Delete도 적용해보자.
supabase에서 제공해주는 Delete Template에서 하나만 수정했는데,
to
절에 authenticated
를 추가해주었다.
create policy "Enable delete for users based on user_id"
on "public"."tasks_rls"
as PERMISSIVE
for DELETE
to authenticated -- public → authenticated
using (
(select auth.uid()) = user_id
);
이제 로그인한 사용자만 삭제 가능한지 확인해보자.
하지만 위에서 언급했던 문제의 상황이 발생했다.
삭제 문구는 정상적으로 동작하는데, 삭제가 되지 않는 것이다.
명확히 구분하기 위해 columns을 하나 더 추가해보았다.
export const columns: ColumnDef<TaskProps>[] = [
//...
{
accessorKey: 'author',
header: 'Author',
cell: ({ row }) => {
const userId = row.original.userId;
const { session } = useLogin();
const isMyTask = session?.user?.id === userId;
const authorText = isMyTask ? '내가 작성함' : '내가 작성안함';
const textColor = isMyTask ? 'text-blue-600' : 'text-red-600';
return <div className={`font-medium ${textColor}`}>{authorText}</div>;
},
},
//...
];
내가 작성한 글일 경우, ‘내가 작성함’이라는 문구가 뜨도록 하였다.
위 GIF처럼, 내가 작성한 글(파란색)은 삭제가 되지만,
내가 작성하지 않은 글(빨간색)은 삭제되지 않는다.
하지만, 메시지는 여전히 데이터를 성공적으로 삭제하였습니다.
가 떠서 혼란스럽다.
네트워크 탭을 열어서 확인해보면, 사실 아무것도 반환하지 않는다.
원인분석
처음엔 버그라고 생각했다.
supabase의 github issue에서 관련된 내용을 검색하다가 이슈 하나를 발견했다.
나와 동일한 문제를 경험한 것 같았고, 답변에서 버그가 아닌 것을 확인했다.
그럼 권한 없는 사람이 Delete 요청을 보내면, 적절한 메시지를 띄울 수 있는 방법은 없는 것일까..?
해결하기
Claude에게 해당 고민에 대해 질문했는데, 코드를 하나 짜주었다.
CREATE OR REPLACE FUNCTION public.delete_task(task_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
DECLARE
task_exists BOOLEAN;
rows_affected INT;
result JSONB;
BEGIN
-- 작업 삭제 시도 (RLS가 자동으로 적용됨)
WITH deleted_rows AS (
DELETE FROM tasks_rls
WHERE id = task_id
RETURNING id
)
SELECT COUNT(*) INTO rows_affected FROM deleted_rows;
IF rows_affected = 0 THEN
-- 작업이 존재하는지 확인
SELECT EXISTS(SELECT 1 FROM tasks_rls WHERE id = task_id) INTO task_exists;
IF task_exists THEN
RAISE EXCEPTION '작업을 삭제할 권한이 없습니다.' USING ERRCODE = 'UNAUTHORIZED';
ELSE
RAISE EXCEPTION '작업을 찾을 수 없습니다.' USING ERRCODE = 'NOT_FOUND';
END IF;
END IF;
result := jsonb_build_object('success', true, 'message', '작업이 성공적으로 삭제되었습니다.');
RETURN result;
END;
$$;
SQL문법은 하나도 몰라서 검색을 좀 해봤는데, Database Function이라는 것을 알게됐다.
그리고 이를 통해 Postgres에 원하는 동작을 수행할 수 있다.
위와 같이 붙여넣고 Run을 실행했을 때, Success가 뜨는 것을 확인했다.
테스트 해보자.
권한이 없는 아이디로 삭제버튼을 클릭했을 때, 네트워크 탭에서 다음과 같은 Response를 내려준다.
이를통해 클라이언트에서 적절히 Error 메시지를 띄워줄 수 있을 것이다.
정리
사실 이게 옳은 방법인지 잘 모르겠다.
RLS를 적용해보면서, 권한이 없는 유저가 해당 데이터를 삭제하려고 할 때,
권한없음 에러가 내려오지 않는 것을 확인했고, 이를 RPC를 통해 해결해본 것이다.
한 가지 흥미로운 변화는, 내가 이 사례를 통해 SQL에 관심이 간다는 점이다.
필요하지 않으면 학습하지 않았는데, 이번 계기로 SQL에 대해 조금 더 알아보고 싶어졌다.
적어도, 위에서 Claude가 적어준 SQL문을 읽고 해석할 정도는 되어야할 것 같다. 😭
참고자료
error is always null if delete is not successful due to RLS policy #902
Database Functions