Skip to content

Commit 22c400a

Browse files
fix: address review security feedback — use security definer RPCs for invite flow
Replace overly permissive RLS policies with security definer functions: - get_invite_by_token: bypasses RLS for token lookup, works for anon+authenticated - accept_invite: atomically marks invite accepted and inserts member with the invite's role, preventing role escalation and column tampering Remove unused props (token, workspaceId, role) from InviteAccept component. Co-authored-by: Ona <no-reply@ona.com>
1 parent 59f7970 commit 22c400a

3 files changed

Lines changed: 93 additions & 91 deletions

File tree

src/app/(auth)/invite/[token]/page.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ export default async function InviteAcceptPage({
1616
const { token } = await params;
1717
const supabase = await createClient();
1818

19-
// Look up the invite by token
20-
const { data: invite } = await supabase
21-
.from("workspace_invites")
22-
.select("*, workspaces(name, slug)")
23-
.eq("token", token)
24-
.maybeSingle();
19+
// Look up the invite by token via security definer function.
20+
// Works for both anon and authenticated users.
21+
const { data: invites } = await supabase.rpc("get_invite_by_token", {
22+
invite_token: token,
23+
});
24+
25+
const invite = invites?.[0] ?? null;
2526

2627
if (!invite) {
2728
return (
@@ -39,16 +40,14 @@ export default async function InviteAcceptPage({
3940
}
4041

4142
if (invite.accepted_at) {
42-
// Supabase join returns the relation as an opaque type; cast is unavoidable
43-
const ws = invite.workspaces as unknown as { name: string; slug: string };
4443
return (
4544
<Card>
4645
<CardHeader>
4746
<CardTitle className="text-2xl font-semibold">
4847
Already accepted
4948
</CardTitle>
5049
<CardDescription>
51-
This invite to {ws?.name} has already been accepted.
50+
This invite to {invite.workspace_name} has already been accepted.
5251
</CardDescription>
5352
</CardHeader>
5453
</Card>
@@ -70,9 +69,6 @@ export default async function InviteAcceptPage({
7069
);
7170
}
7271

73-
// Supabase join returns the relation as an opaque type; cast is unavoidable
74-
const ws = invite.workspaces as unknown as { name: string; slug: string };
75-
7672
// Check if the current user is authenticated
7773
const {
7874
data: { user },
@@ -82,7 +78,7 @@ export default async function InviteAcceptPage({
8278
<Card>
8379
<CardHeader>
8480
<CardTitle className="text-2xl font-semibold">
85-
Join {ws?.name}
81+
Join {invite.workspace_name}
8682
</CardTitle>
8783
<CardDescription>
8884
You&apos;ve been invited to join as {invite.role === "admin" ? "an" : "a"}{" "}
@@ -91,12 +87,9 @@ export default async function InviteAcceptPage({
9187
</CardHeader>
9288
<CardContent>
9389
<InviteAccept
94-
token={token}
9590
inviteId={invite.id}
96-
workspaceId={invite.workspace_id}
97-
workspaceSlug={ws?.slug ?? ""}
91+
workspaceSlug={invite.workspace_slug ?? ""}
9892
email={invite.email}
99-
role={invite.role}
10093
isAuthenticated={!!user}
10194
userEmail={user?.email ?? null}
10295
/>

src/components/members/invite-accept.tsx

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,19 @@ import { useRouter } from "next/navigation";
55
import Link from "next/link";
66
import { createClient } from "@/lib/supabase/client";
77
import { Button } from "@/components/ui/button";
8-
import type { InviteRole } from "@/lib/types";
98

109
interface InviteAcceptProps {
11-
token: string;
1210
inviteId: string;
13-
workspaceId: string;
1411
workspaceSlug: string;
1512
email: string;
16-
role: InviteRole;
1713
isAuthenticated: boolean;
1814
userEmail: string | null;
1915
}
2016

2117
export function InviteAccept({
2218
inviteId,
23-
workspaceId,
2419
workspaceSlug,
2520
email,
26-
role,
2721
isAuthenticated,
2822
userEmail,
2923
}: InviteAcceptProps) {
@@ -76,52 +70,23 @@ export function InviteAccept({
7670

7771
const supabase = createClient();
7872

79-
const {
80-
data: { user },
81-
} = await supabase.auth.getUser();
82-
83-
if (!user) {
84-
setError("You must be signed in to accept this invite.");
85-
setAccepting(false);
86-
return;
87-
}
88-
89-
// Insert the member row
90-
const { error: memberError } = await supabase.from("members").insert({
91-
workspace_id: workspaceId,
92-
user_id: user.id,
93-
role,
94-
joined_at: new Date().toISOString(),
73+
// Use the security definer RPC which atomically marks the invite as
74+
// accepted and inserts the member with the correct role from the invite.
75+
const { error: acceptError } = await supabase.rpc("accept_invite", {
76+
invite_id: inviteId,
9577
});
9678

97-
if (memberError) {
98-
// If already a member, treat as success
99-
if (memberError.message.includes("duplicate key")) {
100-
// Mark invite as accepted anyway
101-
await supabase
102-
.from("workspace_invites")
103-
.update({ accepted_at: new Date().toISOString() })
104-
.eq("id", inviteId);
105-
79+
if (acceptError) {
80+
// Duplicate key means already a member — treat as success
81+
if (acceptError.message.includes("duplicate key")) {
10682
router.push(`/${workspaceSlug}`);
10783
return;
10884
}
109-
setError(memberError.message);
85+
setError(acceptError.message);
11086
setAccepting(false);
11187
return;
11288
}
11389

114-
// Mark the invite as accepted
115-
const { error: acceptError } = await supabase
116-
.from("workspace_invites")
117-
.update({ accepted_at: new Date().toISOString() })
118-
.eq("id", inviteId);
119-
120-
if (acceptError) {
121-
// Member was already created, so redirect anyway
122-
console.error("Failed to mark invite as accepted:", acceptError.message);
123-
}
124-
12590
router.push(`/${workspaceSlug}`);
12691
}
12792

Lines changed: 75 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,83 @@
1-
-- Additional RLS policies for the workspace members invite/accept flow.
1+
-- Additional RLS policies and functions for the workspace members invite/accept flow.
22
-- The base tables and core policies exist in 20260415092907_create_schema.sql.
33

4-
-- Allow any authenticated user to read an invite by its token.
5-
-- Needed for the /invite/[token] accept page where the user
6-
-- may not be the admin but is the invitee.
7-
create policy "authenticated users can read invite by token"
8-
on workspace_invites for select
9-
to authenticated
10-
using (true);
4+
-- Lookup an invite by token. Uses security definer to bypass RLS so both
5+
-- anon (unauthenticated) and authenticated users can view the invite page.
6+
-- Tokens are unguessable UUIDs, so exposing a single row by exact match is safe.
7+
create or replace function get_invite_by_token(invite_token text)
8+
returns table (
9+
id uuid,
10+
workspace_id uuid,
11+
email text,
12+
role invite_role,
13+
invited_by uuid,
14+
token text,
15+
expires_at timestamptz,
16+
accepted_at timestamptz,
17+
created_at timestamptz,
18+
workspace_name text,
19+
workspace_slug text
20+
)
21+
language sql
22+
stable
23+
security definer
24+
set search_path = ''
25+
as $$
26+
select
27+
wi.id,
28+
wi.workspace_id,
29+
wi.email,
30+
wi.role,
31+
wi.invited_by,
32+
wi.token,
33+
wi.expires_at,
34+
wi.accepted_at,
35+
wi.created_at,
36+
w.name as workspace_name,
37+
w.slug as workspace_slug
38+
from public.workspace_invites wi
39+
join public.workspaces w on w.id = wi.workspace_id
40+
where wi.token = invite_token;
41+
$$;
1142

12-
-- Allow invited users to mark their own invite as accepted.
13-
-- Matches on email (case-insensitive) and only allows updating unaccepted invites.
14-
create policy "invited users can accept their invite"
15-
on workspace_invites for update
16-
to authenticated
17-
using (lower(email) = lower(auth.jwt() ->> 'email') and accepted_at is null)
18-
with check (lower(email) = lower(auth.jwt() ->> 'email'));
43+
-- Accept an invite: marks the invite as accepted and inserts the member row.
44+
-- Uses security definer to enforce that only accepted_at is set on the invite
45+
-- and the member role matches the invite role (preventing escalation).
46+
create or replace function accept_invite(invite_id uuid)
47+
returns void
48+
language plpgsql
49+
security definer
50+
set search_path = ''
51+
as $$
52+
declare
53+
v_invite record;
54+
begin
55+
-- Lock and validate the invite
56+
select * into v_invite
57+
from public.workspace_invites
58+
where id = invite_id
59+
and lower(email) = lower(auth.jwt() ->> 'email')
60+
and accepted_at is null
61+
and expires_at > now()
62+
for update;
1963

20-
-- Allow users to insert themselves as a member when accepting an invite.
21-
-- Verifies a valid, unexpired, unaccepted invite exists for their email.
22-
create policy "invited users can join via invite"
23-
on members for insert
24-
to authenticated
25-
with check (
26-
user_id = auth.uid()
27-
and exists (
28-
select 1 from workspace_invites
29-
where workspace_invites.workspace_id = members.workspace_id
30-
and lower(workspace_invites.email) = lower(auth.jwt() ->> 'email')
31-
and workspace_invites.accepted_at is null
32-
and workspace_invites.expires_at > now()
33-
)
34-
);
64+
if not found then
65+
raise exception 'Invalid, expired, or already accepted invite';
66+
end if;
67+
68+
-- Mark invite as accepted
69+
update public.workspace_invites
70+
set accepted_at = now()
71+
where id = invite_id;
72+
73+
-- Insert member with the role from the invite (prevents role escalation)
74+
insert into public.members (workspace_id, user_id, role, joined_at)
75+
values (v_invite.workspace_id, auth.uid(), v_invite.role::text::public.member_role, now())
76+
on conflict (workspace_id, user_id) do nothing;
77+
end;
78+
$$;
3579

3680
-- Allow members to remove themselves from a workspace (leave).
3781
create policy "members can remove themselves"
3882
on members for delete
39-
using (user_id = auth.uid());
83+
using (user_id = auth.uid());

0 commit comments

Comments
 (0)